Browse Source

Merge issue#90-Amber-login into feature/text-entry - resolved conflict in compose page

master
silberengel 8 months ago
parent
commit
2486fd54a7
  1. 13
      Dockerfile
  2. 16
      README.md
  3. 3
      deno.lock
  4. 2
      docker-compose.yaml
  5. 2286
      package-lock.json
  6. 3
      package.json
  7. 28
      playwright.config.ts
  8. 5
      postcss.config.js
  9. 54
      src/app.css
  10. 31
      src/app.html
  11. 555
      src/lib/components/CommentBox.svelte
  12. 398
      src/lib/components/EventDetails.svelte
  13. 448
      src/lib/components/EventInput.svelte
  14. 2
      src/lib/components/EventLimitControl.svelte
  15. 10
      src/lib/components/EventRenderLevelLimit.svelte
  16. 659
      src/lib/components/EventSearch.svelte
  17. 42
      src/lib/components/Login.svelte
  18. 370
      src/lib/components/LoginMenu.svelte
  19. 72
      src/lib/components/LoginModal.svelte
  20. 12
      src/lib/components/Modal.svelte
  21. 4
      src/lib/components/Navigation.svelte
  22. 179
      src/lib/components/Preview.svelte
  23. 6
      src/lib/components/Publication.svelte
  24. 382
      src/lib/components/PublicationFeed.svelte
  25. 160
      src/lib/components/PublicationHeader.svelte
  26. 143
      src/lib/components/PublicationSection.svelte
  27. 162
      src/lib/components/RelayActions.svelte
  28. 48
      src/lib/components/RelayDisplay.svelte
  29. 167
      src/lib/components/RelayStatus.svelte
  30. 12
      src/lib/components/Toc.svelte
  31. 63
      src/lib/components/cards/BlogHeader.svelte
  32. 124
      src/lib/components/cards/ProfileHeader.svelte
  33. 120
      src/lib/components/util/ArticleNav.svelte
  34. 148
      src/lib/components/util/CardActions.svelte
  35. 105
      src/lib/components/util/ContainingIndexes.svelte
  36. 30
      src/lib/components/util/CopyToClipboard.svelte
  37. 69
      src/lib/components/util/Details.svelte
  38. 70
      src/lib/components/util/Interactions.svelte
  39. 66
      src/lib/components/util/Profile.svelte
  40. 4
      src/lib/components/util/QrCode.svelte
  41. 23
      src/lib/components/util/TocToggle.svelte
  42. 80
      src/lib/components/util/ViewPublicationLink.svelte
  43. 2
      src/lib/components/util/ZapOutline.svelte
  44. 48
      src/lib/consts.ts
  45. 80
      src/lib/data_structures/publication_tree.ts
  46. 26
      src/lib/navigator/EventNetwork/Legend.svelte
  47. 44
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  48. 18
      src/lib/navigator/EventNetwork/Settings.svelte
  49. 154
      src/lib/navigator/EventNetwork/index.svelte
  50. 49
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  51. 46
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  52. 478
      src/lib/ndk.ts
  53. 429
      src/lib/parser.ts
  54. 14
      src/lib/snippets/PublicationSnippets.svelte
  55. 54
      src/lib/snippets/UserSnippets.svelte
  56. 5
      src/lib/stores.ts
  57. 11
      src/lib/stores/authStore.Svelte.ts
  58. 2
      src/lib/stores/relayStore.ts
  59. 301
      src/lib/stores/userStore.ts
  60. 8
      src/lib/types.ts
  61. 26
      src/lib/utils.ts
  62. 86
      src/lib/utils/community_checker.ts
  63. 400
      src/lib/utils/event_input_utils.ts
  64. 224
      src/lib/utils/event_search.ts
  65. 132
      src/lib/utils/indexEventCache.ts
  66. 77
      src/lib/utils/markup/MarkupInfo.md
  67. 371
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  68. 528
      src/lib/utils/markup/advancedMarkupParser.ts
  69. 213
      src/lib/utils/markup/asciidoctorExtensions.ts
  70. 136
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  71. 207
      src/lib/utils/markup/basicMarkupParser.ts
  72. 60
      src/lib/utils/markup/tikzRenderer.ts
  73. 24
      src/lib/utils/mime.ts
  74. 431
      src/lib/utils/nostrEventService.ts
  75. 397
      src/lib/utils/nostrUtils.ts
  76. 2
      src/lib/utils/npubCache.ts
  77. 328
      src/lib/utils/profile_search.ts
  78. 141
      src/lib/utils/relayDiagnostics.ts
  79. 105
      src/lib/utils/searchCache.ts
  80. 124
      src/lib/utils/search_constants.ts
  81. 69
      src/lib/utils/search_types.ts
  82. 25
      src/lib/utils/search_utility.ts
  83. 104
      src/lib/utils/search_utils.ts
  84. 656
      src/lib/utils/subscription_search.ts
  85. 27
      src/routes/+layout.svelte
  86. 90
      src/routes/+layout.ts
  87. 70
      src/routes/+page.svelte
  88. 22
      src/routes/[...catchall]/+page.svelte
  89. 17
      src/routes/about/+page.svelte
  90. 289
      src/routes/contact/+page.svelte
  91. 802
      src/routes/events/+page.svelte
  92. 1
      src/routes/new/compose/+page.svelte
  93. 62
      src/routes/new/edit/+page.svelte
  94. 40
      src/routes/publication/+error.svelte
  95. 1
      src/routes/publication/+page.svelte
  96. 46
      src/routes/publication/+page.ts
  97. 28
      src/routes/start/+page.svelte
  98. 20
      src/routes/visualize/+page.svelte
  99. 2
      src/styles/publications.css
  100. 2
      src/styles/scrollbar.css
  101. Some files were not shown because too many files have changed in this diff Show More

13
Dockerfile

@ -1,13 +0,0 @@
FROM node:23-alpine AS build
WORKDIR /app
COPY . ./
COPY package.json ./
COPY package-lock.json ./
RUN npm install
RUN npm run build
EXPOSE 80
FROM nginx:1.27.4
COPY --from=build /app/build /usr/share/nginx/html

16
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,21 +46,25 @@ 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 ```bash
deno task preview deno task preview
``` ```
@ -66,11 +74,13 @@ deno task preview
This docker container performs the build. This docker container performs the build.
To build the container: To build the container:
```bash ```bash
docker build . -t gc-alexandria docker build . -t gc-alexandria
``` ```
To run the container, in detached mode (-d): To run the container, in detached mode (-d):
```bash ```bash
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
``` ```
@ -95,25 +105,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.local .
``` ```
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
``` ```

3
deno.lock

@ -3201,8 +3201,10 @@
"npm:@types/d3@^7.4.3", "npm:@types/d3@^7.4.3",
"npm:@types/he@1.2", "npm:@types/he@1.2",
"npm:@types/node@22", "npm:@types/node@22",
"npm:@types/qrcode@^1.5.5",
"npm:asciidoctor@3.0", "npm:asciidoctor@3.0",
"npm:autoprefixer@10", "npm:autoprefixer@10",
"npm:bech32@2",
"npm:d3@^7.9.0", "npm:d3@^7.9.0",
"npm:eslint-plugin-svelte@2", "npm:eslint-plugin-svelte@2",
"npm:flowbite-svelte-icons@2.1", "npm:flowbite-svelte-icons@2.1",
@ -3217,6 +3219,7 @@
"npm:postcss@8", "npm:postcss@8",
"npm:prettier-plugin-svelte@3", "npm:prettier-plugin-svelte@3",
"npm:prettier@3", "npm:prettier@3",
"npm:qrcode@^1.5.4",
"npm:svelte-check@4", "npm:svelte-check@4",
"npm:svelte@5", "npm:svelte@5",
"npm:tailwind-merge@^3.3.0", "npm:tailwind-merge@^3.3.0",

2
docker-compose.yaml

@ -1,4 +1,4 @@
version: '3' version: "3"
services: services:
wikinostr: wikinostr:

2286
package-lock.json generated

File diff suppressed because it is too large Load Diff

3
package.json

@ -14,7 +14,7 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@nostr-dev-kit/ndk": "2.11.x", "@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x", "@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x", "@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x", "@tailwindcss/forms": "0.5.x",
@ -26,6 +26,7 @@
"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.10.x",
"plantuml-encoder": "^1.4.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {

28
playwright.config.ts

@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from "@playwright/test";
/** /**
* Read environment variables from file. * Read environment variables from file.
@ -12,7 +12,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 +22,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 +81,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 +99,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(),
]
}; };

54
src/app.css

@ -1,14 +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'; @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 {
@ -27,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 {
@ -46,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 {
@ -68,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)),
@ -89,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 {
@ -126,11 +126,11 @@
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 */
@ -143,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,
@ -152,7 +152,7 @@
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 */ /* Sidebar */
@ -188,14 +188,14 @@
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;
} }
@ -216,7 +216,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 {
@ -246,11 +246,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 */
@ -277,7 +277,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;
@ -287,7 +286,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;
} }
@ -378,7 +377,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 {
@ -396,7 +395,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;
@ -495,7 +493,7 @@
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;
} }
} }

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>

555
src/lib/components/CommentBox.svelte

@ -1,23 +1,31 @@
<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 { NostrProfile, ProfileSearchResult } from "$lib/utils/search_utility";
import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { userPubkey } from '$lib/stores/authStore.Svelte';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { onMount } from 'svelte'; import {
extractRootEventInfo,
extractParentEventInfo,
buildReplyTags,
createSignedEvent,
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { tick } from 'svelte';
import { goto } from "$app/navigation";
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);
@ -25,42 +33,97 @@
let showFallbackRelays = $state(false); let showFallbackRelays = $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 +132,204 @@
} }
function clearForm() { function clearForm() {
content = ''; content = "";
preview = ''; preview = "";
error = null; error = null;
success = null;
showOtherRelays = false; showOtherRelays = false;
showFallbackRelays = false; showFallbackRelays = 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,
useFallbackRelays = false,
) {
isSubmitting = true; isSubmitting = true;
error = null; error = null;
success = null; success = null;
try { try {
if (!props.event.kind) { const pk = $userPubkey || '';
const npub = toNpub(pk);
if (!npub) {
throw new Error('Invalid public key: must be a 64-character hex string.');
}
if (props.event.kind === undefined || props.event.kind === null) {
throw new Error('Invalid event: missing kind'); throw new Error('Invalid event: missing kind');
} }
const kind = props.event.kind === 1 ? 1 : 1111; const parent = props.event;
const tags: string[][] = []; // Use the same kind as parent for replies, or 1111 for generic replies
const kind = parent.kind === 1 ? 1 : 1111;
if (kind === 1) {
// NIP-10 reply // Extract root and parent event information
tags.push(['e', props.event.id, '', 'reply']); const rootInfo = extractRootEventInfo(parent);
tags.push(['p', props.event.pubkey]); const parentInfo = extractParentEventInfo(parent);
if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root'); // Build tags for the reply
if (rootTag) { const tags = buildReplyTags(parent, rootInfo, parentInfo, kind);
tags.push(['e', rootTag[1], '', 'root']);
// Create and sign the event
const { event: signedEvent } = await createSignedEvent(content, pk, kind, tags);
// Publish the event
const result = await publishEvent(
signedEvent,
useOtherRelays,
useFallbackRelays,
props.userRelayPreference
);
if (result.success) {
success = { relay: result.relay!, eventId: result.eventId! };
// Navigate to the published event
navigateToEvent(result.eventId!);
} else {
if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true;
error = "Failed to publish to primary relays. Would you like to try the other relays?";
} else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true;
error = "Failed to publish to other relays. Would you like to try the fallback relays?";
} else {
error = "Failed to publish comment. Please try again later.";
} }
// 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]]);
} }
}); } catch (e: unknown) {
console.error('Error publishing comment:', e);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
} finally {
isSubmitting = false;
} }
} else {
// 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 = { // Add a helper to shorten npub
kind, function shortenNpub(npub: string | undefined) {
created_at: Math.floor(Date.now() / 1000), if (!npub) return '';
tags, return npub.slice(0, 8) + '…' + npub.slice(-4);
content,
pubkey: props.userPubkey
};
const id = getEventHash(eventToSign);
const sig = await signEvent(eventToSign);
const signedEvent = {
...eventToSign,
id,
sig
};
// Determine which relays to use
let relays = props.userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = props.userRelayPreference ? standardRelays : get(userRelays);
} }
if (useFallbackRelays) {
relays = fallbackRelays; 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;
} }
// Try to publish to relays // Prevent multiple concurrent searches
let published = false; if (isSearching) {
for (const relayUrl of relays) { return;
}
console.log('Starting search for:', mentionSearch.trim());
// 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);
// Update state
ws.onopen = () => { mentionResults = result.profiles;
ws.send(JSON.stringify(['EVENT', signedEvent])); communityStatus = result.Status;
};
console.log('State updated - mentionResults length:', mentionResults.length);
ws.onmessage = (e) => { console.log('State updated - communityStatus keys:', Object.keys(communityStatus));
const [type, id, ok, message] = JSON.parse(e.data); } catch (error) {
if (type === 'OK' && id === signedEvent.id) { console.error('Error searching mentions:', error);
clearTimeout(timeout); mentionResults = [];
if (ok) { communityStatus = {};
published = true; } finally {
success = { relay: relayUrl, eventId: signedEvent.id }; mentionLoading = false;
ws.close(); isSearching = false;
resolve(); console.log('Search finished - loading:', mentionLoading, 'searching:', isSearching);
} else {
ws.close();
reject(new Error(message));
} }
} }
};
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 +339,127 @@
{#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 +469,7 @@
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 +478,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 showFallbackRelays}
<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 +511,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 +519,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 +541,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>

398
src/lib/components/EventDetails.svelte

@ -5,11 +5,21 @@
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 { standardRelays } from "$lib/consts";
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;
@ -27,69 +37,308 @@
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) {
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, standardRelays);
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) { } else if (tag[0] === 'e' && tag.length > 1) {
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 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, standardRelays);
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, standardRelays);
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, standardRelays);
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, standardRelays);
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, standardRelays);
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]}` };
}
$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, standardRelays),
link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}`,
});
// nevent // nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); ids.push({
label: "nevent",
value: neventEncode(event, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
});
// 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, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
});
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, standardRelays); const naddr = naddrEncode(event, standardRelays);
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,14 +346,31 @@
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">
@ -116,37 +382,49 @@
</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,30 +432,74 @@
<!-- 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>

448
src/lib/components/EventInput.svelte

@ -0,0 +1,448 @@
<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 { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts';
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);
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' };
const pubkey = String(currentUserPubkey);
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);
if (!ndk || !currentUserPubkey) {
error = 'NDK or pubkey missing.';
loading = false;
return;
}
const pubkey = String(currentUserPubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
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, 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,
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', ...standardRelays];
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-primary-600 dark:text-primary-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>

659
src/lib/components/EventSearch.svelte

@ -1,204 +1,601 @@
<script lang="ts"> <script lang="ts">
import { Input, Button } from "flowbite-svelte"; import { Input, Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { Spinner } from "flowbite-svelte";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; import { goto } from "$app/navigation";
import { nip19 } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { goto } from '$app/navigation'; import RelayDisplay from "./RelayDisplay.svelte";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import { searchEvent, searchBySubscription, searchNip05 } from "$lib/utils/search_utility";
import RelayDisplay from './RelayDisplay.svelte'; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
const { loading, error, searchValue, onEventFound, event } = $props<{ import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
// 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 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 || (Object.values(relayStatuses).some(s => s === "pending") && !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 = {};
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 = {};
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 = {};
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 = {};
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(() => {
if (searchValue) { // Only sync if we're not currently searching, resetting, or if the user is editing
searchEvent(false, searchValue); 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(() => { $effect(() => {
if (!searchValue || searching || isResetting || isProcessingSearch || isWaitingForSearchResult) {
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, standardRelays);
} catch {}
try {
currentNaddr = getMatchingTags(foundEvent, 'd')[0]?.[1]
? naddrEncode(foundEvent, standardRelays)
: 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, standardRelays);
} catch {}
}
if (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
) {
// Already displaying the event for this searchValue
return;
}
}
// Otherwise, trigger a search for the new value
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
isProcessingSearch = true;
isWaitingForSearchResult = true;
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;
handleSearchBySubscription('d', dTagValue);
}
});
// Simple effect to handle event prop changes
$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;
relayStatuses = {};
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;
relayStatuses = {}; // Clear relay statuses 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;
} }
// If it's a 64-char hex, try as event id first, then as pubkey (profile) // Abort any ongoing fetch
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) { if (currentAbortController) {
// Try as event id currentAbortController.abort();
filterOrId = cleanedQuery; currentAbortController = null;
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
// Always try as pubkey (profile event) as well
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()) {
handleFoundEvent(profileEvent);
} else if (eventResult) {
handleFoundEvent(eventResult);
} }
return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) { // 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);
}
function navigateToSearch(query: string, paramName: string) {
const encoded = encodeURIComponent(query);
goto(`?${paramName}=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
// Search handlers
async function handleSearchBySubscription(searchType: 'd' | 't' | 'n', searchTerm: string) {
console.log("EventSearch: Starting subscription search:", { searchType, searchTerm });
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
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; const result = await searchBySubscription(
break; searchType,
case 'note': searchTerm,
filterOrId = decoded.data; {
break; onSecondOrderUpdate: (updatedResult) => {
case 'naddr': console.log("EventSearch: Second order update:", updatedResult);
filterOrId = { onSearchResults(
kinds: [decoded.data.kind], updatedResult.events,
authors: [decoded.data.pubkey], updatedResult.secondOrder,
'#d': [decoded.data.identifier], updatedResult.tTagEvents,
}; updatedResult.eventIds,
break; updatedResult.addresses,
case 'nprofile': updatedResult.searchType,
filterOrId = { updatedResult.searchTerm
kinds: [0], );
authors: [decoded.data.pubkey], },
}; onSubscriptionCreated: (sub) => {
break; console.log("EventSearch: Subscription created:", sub);
case 'npub': if (activeSub) {
filterOrId = { activeSub.stop();
kinds: [0], }
authors: [decoded.data], activeSub = sub;
};
break;
default:
filterOrId = cleanedQuery;
} }
console.log('[Events] Using filterOrId:', filterOrId); },
currentAbortController.signal
);
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;
relayStatuses = {}; // Clear relay statuses 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.'; }
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, totalCount, searchType);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
} catch (error) {
if (error instanceof Error && error.message === 'Search cancelled') {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return; 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}`;
} }
}
relayStatuses = {}; // Clear relay statuses 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) { function handleClear() {
console.warn('[Events] Event not found for filterOrId:', filterOrId); isResetting = true;
localError = 'Event not found'; searchQuery = '';
} else { isUserEditing = false; // Reset user editing flag
console.log('[Events] Event found:', event); resetSearchState();
handleFoundEvent(event);
// 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;
relayStatuses = {};
localError = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
} }
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', query); if (onClear) {
localError = 'Error fetching event. Please check the ID and try again.'; onClear();
} }
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
}, 100);
} }
function handleFoundEvent(event: NDKEvent) { function getResultMessage(): string {
foundEvent = event; if (searchResultCount === 0) {
onEventFound(event); 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}.`;
} }
</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 -->
{#if showError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <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> </div>
{/if} {/if}
<!-- Success Display -->
{#if showSuccess}
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert">
{getResultMessage()}
</div> </div>
{/if} {/if}
<!-- Relay Status Display -->
<div class="mt-4"> <div class="mt-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]} {#each Object.entries(relayStatuses) as [relay, status]}
<RelayDisplay {relay} showStatus={true} status={status} /> <RelayDisplay {relay} showStatus={true} {status} />
{/each} {/each}
</div> </div>
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')} {#if !foundEvent && hasActiveSearch}
<div class="text-gray-500 mt-2">Searching relays...</div> <div class="text-gray-700 dark:text-gray-300 mt-2">
{/if} Searching relays...
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')} </div>
<div class="text-red-500 mt-2">Event not found on any relay.</div>
{/if} {/if}
</div> </div>
</div> </div>

42
src/lib/components/Login.svelte

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

370
src/lib/components/LoginMenu.svelte

@ -0,0 +1,370 @@
<script lang='ts'>
import { Avatar, Popover } from 'flowbite-svelte';
import { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons';
import { userStore, loginWithExtension, loginWithAmber, loginWithNpub, logoutUser } from '$lib/stores/userStore';
import { get } from 'svelte/store';
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// UI state
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;
onMount(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1') {
console.log('LoginMenu: Found fallback flag on mount, showing modal');
showAmberFallback = true;
}
});
// Subscribe to userStore
let user = $state(get(userStore));
userStore.subscribe(val => {
user = val;
// Check for fallback flag when user state changes to signed in
if (val.signedIn && localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) {
console.log('LoginMenu: User signed in and fallback flag found, showing modal');
showAmberFallback = true;
}
// Set up periodic check when user is signed in
if (val.signedIn && !fallbackCheckInterval) {
fallbackCheckInterval = setInterval(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) {
console.log('LoginMenu: Found fallback flag during periodic check, showing modal');
showAmberFallback = true;
}
}, 500); // Check every 500ms
} else if (!val.signedIn && fallbackCheckInterval) {
clearInterval(fallbackCheckInterval);
fallbackCheckInterval = null;
}
});
// 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)}`);
}
}
};
const handleLogout = () => {
localStorage.removeItem('amber/nsec');
localStorage.removeItem('alexandria/amber/fallback');
logoutUser();
};
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback');
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback');
}
function shortenNpub(long: string | undefined) {
if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4);
}
function toNullAsUndefined(val: string | null): string | undefined {
return val === null ? undefined : val;
}
function nullToUndefined(val: string | null | undefined): string | undefined {
return val === null ? undefined : val;
}
</script>
<div class="relative">
{#if !user.signedIn}
<!-- Login button -->
<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>
</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'
>
<Avatar
rounded
class='h-6 w-6 cursor-pointer'
src={user.profile?.picture || undefined}
alt={user.profile?.displayName || user.profile?.name || 'User'}
/>
</button>
<Popover
placement="bottom"
triggeredBy={`#${profileAvatarId}`}
class='popover-leather w-[220px]'
trigger='click'
>
<div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col'>
<h3 class='text-lg font-bold'>{user.profile?.displayName || user.profile?.name || (user.npub ? shortenNpub(user.npub) : 'Unknown')}</h3>
<ul class="space-y-2 mt-2">
<li>
<button
class='text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer'
onclick={() => goto(`/events?id=${user.npub}`)}
type='button'
>
{user.npub ? shortenNpub(user.npub) : 'Unknown'}
</button>
</li>
<li class="text-xs text-gray-500">
{#if user.loginMethod === 'extension'}
Logged in with extension
{:else if user.loginMethod === 'amber'}
Logged in with Amber
{:else if user.loginMethod === 'npub'}
Logged in with npub
{:else}
Unknown login method
{/if}
</li>
<li>
<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'
onclick={handleLogout}
>
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out
</button>
</li>
</ul>
</div>
</div>
</Popover>
</div>
{/if}
</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}

72
src/lib/components/LoginModal.svelte

@ -1,6 +1,7 @@
<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;
@ -10,68 +11,67 @@
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"
onclick={onClose}
> >
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span> <p class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
</button> You need to be logged in to submit an issue. Your form data will be
</div> preserved.
<!-- 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}

4
src/lib/components/Navigation.svelte

@ -7,7 +7,7 @@
NavHamburger, NavHamburger,
NavBrand, NavBrand,
} from "flowbite-svelte"; } from "flowbite-svelte";
import Login from "./Login.svelte"; import LoginMenu from "./LoginMenu.svelte";
let { class: className = "" } = $props(); let { class: className = "" } = $props();
</script> </script>
@ -19,7 +19,7 @@
</NavBrand> </NavBrand>
</div> </div>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Login /> <LoginMenu />
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">

179
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,7 +155,7 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function readBlog(rootId: string) { function readBlog(rootId: string) {
@ -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
event={getBlogEvent(index)}
{rootId}
onBlogUpdate={readBlog}
active={true}
/>
{:else} {: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>

6
src/lib/components/Publication.svelte

@ -21,7 +21,7 @@
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 TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser'; import { pharosInstance } from "$lib/parser";
let { rootAddress, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string; rootAddress: string;
@ -193,7 +193,7 @@
{:else if !isDone} {:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button> <Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else} {:else}
<p class="text-gray-500 dark:text-gray-400"> <p class="text-gray-700 dark:text-gray-300">
You've reached the end of the publication. You've reached the end of the publication.
</p> </p>
{/if} {/if}
@ -287,7 +287,7 @@
<Card class="ArticleBox card-leather w-full grid max-w-xl"> <Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2"> <div class="flex flex-col my-2">
<span>Unknown</span> <span>Unknown</span>
<span class="text-gray-500">1.1.1970</span> <span class="text-gray-700 dark:text-gray-300">1.1.1970</span>
</div> </div>
<div class="flex flex-col flex-grow space-y-4"> <div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to This is a very intelligent comment placeholder that applies to

382
src/lib/components/PublicationFeed.svelte

@ -1,200 +1,237 @@
<script lang='ts'> <script lang="ts">
import { indexKind } from '$lib/consts'; import { indexKind } from "$lib/consts";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from '$lib/utils'; import { filterValidIndexEvents, debounce } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import { Button, P, Skeleton, Spinner, Checkbox } from "flowbite-svelte";
import ArticleHeader from './PublicationHeader.svelte'; import ArticleHeader from "./PublicationHeader.svelte";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils'; 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";
let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>(); let {
relays,
fallbackRelays,
searchQuery = "",
userRelays = [],
} = $props<{
relays: string[];
fallbackRelays: string[];
searchQuery?: string;
userRelays?: string[];
}>();
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false); let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>(
{},
);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived( let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime() eventsInView?.at(eventsInView.length - 1)?.created_at ??
new Date().getTime(),
); );
// Debounced search function let allIndexEvents: NDKEvent[] = $state([]);
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(() => { async function fetchAllIndexEventsFromRelays() {
console.debug('[PublicationFeed] Search query effect triggered:', searchQuery);
debouncedSearch(searchQuery);
});
async function getEvents(before: number | undefined = undefined, search: string = '', reset: boolean = false) {
loading = true; loading = true;
const ndk = $ndkInstance; const ndk = $ndkInstance;
const primaryRelays: string[] = relays; const communityRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r)); const userRelayList: string[] = userRelays || [];
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending'])); const fallback: string[] = fallbackRelays.filter(
(r: string) => !communityRelays.includes(r) && !userRelayList.includes(r),
);
const allRelays = includeAllRelays
? [...communityRelays, ...userRelayList, ...fallback]
: [...communityRelays, ...userRelayList];
// 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;
}
relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
let allEvents: NDKEvent[] = []; let allEvents: NDKEvent[] = [];
let fetchedCount = 0; // Track number of new events
console.debug('[getEvents] Called with before:', before, 'search:', search); // Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<NDKEvent[]> {
try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk
.fetchEvents(
{
kinds: [indexKind],
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet,
)
.withTimeout(5000);
eventSet = filterValidIndexEvents(eventSet);
relayStatuses = { ...relayStatuses, [relay]: "found" };
return Array.from(eventSet);
} catch (err) {
console.error(`Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: "notfound" };
return [];
}
}
// Fetch from all relays in parallel, do not block on any single relay
const results = await Promise.allSettled(allRelays.map(fetchFromRelay));
for (const result of results) {
if (result.status === "fulfilled") {
allEvents = allEvents.concat(result.value);
}
}
// Deduplicate by tagAddress
const eventMap = new Map(
allEvents.map((event) => [event.tagAddress(), event]),
);
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
}
// Function to filter events based on search query // Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => { const filterEventsBySearch = (events: NDKEvent[]) => {
if (!search) return events; if (!searchQuery) return events;
const query = search.toLowerCase(); const query = searchQuery.toLowerCase();
console.debug('[PublicationFeed] Filtering events with query:', query, 'Total events before filter:', events.length); 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 // Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query); const isNip05Query = isValidNip05Address(query);
console.debug('[PublicationFeed] Is NIP-05 query:', isNip05Query); console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter(event => { const filtered = events.filter((event) => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? ''; const title =
const authorName = getMatchingTags(event, 'author')[0]?.[1]?.toLowerCase() ?? ''; getMatchingTags(event, "title")[0]?.[1]?.toLowerCase() ?? "";
const authorName =
getMatchingTags(event, "author")[0]?.[1]?.toLowerCase() ?? "";
const authorPubkey = event.pubkey.toLowerCase(); const authorPubkey = event.pubkey.toLowerCase();
const nip05 = getMatchingTags(event, 'nip05')[0]?.[1]?.toLowerCase() ?? ''; const nip05 =
getMatchingTags(event, "nip05")[0]?.[1]?.toLowerCase() ?? "";
// For NIP-05 queries, only match against NIP-05 tags // For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) { if (isNip05Query) {
const matches = nip05 === query; const matches = nip05 === query;
if (matches) { if (matches) {
console.debug('[PublicationFeed] Event matches NIP-05 search:', { console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id, id: event.id,
nip05, nip05,
authorPubkey authorPubkey,
}); });
} }
return matches; return matches;
} }
// For regular queries, match against all fields // For regular queries, match against all fields
const matches = ( const matches =
title.includes(query) || title.includes(query) ||
authorName.includes(query) || authorName.includes(query) ||
authorPubkey.includes(query) || authorPubkey.includes(query) ||
nip05.includes(query) nip05.includes(query);
);
if (matches) { if (matches) {
console.debug('[PublicationFeed] Event matches search:', { console.debug("[PublicationFeed] Event matches search:", {
id: event.id, id: event.id,
title, title,
authorName, authorName,
authorPubkey, authorPubkey,
nip05 nip05,
}); });
} }
return matches; return matches;
}); });
console.debug('[PublicationFeed] Events after filtering:', filtered.length);
// 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; return filtered;
}; };
// First, try primary relays // Debounced search function
let foundEventsInPrimary = false; const debouncedSearch = debounce(async (query: string) => {
await Promise.all( console.debug("[PublicationFeed] Search query changed:", query);
primaryRelays.map(async (relay: string) => { if (query.trim()) {
try { const filtered = filterEventsBySearch(allIndexEvents);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); eventsInView = filtered.slice(0, 30);
let eventSet = await ndk.fetchEvents( endOfFeed = filtered.length <= 30;
{
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 { } else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' }; eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
} }
console.debug(`[getEvents] Fetched ${eventArray.length} events from relay: ${relay} (search: "${search}")`); }, 300);
} 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 $effect(() => {
if (!foundEventsInPrimary && fallback.length > 0) { console.debug(
console.debug('[getEvents] No events found in primary relays, trying fallback relays'); "[PublicationFeed] Search query effect triggered:",
relayStatuses = { ...relayStatuses, ...Object.fromEntries(fallback.map((r: string) => [r, 'pending'])) }; searchQuery,
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' };
}
})
); );
} debouncedSearch(searchQuery);
// Deduplicate and sort });
const eventMap = reset
? new Map(allEvents.map(event => [event.tagAddress(), event])) async function loadMorePublications() {
: new Map([...eventsInView, ...allEvents].map(event => [event.tagAddress(), event])); loadingMore = true;
const uniqueEvents = Array.from(eventMap.values()); const current = eventsInView.length;
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!); let source = searchQuery.trim()
eventsInView = uniqueEvents; ? filterEventsBySearch(allIndexEvents)
const pageSize = fallback.length > 0 ? 18 : 30; : allIndexEvents;
if (fetchedCount < pageSize) { eventsInView = source.slice(0, current + 30);
endOfFeed = true; endOfFeed = eventsInView.length >= source.length;
} else { loadingMore = false;
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[] => { function getSkeletonIds(): string[] {
const skeletonHeight = 124; // The height of the skeleton component in pixels. const skeletonHeight = 124; // The height of the skeleton component in pixels.
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = []; const skeletonIds = [];
@ -204,51 +241,80 @@
return skeletonIds; return skeletonIds;
} }
async function loadMorePublications() { function getCacheStats(): string {
loadingMore = true; const indexStats = indexEventCache.getStats();
await getEvents(cutoffTimestamp, searchQuery, false); const searchStats = searchCache.size();
loadingMore = false; return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
} }
// Include all relays checkbox state
let includeAllRelays = $state(false);
// Watch for changes in include all relays setting
$effect(() => {
console.log(`[PublicationFeed] Include all relays setting changed to: ${includeAllRelays}`);
// Clear cache when relay configuration changes
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
});
onMount(async () => { onMount(async () => {
await getEvents(); await fetchAllIndexEventsFromRelays();
}); });
</script> </script>
<div class='leather'> <div class="flex flex-col space-y-4">
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'> <!-- Include all relays checkbox -->
<div class="flex items-center justify-center">
<Checkbox bind:checked={includeAllRelays} class="mr-2" />
<label for="include-all-relays" class="text-sm text-gray-700 dark:text-gray-300">
Include all relays (slower but more comprehensive search)
</label>
</div>
<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} {#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id} {#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' /> <Skeleton divClass="skeleton-leather w-full" size="lg" />
{/each} {/each}
{:else if eventsInView.length > 0} {:else if eventsInView.length > 0}
{#each eventsInView as event} {#each eventsInView as event}
<ArticleHeader {event} /> <ArticleHeader {event} />
{/each} {/each}
{:else} {:else}
<div class='col-span-full'> <div class="col-span-full">
<p class='text-center'>No publications found.</p> <p class="text-center">No publications found.</p>
</div> </div>
{/if} {/if}
</div> </div>
{#if !loadingMore && !endOfFeed} {#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'> <div class="flex justify-center mt-4 mb-8">
<Button outline class="w-full max-w-md" onclick={async () => { <Button
outline
class="w-full max-w-md"
onclick={async () => {
await loadMorePublications(); await loadMorePublications();
}}> }}
>
Show more publications Show more publications
</Button> </Button>
</div> </div>
{:else if loadingMore} {:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'> <div class="flex justify-center mt-4 mb-8">
<Button outline disabled class="w-full max-w-md"> <Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-300' size='4' /> <Spinner class="mr-3 text-gray-600 dark:text-gray-300" size="4" />
Loading... Loading...
</Button> </Button>
</div> </div>
{:else} {:else}
<div class='flex justify-center mt-4 mb-8'> <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> <P class="text-sm text-gray-700 dark:text-gray-300"
>You've reached the end of the feed.</P
>
</div> </div>
{/if} {/if}
</div> </div>

160
src/lib/components/PublicationHeader.svelte

@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from '$lib/utils'; import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from '../consts'; import { standardRelays } from "../consts";
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { goto } from '$app/navigation';
import { getUserMetadata, toNpub, getMatchingTags } from "$lib/utils/nostrUtils";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -14,51 +16,157 @@
}); });
const href = $derived.by(() => { const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1]; const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) { if (d != null) {
return `publication?d=${d}`; return `publication?d=${d}`;
} else { } else {
return `publication?id=${naddrEncode(event, relays)}`; return `publication?id=${naddrEncode(event, relays)}`;
} }
} });
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let authorTag: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? '');
let pTag: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? '');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(event.getMatchingTags('t').map((tag: string[]) => tag[1]));
// New: fetch profile display name for authorPubkey
let authorDisplayName = $state<string | undefined>(undefined);
let imageLoaded = $state(false);
let imageError = $state(false);
function isValidNostrPubkey(str: string): boolean {
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63);
}
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
function generatePastelColor(eventId: string): string {
// Use the first 6 characters of the event ID to generate a pastel color
const hash = eventId.substring(0, 6);
const r = parseInt(hash.substring(0, 2), 16);
const g = parseInt(hash.substring(2, 4), 16);
const b = parseInt(hash.substring(4, 6), 16);
// Convert to pastel by mixing with white (lightening the color)
const pastelR = Math.round((r + 255) / 2);
const pastelG = Math.round((g + 255) / 2);
const pastelB = Math.round((b + 255) / 2);
return `rgb(${pastelR}, ${pastelG}, ${pastelB})`;
}
console.log("PublicationHeader event:", event); function handleImageLoad() {
imageLoaded = true;
}
function handleImageError() {
imageError = true;
}
$effect(() => {
if (authorPubkey) {
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
authorTag ||
authorPubkey;
});
} else {
authorDisplayName = undefined;
}
});
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'> <Card
{#if image} class="ArticleBox card-leather max-w-md h-64 flex flex-row overflow-hidden"
<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 class="w-24 h-full overflow-hidden flex-shrink-0">
{#if image && !imageError}
<div class="w-full h-full relative">
<!-- Pastel placeholder -->
<div
class="w-full h-full transition-opacity duration-300"
style="background-color: {generatePastelColor(event.id)}; opacity: {imageLoaded ? '0' : '1'}"
></div>
<!-- Image -->
<img
src={image}
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
style="opacity: {imageLoaded ? '1' : '0'}"
onload={handleImageLoad}
onerror={handleImageError}
loading="lazy"
alt="Publication cover"
/>
</div> </div>
{:else}
<!-- Pastel placeholder when no image or image failed to load -->
<div
class="w-full h-full"
style="background-color: {generatePastelColor(event.id)}"
></div>
{/if} {/if}
<div class='col flex flex-row flex-grow space-x-4'> </div>
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow p-4 relative">
<a href="/{href}" class='flex flex-col space-y-2'> <div class="absolute top-2 right-2 z-10">
<CardActions {event} />
</div>
<button
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline pr-8"
onclick={() => goto(`/${href}`)}
>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
<h3 class='text-base font-normal'> <h3 class='text-base font-normal'>
by by
{#if authorPubkey != null} {#if authorTag && pTag && isValidNostrPubkey(pTag)}
{@render userBadge(authorPubkey, author)} {authorTag} {@render userBadge(pTag, '')}
{:else if authorTag}
{authorTag}
{:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, '')}
{:else if authorPubkey != null}
{@render userBadge(authorPubkey, authorDisplayName)}
{:else} {:else}
{author} unknown
{/if} {/if}
</h3> </h3>
{#if version != '1'} {#if version != "1"}
<h3 class='text-base font-thin'>version: {version}</h3> <h3
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
version: {version}
</h3>
{/if} {/if}
</a> </button>
</div> {#if hashtags.length > 0}
<div class="flex flex-col justify-start items-center"> <div class="tags mt-auto pt-2 flex flex-wrap gap-1">
<CardActions event={event} /> {#each hashtags as tag (tag)}
<button
class="text-xs text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div> </div>
{/if}
</div> </div>
</Card> </Card>
{/if} {/if}

143
src/lib/components/PublicationSection.svelte

@ -1,11 +1,16 @@
<script lang='ts'> <script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor"; import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { goto } from '$app/navigation';
let { let {
address, address,
@ -13,32 +18,53 @@
leaves, leaves,
ref, ref,
}: { }: {
address: string, address: string;
rootAddress: string, rootAddress: string;
leaves: Array<NDKEvent | null>, leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void, ref: (ref: HTMLElement) => void;
} = $props(); } = $props();
const publicationTree: PublicationTree = getContext('publicationTree'); console.debug(`[PublicationSection] Received address: ${address}`);
const asciidoctor: Asciidoctor = getContext('asciidoctor'); console.debug(`[PublicationSection] Root address: ${rootAddress}`);
console.debug(`[PublicationSection] Leaves count: ${leaves.length}`);
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => const publicationTree: PublicationTree = getContext("publicationTree");
await publicationTree.getEvent(address)); const asciidoctor: Asciidoctor = getContext("asciidoctor");
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => let leafEvent: Promise<NDKEvent | null> = $derived.by(
await publicationTree.getEvent(rootAddress)); async () => {
console.debug(`[PublicationSection] Getting event for address: ${address}`);
const event = await publicationTree.getEvent(address);
console.debug(`[PublicationSection] Retrieved event: ${event?.id}`);
return event;
},
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(async () => let publicationType: Promise<string | undefined> = $derived.by(
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
await publicationTree.getHierarchy(address)); async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(async () => let leafTitle: Promise<string | undefined> = $derived.by(
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
let leafContent: Promise<string | Document> = $derived.by(async () => {
const rawContent = (await leafEvent)?.content ?? "";
const asciidoctorHtml = asciidoctor.convert(rawContent);
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString());
});
let leafContent: Promise<string | Document> = $derived.by(async () => let leafHashtags: Promise<string[]> = $derived.by(
asciidoctor.convert((await leafEvent)?.content ?? '')); async () => (await leafEvent)?.getMatchingTags("t").map((tag: string[]) => tag[1]) ?? [],
);
let previousLeafEvent: NDKEvent | null = $derived.by(() => { let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number; let index: number;
@ -46,7 +72,7 @@
let decrement = 1; let decrement = 1;
do { do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) { if (index === 0) {
return null; return null;
} }
@ -56,15 +82,20 @@
return event; return event;
}); });
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) { if (!previousLeafEvent) {
return null; return null;
} }
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
}); },
);
let divergingBranches = $derived.by(async () => { let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([
leafHierarchy,
previousLeafHierarchy,
]);
const branches: [NDKEvent, number][] = []; const branches: [NDKEvent, number][] = [];
@ -75,13 +106,17 @@
return branches; return branches;
} }
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node. // Find the first diverging node.
let divergingIndex = 0; let divergingIndex = 0;
while ( while (
divergingIndex < minLength && divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) { ) {
divergingIndex++; divergingIndex++;
} }
@ -96,6 +131,15 @@
let sectionRef: HTMLElement; let sectionRef: HTMLElement;
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
$effect(() => { $effect(() => {
if (!sectionRef) { if (!sectionRef) {
return; return;
@ -103,19 +147,52 @@
ref(sectionRef); ref(sectionRef);
}); });
</script> </script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'> <section
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} id={address}
<TextPlaceholder size='xxl' /> bind:this={sectionRef}
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, leafEvent, leafHashtags], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, resolvedLeafEvent, hashtags]}
{@const contentString = leafContent.toString()}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)} {@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each} {/each}
{#if leafTitle} {#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1} {@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)} {@render sectionHeading(leafTitle, leafDepth)}
{/if} {/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} {@render contentParagraph(
contentString,
publicationType ?? "article",
false,
)}
{#if hashtags.length > 0}
<div class="tags my-2 flex flex-wrap gap-1">
{#each hashtags as tag (tag)}
<button
class="text-sm text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div>
{/if}
{/await} {/await}
</section> </section>

162
src/lib/components/RelayActions.svelte

@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } 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,
createNDKEvent,
} from "$lib/utils/nostrUtils";
import RelayDisplay, {
getConnectedRelays,
getEventRelays,
} from "./RelayDisplay.svelte";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
const { event } = $props<{ const { event } = $props<{
@ -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 = [...standardRelays, ...userRelays, ...fallbackRelays].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(3000);
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( { "Standard Relays": standardRelays, "User Relays": Array.from($ndkInstance?.pool?.relays.values() || []).map((r) => r.url), "Fallback Relays": fallbackRelays }, ) 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}

48
src/lib/components/RelayDisplay.svelte

@ -1,14 +1,16 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
// 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 standardRelays)
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; return standardRelays;
} }
@ -16,41 +18,57 @@
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 { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts"; 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>

167
src/lib/components/RelayStatus.svelte

@ -0,0 +1,167 @@
<script lang="ts">
import { Button, Alert } from "flowbite-svelte";
import {
ndkInstance,
ndkSignedIn,
testRelayConnection,
checkWebSocketSupport,
checkEnvironmentForWebSocketDowngrade,
} from "$lib/ndk";
import { standardRelays, anonymousRelays } from "$lib/consts";
import { onMount } from "svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, outboxRelays } from "$lib/ndk";
import { FeedType } from "$lib/consts";
interface RelayStatus {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
testing: boolean;
}
let relayStatuses = $state<RelayStatus[]>([]);
let testing = $state(false);
async function runRelayTests() {
testing = true;
const ndk = $ndkInstance;
if (!ndk) {
testing = false;
return;
}
let relaysToTest: string[] = [];
if ($feedType === FeedType.UserRelays && $ndkSignedIn) {
// Use user's relays (inbox + outbox), deduplicated
const userRelays = new Set([...$inboxRelays, ...$outboxRelays]);
relaysToTest = Array.from(userRelays);
} else {
// Use default relays (standard + anonymous), deduplicated
relaysToTest = Array.from(
new Set([...standardRelays, ...anonymousRelays]),
);
}
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>

12
src/lib/components/Toc.svelte

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
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";
export let notes: NDKEvent[] = []; export let notes: NDKEvent[] = [];
// check if notes is empty // check if notes is empty
if (notes.length === 0) { if (notes.length === 0) {
console.debug('notes is empty'); console.debug("notes is empty");
} }
</script> </script>
@ -12,7 +12,11 @@
<h2>Table of contents</h2> <h2>Table of contents</h2>
<ul> <ul>
{#each notes as note} {#each notes as note}
<li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li> <li>
<a href="#{nip19.noteEncode(note.id)}"
>{note.getMatchingTags("title")[0][1]}</a
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>

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

@ -1,23 +1,38 @@
<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, Img } 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";
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",
@ -25,7 +40,7 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function showBlog() { function showBlog() {
@ -34,25 +49,33 @@
</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">
<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} /> <CardActions {event} />
</div> </div>
{#if image && active} {#if image && active}
<div class="ArticleBoxImage flex col justify-center" <div
class="ArticleBoxImage flex col justify-center"
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" /> <Img src={image} class="rounded w-full max-h-72 object-cover" />
</div> </div>
{/if} {/if}
<div class='flex flex-col flex-grow space-y-4'> <div class="flex flex-col flex-grow space-y-4">
<button onclick={() => showBlog()} class='text-left'> <button onclick={() => showBlog()} class="text-left">
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <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,7 +86,7 @@
{/if} {/if}
</div> </div>
{#if active} {#if active}
<Interactions rootId={rootId} event={event} /> <Interactions {rootId} {event} />
{/if} {/if}
</div> </div>
</Card> </Card>

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

@ -5,43 +5,99 @@
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 { 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} {#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';}} /> <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";
}}
/>
</div> </div>
{/if} {/if}
<div class='flex flex-row space-x-4 items-center'> <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 +124,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,7 +154,18 @@
{#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>
@ -96,18 +174,28 @@
</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}

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

@ -1,35 +1,41 @@
<script lang="ts"> <script lang="ts">
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons"; import {
BookOutline,
CaretLeftOutline,
CloseOutline,
GlobeOutline,
} from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
let { let { publicationType, indexEvent } = $props<{
publicationType, rootId: any;
indexEvent publicationType: string;
} = $props<{ indexEvent: NDKEvent;
rootId: any,
publicationType: string,
indexEvent: NDKEvent
}>(); }>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]); let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null); indexEvent.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let pubkey: string = $derived(
indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
);
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let lastScrollY = $state(0); let lastScrollY = $state(0);
let isVisible = $state(true); let isVisible = $state(true);
// Function to toggle column visibility // Function to toggle column visibility
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') { function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const newValue = !current[column]; const newValue = !current[column];
const updated = { ...current, [column]: newValue }; const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === 'blog' && newValue) { if (window.innerWidth < 1400 && column === "blog" && newValue) {
updated.discussion = false; updated.discussion = false;
} }
@ -39,11 +45,13 @@
function shouldShowBack() { function shouldShowBack() {
const vis = $publicationColumnVisibility; const vis = $publicationColumnVisibility;
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]); return ["discussion", "toc", "inner"].some(
(key) => vis[key as keyof typeof vis],
);
} }
function backToMain() { function backToMain() {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const updated = { ...current }; const updated = { ...current };
// if current is 'inner', just go back to blog // if current is 'inner', just go back to blog
@ -56,7 +64,7 @@
updated.discussion = false; updated.discussion = false;
updated.toc = false; updated.toc = false;
if (publicationType === 'blog') { if (publicationType === "blog") {
updated.inner = true; updated.inner = true;
updated.blog = false; updated.blog = false;
} else { } else {
@ -68,13 +76,13 @@
} }
function backToBlog() { function backToBlog() {
publicationColumnVisibility.update(current => { publicationColumnVisibility.update((current) => {
const updated = { ...current }; const updated = { ...current };
updated.inner = false; updated.inner = false;
updated.discussion = false; updated.discussion = false;
updated.blog = true; updated.blog = true;
return updated; return updated;
}) });
} }
function handleScroll() { function handleScroll() {
@ -96,51 +104,91 @@
let unsubscribe: () => void; let unsubscribe: () => void;
onMount(() => { onMount(() => {
window.addEventListener('scroll', handleScroll); window.addEventListener("scroll", handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => { unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes isVisible = true; // show navbar when store changes
}); });
}); });
onDestroy(() => { onDestroy(() => {
window.removeEventListener('scroll', handleScroll); window.removeEventListener("scroll", handleScroll);
unsubscribe(); unsubscribe();
}); });
</script> </script>
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}"> <nav
class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
? 'translate-y-0'
: '-translate-y-full'}"
>
<div class="mx-auto flex space-x-2 container"> <div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()} {#if shouldShowBack()}
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}> <Button
<CaretLeftOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Back</span> class="btn-leather !w-auto sm:hidden"
outline={true}
onclick={backToMain}
>
<CaretLeftOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Back</span
>
</Button> </Button>
{/if} {/if}
{#if !isLeaf} {#if !isLeaf}
{#if publicationType === 'blog'} {#if publicationType === "blog"}
<Button class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog ? 'active' : ''}" <Button
outline={true} onclick={() => toggleColumn('blog')} > class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span> ? 'active'
: ''}"
outline={true}
onclick={() => toggleColumn("blog")}
>
<BookOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Table of Contents</span
>
</Button> </Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc} {:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button class='btn-leather !w-auto' outline={true} onclick={() => toggleColumn('toc')}> <Button
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span> class="btn-leather !w-auto"
outline={true}
onclick={() => toggleColumn("toc")}
>
<BookOutline class="!fill-none inline mr-1" /><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"> <div class="flex flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span></p> <p class="max-w-[60vw] line-ellipsis">
<b class="text-nowrap">{title}</b>
<span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span
>
</p>
</div> </div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner} {#if $publicationColumnVisibility.inner}
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}> <Button
<CloseOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Close</span> class="btn-leather !w-auto hidden sm:flex"
outline={true}
onclick={backToBlog}
>
<CloseOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Close</span
>
</Button> </Button>
{/if} {/if}
{#if publicationType !== 'blog' && !$publicationColumnVisibility.discussion} {#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} > <Button
<GlobeOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Discussion</span> class="btn-leather !hidden sm:flex !w-auto"
outline={true}
onclick={() => toggleColumn("discussion")}
>
<GlobeOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Discussion</span
>
</Button> </Button>
{/if} {/if}
</div> </div>

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

@ -1,22 +1,27 @@
<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 CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays, fallbackRelays, FeedType } from "$lib/consts";
import { ndkSignedIn, inboxRelays } from "$lib/ndk";
import { feedType } from "$lib/stores"; import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk"; import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { goto } from "$app/navigation";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; 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(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? ''); let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? '');
@ -41,27 +46,26 @@
*/ */
let activeRelays = $derived( let activeRelays = $derived(
(() => { (() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays; const isUserFeed = user.signedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays; const relays = isUserFeed ? user.relays.inbox : standardRelays;
console.debug("[CardActions] Selected relays:", { console.debug("[CardActions] Selected relays:", {
eventId: event.id, eventId: event.id,
isSignedIn: $ndkSignedIn, isSignedIn: user.signedIn,
feedType: $feedType, feedType: $feedType,
isUserFeed, 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 +73,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 +83,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 +93,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"
data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" /> <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 +161,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 +236,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>

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

@ -0,0 +1,105 @@
<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 { standardRelays } from "$lib/consts";
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, standardRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`);
} catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err);
}
}
}
$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}

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

@ -3,7 +3,8 @@
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';
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
@ -11,10 +12,11 @@
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(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1'); let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1');
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null); let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null);
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null); let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null);
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null); let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null);
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null); let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null);
@ -25,35 +27,59 @@
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null); 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">
<!-- Index author badge -->
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P> <P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P>
<CardActions event={event}></CardActions> <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
class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"
>
{#if image} {#if image}
<div class="my-2"> <div class="my-2">
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} /> <img
class="w-full md:max-w-48 object-contain rounded"
alt={title}
src={image}
/>
</div> </div>
{/if} {/if}
<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 +87,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 +139,5 @@
{/if} {/if}
{#if !isModal} {#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/> <Interactions {event} {rootId} direction="row" />
{/if} {/if}

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

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

@ -1,11 +1,12 @@
<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 { logoutUser } from '$lib/stores/userStore';
import { ndkInstance } from '$lib/ndk';
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from 'svelte/store';
const externalProfileDestination = './events?id=' import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();
@ -16,25 +17,32 @@ let tag = $derived(profile?.name);
let npub = $state<string | undefined>(undefined); let npub = $state<string | undefined>(undefined);
$effect(() => { $effect(() => {
const user = $ndkInstance const ndk = get(ndkInstance);
.getUser({ pubkey: pubkey ?? undefined }); if (!ndk) return;
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub; npub = user.npub;
user.fetchProfile() user.fetchProfile()
.then(userProfile => { .then((userProfile: NDKUserProfile | null) => {
profile = userProfile; profile = userProfile;
}); });
}); });
async function handleSignOutClick() { async function handleSignOutClick() {
logout($ndkInstance.activeUser!); logoutUser();
profile = null; profile = null;
} }
function handleViewProfile() {
if (npub) {
goto(`/events?id=${encodeURIComponent(npub)}`);
}
}
function shortenNpub(long: string | undefined) { function shortenNpub(long: string | undefined) {
if (!long) return ''; if (!long) return "";
return long.slice(0, 8) + '…' + long.slice(-4); return long.slice(0, 8) + "…" + long.slice(-4);
} }
</script> </script>
@ -43,7 +51,7 @@ function shortenNpub(long: string|undefined) {
<div class="group"> <div class="group">
<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}
id="profile-avatar" id="profile-avatar"
@ -52,32 +60,42 @@ function shortenNpub(long: string|undefined) {
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy="#profile-avatar" triggeredBy="#profile-avatar"
class='popover-leather w-[180px]' class="popover-leather w-[180px]"
trigger='hover' trigger="hover"
> >
<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}
{/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)}
copyText={npub}
/>
</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'> <button
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span> class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
</a> 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>
{#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}

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;

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

@ -33,13 +33,13 @@
tocUpdate; tocUpdate;
const items: TocItem[] = []; const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId); const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log('TOC rootId:', rootId, 'childIds:', childIds); console.log("TOC rootId:", rootId, "childIds:", childIds);
const processNode = (nodeId: string) => { const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId); const title = $pharosInstance.getIndexTitle(nodeId);
if (title) { if (title) {
items.push({ items.push({
label: title, label: title,
hash: `#${nodeId}` hash: `#${nodeId}`,
}); });
} }
const children = $pharosInstance.getChildIndexIds(nodeId); const children = $pharosInstance.getChildIndexIds(nodeId);
@ -83,7 +83,10 @@
*/ */
function setTocVisibilityOnResize() { function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium // Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint })); publicationColumnVisibility.update((v) => ({
...v,
toc: window.innerWidth >= tocBreakpoint,
}));
} }
/** /**
@ -98,7 +101,7 @@
// Only allow hiding TOC on screens smaller than tocBreakpoint // Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) { if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update(v => ({ ...v, toc: false})); publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
} }
} }
@ -125,14 +128,18 @@
<!-- TODO: Get TOC from parser. --> <!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc} {#if $publicationColumnVisibility.toc}
<Sidebar class='sidebar-leather left-0'> <Sidebar class="sidebar-leather left-0">
<SidebarWrapper> <SidebarWrapper>
<SidebarGroup class='sidebar-group-leather'> <SidebarGroup class="sidebar-group-leather">
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading> <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> <p>
(This ToC is only for demo purposes, and is not fully-functional.)
</p>
{#each tocItems as item} {#each tocItems as item}
<SidebarItem <SidebarItem
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}" class="sidebar-item-leather {activeHash === item.hash
? 'bg-primary-200 font-bold'
: ''}"
label={item.label} label={item.label}
href={item.hash} href={item.hash}
/> />

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

@ -0,0 +1,80 @@
<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 { standardRelays } 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, standardRelays);
} 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}

2
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

48
src/lib/consts.ts

@ -1,23 +1,43 @@
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 communityRelay = "wss://theforest.nostr1.com";
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; export const profileRelays = ["wss://profiles.nostr1.com", "wss://aggr.nostr.land", "wss://relay.noswhere.com"];
export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com",
// Removed gitcitadel.nostr1.com as it's causing connection issues
//'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu',
];
// Non-auth relays for anonymous users
export const anonymousRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com",
"wss://freelay.sovbit.host",
];
export const fallbackRelays = [ export const fallbackRelays = [
'wss://purplepag.es', "wss://purplepag.es",
'wss://indexer.coracle.social', "wss://indexer.coracle.social",
'wss://relay.noswhere.com', "wss://relay.noswhere.com",
'wss://relay.damus.io', "wss://aggr.nostr.land",
'wss://relay.nostr.band', "wss://nostr.land",
'wss://relay.lumina.rocks', "wss://nostr.wine",
'wss://nostr.wine', "wss://nostr.sovbit.host",
'wss://nostr.land' "wss://freelay.sovbit.host",
"wss://nostr21.com",
"wss://greensoul.space",
"wss://relay.damus.io",
"wss://relay.nostr.band",
]; ];
export enum FeedType { export enum FeedType {
StandardRelays = 'standard', StandardRelays = "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";

80
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,7 @@
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'; import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}; };
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 +88,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 +119,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.`,
); );
} }
@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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 +168,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)!];
@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #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 +206,9 @@ 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(); 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();
} }
@ -260,7 +269,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) {
@ -288,7 +298,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) {
@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent; this.target = parent;
return true; return true;
} }
}(this); })(this);
// #endregion // #endregion
@ -412,17 +423,23 @@ 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.`); throw new Error(
`PublicationTree: Event with address ${currentAddress} not found.`,
);
} }
// Stop immediately if the target of the search is found. // Stop immediately if the target of the search is found.
@ -431,8 +448,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) {
@ -464,7 +481,16 @@ 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)); if (this.#nodes.has(address)) {
console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return;
}
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);
} }
@ -480,18 +506,18 @@ 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(':'); const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({ const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
'#d': [dTag], "#d": [dTag],
}); });
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 +531,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),
@ -523,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#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;
} }

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

@ -1,12 +1,12 @@
<!-- Legend Component (Svelte 5, Runes Mode) --> <!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts"> <script lang="ts">
import {Button} from 'flowbite-svelte'; import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let { let { collapsedOnInteraction = false, className = "" } = $props<{
collapsedOnInteraction = false, collapsedOnInteraction: boolean;
className = "" className: string;
} = $props<{collapsedOnInteraction: boolean, className: string}>(); }>();
let expanded = $state(true); let expanded = $state(true);
@ -24,7 +24,13 @@
<div class={`leather-legend ${className}`}> <div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3"> <div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3> <h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" > <Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded} {#if expanded}
<CaretUpOutline /> <CaretUpOutline />
{:else} {:else}
@ -45,7 +51,9 @@
<span class="legend-letter">I</span> <span class="legend-letter">I</span>
</span> </span>
</div> </div>
<span class="legend-text">Index events (kind 30040) - Each with a unique pastel color</span> <span class="legend-text"
>Index events (kind 30040) - Each with a unique pastel color</span
>
</li> </li>
<!-- Content event node --> <!-- Content event node -->
@ -55,7 +63,9 @@
<span class="legend-letter">C</span> <span class="legend-letter">C</span>
</span> </span>
</div> </div>
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span> <span class="legend-text"
>Content events (kinds 30041, 30818) - Publication sections</span
>
</li> </li>
<!-- Link arrow --> <!-- Link arrow -->

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

@ -7,10 +7,16 @@
<script lang="ts"> <script lang="ts">
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
// Component props // Component props
let { node, selected = false, x, y, onclose } = $props<{ let {
node,
selected = false,
x,
y,
onclose,
} = $props<{
node: NetworkNode; // The node to display information for node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked) selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip x: number; // X position for the tooltip
@ -68,7 +74,10 @@
/** /**
* Truncates content to a maximum length * Truncates content to a maximum length
*/ */
function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string { function truncateContent(
content: string,
maxLength: number = MAX_CONTENT_LENGTH,
): string {
if (!content) return ""; if (!content) return "";
if (content.length <= maxLength) return content; if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "..."; return content.substring(0, maxLength) + "...";
@ -117,13 +126,18 @@
style="left: {tooltipX}px; top: {tooltipY}px;" style="left: {tooltipX}px; top: {tooltipY}px;"
> >
<!-- Close button --> <!-- Close button -->
<button <button class="tooltip-close-btn" onclick={closeTooltip} aria-label="Close">
class="tooltip-close-btn" <svg
onclick={closeTooltip} xmlns="http://www.w3.org/2000/svg"
aria-label="Close" class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> <path
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /> fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
@ -131,10 +145,7 @@
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a <a href="/publication?id={node.id}" class="tooltip-title-link">
href="/publication?id={node.id}"
class="tooltip-title-link"
>
{node.title || "Untitled"} {node.title || "Untitled"}
</a> </a>
</div> </div>
@ -152,7 +163,8 @@
<!-- Summary (for index nodes) --> <!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)} {#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary"> <div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")} <span class="font-semibold">Summary:</span>
{truncateContent(getSummaryTag(node) || "")}
</div> </div>
{/if} {/if}
@ -165,9 +177,7 @@
<!-- Help text for selected nodes --> <!-- Help text for selected nodes -->
{#if selected} {#if selected}
<div class="tooltip-help-text"> <div class="tooltip-help-text">Click node again to dismiss</div>
Click node again to dismiss
</div>
{/if} {/if}
</div> </div>
</div> </div>

18
src/lib/navigator/EventNetwork/Settings.svelte

@ -2,7 +2,7 @@
Settings Component Settings Component
--> -->
<script lang="ts"> <script lang="ts">
import {Button} from 'flowbite-svelte'; import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
@ -10,10 +10,10 @@
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state"; import { networkFetchLimit } from "$lib/state";
let { let { count = 0, onupdate } = $props<{
count = 0, count: number;
onupdate onupdate: () => void;
} = $props<{count: number, onupdate: () => void}>(); }>();
let expanded = $state(false); let expanded = $state(false);
@ -31,7 +31,13 @@
<div class="leather-legend sm:!right-1 sm:!left-auto"> <div class="leather-legend sm:!right-1 sm:!left-auto">
<div class="flex items-center justify-between space-x-3"> <div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Settings</h3> <h3 class="h-leather">Settings</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" > <Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded} {#if expanded}
<CaretUpOutline /> <CaretUpOutline />
{:else} {:else}

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

@ -16,13 +16,13 @@
setupDragHandlers, setupDragHandlers,
applyGlobalLogGravity, applyGlobalLogGravity,
applyConnectedGravity, applyConnectedGravity,
type Simulation type Simulation,
} from "./utils/forceSimulation"; } from "./utils/forceSimulation";
import Legend from "./Legend.svelte"; import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte"; import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types"; import type { NetworkNode, NetworkLink } from "./types";
import Settings from "./Settings.svelte"; import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte'; import { Button } from "flowbite-svelte";
// Type alias for D3 selections // Type alias for D3 selections
type Selection = any; type Selection = any;
@ -45,7 +45,10 @@
} }
// Component props // Component props
let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>(); let { events = [], onupdate } = $props<{
events?: NDKEvent[];
onupdate: () => void;
}>();
// Error state // Error state
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(null);
@ -69,7 +72,9 @@
let width = $state(1000); let width = $state(1000);
let height = $state(600); let height = $state(600);
let windowHeight = $state<number | undefined>(undefined); let windowHeight = $state<number | undefined>(undefined);
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400); let graphHeight = $derived(
windowHeight ? Math.max(windowHeight * 0.2, 400) : 400,
);
// D3 objects // D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null; let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
@ -100,8 +105,7 @@
} }
debug("SVG dimensions", { width, height }); debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg) const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
.attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content // Clear existing content
svgElement.selectAll("*").remove(); svgElement.selectAll("*").remove();
@ -172,7 +176,7 @@
// Generate graph data from events // Generate graph data from events
debug("Generating graph with events", { debug("Generating graph with events", {
eventCount: events.length, eventCount: events.length,
currentLevels currentLevels,
}); });
const graphData = generateGraph(events, Number(currentLevels)); const graphData = generateGraph(events, Number(currentLevels));
@ -181,7 +185,7 @@
debug("Generated graph data", { debug("Generated graph data", {
nodeCount: nodes.length, nodeCount: nodes.length,
linkCount: links.length linkCount: links.length,
}); });
if (!nodes.length) { if (!nodes.length) {
@ -212,13 +216,14 @@
.selectAll("path.link") .selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`) .data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join( .join(
(enter: any) => enter (enter: any) =>
enter
.append("path") .append("path")
.attr("class", "link network-link-leather") .attr("class", "link network-link-leather")
.attr("stroke-width", 2) .attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"), .attr("marker-end", "url(#arrowhead)"),
(update: any) => update, (update: any) => update,
(exit: any) => exit.remove() (exit: any) => exit.remove(),
); );
// Update nodes // Update nodes
@ -260,23 +265,27 @@
return nodeEnter; return nodeEnter;
}, },
(update: any) => update, (update: any) => update,
(exit: any) => exit.remove() (exit: any) => exit.remove(),
); );
// Update node appearances // Update node appearances
debug("Updating node appearances"); debug("Updating node appearances");
node.select("circle.visual-circle") node
.attr("class", (d: NetworkNode) => !d.isContainer .select("circle.visual-circle")
.attr("class", (d: NetworkNode) =>
!d.isContainer
? "visual-circle network-node-leather network-node-content" ? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather" : "visual-circle network-node-leather",
) )
.attr("fill", (d: NetworkNode) => !d.isContainer .attr("fill", (d: NetworkNode) =>
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT !d.isContainer
: getEventColor(d.id) ? isDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
); );
node.select("text") node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C"));
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Set up node interactions // Set up node interactions
debug("Setting up node interactions"); debug("Setting up node interactions");
@ -322,9 +331,14 @@
if (simulation) { if (simulation) {
simulation.on("tick", () => { simulation.on("tick", () => {
// Apply custom forces to each node // Apply custom forces to each node
nodes.forEach(node => { nodes.forEach((node) => {
// Pull nodes toward the center // Pull nodes toward the center
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha()); applyGlobalLogGravity(
node,
width / 2,
height / 2,
simulation!.alpha(),
);
// Pull connected nodes toward each other // Pull connected nodes toward each other
applyConnectedGravity(node, links, simulation!.alpha()); applyConnectedGravity(node, links, simulation!.alpha());
}); });
@ -349,7 +363,10 @@
}); });
// Update node positions // Update node positions
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`); node.attr(
"transform",
(d: NetworkNode) => `translate(${d.x},${d.y})`,
);
}); });
} }
} catch (error) { } catch (error) {
@ -390,11 +407,15 @@
isDarkMode = newIsDarkMode; isDarkMode = newIsDarkMode;
// Update node colors when theme changes // Update node colors when theme changes
if (svgGroup) { if (svgGroup) {
svgGroup.selectAll("g.node") svgGroup
.selectAll("g.node")
.select("circle.visual-circle") .select("circle.visual-circle")
.attr("fill", (d: NetworkNode) => !d.isContainer .attr("fill", (d: NetworkNode) =>
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT !d.isContainer
: getEventColor(d.id) ? newIsDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
); );
} }
} }
@ -440,7 +461,7 @@
debug("Effect triggered", { debug("Effect triggered", {
hasSvg: !!svg, hasSvg: !!svg,
eventCount: events?.length, eventCount: events?.length,
currentLevels currentLevels,
}); });
try { try {
@ -472,9 +493,12 @@
const svgHeight = svg.clientHeight || height; const svgHeight = svg.clientHeight || height;
// Reset zoom and center // Reset zoom and center
d3.select(svg).transition().duration(750).call( d3.select(svg)
.transition()
.duration(750)
.call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8) d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8),
); );
} }
} }
@ -484,9 +508,7 @@
*/ */
function zoomIn() { function zoomIn() {
if (svg && zoomBehavior) { if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call( d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
zoomBehavior.scaleBy, 1.3
);
} }
} }
@ -495,9 +517,7 @@
*/ */
function zoomOut() { function zoomOut() {
if (svg && zoomBehavior) { if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call( d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
zoomBehavior.scaleBy, 0.7
);
} }
} }
@ -520,7 +540,10 @@
<p>{errorMessage}</p> <p>{errorMessage}</p>
<button <button
class="network-error-retry" class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }} onclick={() => {
errorMessage = null;
updateGraph();
}}
> >
Retry Retry
</button> </button>
@ -528,50 +551,82 @@
{/if} {/if}
<div class="network-svg-container" bind:this={container} role="figure"> <div class="network-svg-container" bind:this={container} role="figure">
<Legend collapsedOnInteraction={graphInteracted} className='' /> <Legend collapsedOnInteraction={graphInteracted} className="" />
<!-- Settings Panel (shown when settings button is clicked) --> <!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} onupdate={onupdate} /> <Settings count={events.length} {onupdate} />
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<svg <svg bind:this={svg} class="network-svg" onclick={handleGraphClick} />
bind:this={svg}
class="network-svg"
onclick={handleGraphClick}
/>
<!-- Zoom controls --> <!-- Zoom controls -->
<div class="network-controls"> <div class="network-controls">
<Button outline size="lg" <Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn} onclick={zoomIn}
aria-label="Zoom in" aria-label="Zoom in"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line> <line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line> <line x1="8" y1="11" x2="14" y2="11"></line>
</svg> </svg>
</Button> </Button>
<Button outline size="lg" <Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomOut} onclick={zoomOut}
aria-label="Zoom out" aria-label="Zoom out"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line> <line x1="8" y1="11" x2="14" y2="11"></line>
</svg> </svg>
</Button> </Button>
<Button outline size="lg" <Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph} onclick={centerGraph}
aria-label="Center graph" aria-label="Center graph"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
</svg> </svg>
@ -588,5 +643,4 @@
onclose={handleTooltipClose} onclose={handleTooltipClose}
/> />
{/if} {/if}
</div> </div>

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

@ -66,14 +66,14 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> {
export function updateNodeVelocity( export function updateNodeVelocity(
node: NetworkNode, node: NetworkNode,
deltaVx: number, deltaVx: number,
deltaVy: number deltaVy: number,
) { ) {
debug("Updating node velocity", { debug("Updating node velocity", {
nodeId: node.id, nodeId: node.id,
currentVx: node.vx, currentVx: node.vx,
currentVy: node.vy, currentVy: node.vy,
deltaVx, deltaVx,
deltaVy deltaVy,
}); });
if (typeof node.vx === "number" && typeof node.vy === "number") { if (typeof node.vx === "number" && typeof node.vy === "number") {
@ -129,8 +129,8 @@ export function applyConnectedGravity(
) { ) {
// Find all nodes connected to this node // Find all nodes connected to this node
const connectedNodes = links const connectedNodes = links
.filter(link => link.source.id === node.id || link.target.id === node.id) .filter((link) => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source); .map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return; if (connectedNodes.length === 0) return;
@ -163,11 +163,16 @@ export function applyConnectedGravity(
*/ */
export function setupDragHandlers( export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>, simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9 warmupClickEnergy: number = 0.9,
) { ) {
return d3 return d3
.drag() .drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { .on(
"start",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Warm up simulation if it's cooled down // Warm up simulation if it's cooled down
if (!event.active) { if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart(); simulation.alphaTarget(warmupClickEnergy).restart();
@ -175,13 +180,25 @@ export function setupDragHandlers(
// Fix node position at current location // Fix node position at current location
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
}) },
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"drag",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update fixed position to mouse position // Update fixed position to mouse position
d.fx = event.x; d.fx = event.x;
d.fy = event.y; d.fy = event.y;
}) },
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"end",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends // Cool down simulation when drag ends
if (!event.active) { if (!event.active) {
simulation.alphaTarget(0); simulation.alphaTarget(0);
@ -189,7 +206,8 @@ export function setupDragHandlers(
// Release fixed position // Release fixed position
d.fx = null; d.fx = null;
d.fy = null; d.fy = null;
}); },
);
} }
/** /**
@ -205,13 +223,13 @@ export function createSimulation(
nodes: NetworkNode[], nodes: NetworkNode[],
links: NetworkLink[], links: NetworkLink[],
nodeRadius: number, nodeRadius: number,
linkDistance: number linkDistance: number,
): Simulation<NetworkNode, NetworkLink> { ): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", { debug("Creating simulation", {
nodeCount: nodes.length, nodeCount: nodes.length,
linkCount: links.length, linkCount: links.length,
nodeRadius, nodeRadius,
linkDistance linkDistance,
}); });
try { try {
@ -220,9 +238,10 @@ export function createSimulation(
.forceSimulation(nodes) .forceSimulation(nodes)
.force( .force(
"link", "link",
d3.forceLink(links) d3
.forceLink(links)
.id((d: NetworkNode) => d.id) .id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1) .distance(linkDistance * 0.1),
) )
.force("collide", d3.forceCollide().radius(nodeRadius * 4)); .force("collide", d3.forceCollide().radius(nodeRadius * 4));

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

@ -9,7 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
// Configuration // Configuration
const DEBUG = false; // Set to true to enable debug logging const DEBUG = false; // Set to true to enable debug logging
@ -37,9 +37,13 @@ function debug(...args: any[]) {
*/ */
export function createNetworkNode( export function createNetworkNode(
event: NDKEvent, event: NDKEvent,
level: number = 0 level: number = 0,
): NetworkNode { ): NetworkNode {
debug("Creating network node", { eventId: event.id, kind: event.kind, level }); debug("Creating network node", {
eventId: event.id,
kind: event.kind,
level,
});
const isContainer = event.kind === INDEX_EVENT_KIND; const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content"; const nodeType = isContainer ? "Index" : "Content";
@ -159,13 +163,19 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events // Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>(); const referencedIds = new Set<string>();
events.forEach((event) => { events.forEach((event) => {
const aTags = getMatchingTags(event, "a"); // Handle both "a" tags (NIP-62) and "e" tags (legacy)
debug("Processing a-tags for event", { let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
debug("Processing tags for event", {
eventId: event.id, eventId: event.id,
aTagCount: aTags.length tagCount: tags.length,
tagType: tags.length > 0 ? (getMatchingTags(event, "a").length > 0 ? "a" : "e") : "none"
}); });
aTags.forEach((tag) => { tags.forEach((tag) => {
const id = extractEventIdFromATag(tag); const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id); if (id) referencedIds.add(id);
}); });
@ -280,7 +290,13 @@ export function processIndexEvent(
if (level >= maxLevel) return; if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index // Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a") // Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(indexEvent, "a");
if (tags.length === 0) {
tags = getMatchingTags(indexEvent, "e");
}
const sequence = tags
.map((tag) => extractEventIdFromATag(tag)) .map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null) .filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id)) .map((id) => state.nodeMap.get(id))
@ -298,10 +314,7 @@ export function processIndexEvent(
* @param maxLevel - Maximum hierarchy level to process * @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data for visualization * @returns Complete graph data for visualization
*/ */
export function generateGraph( export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData {
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel }); debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state // Initialize the graph state
@ -309,19 +322,20 @@ export function generateGraph(
// Find root index events (those not referenced by other events) // Find root index events (those not referenced by other events)
const rootIndices = events.filter( const rootIndices = events.filter(
(e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) (e) =>
e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id),
); );
debug("Found root indices", { debug("Found root indices", {
rootCount: rootIndices.length, rootCount: rootIndices.length,
rootIds: rootIndices.map(e => e.id) rootIds: rootIndices.map((e) => e.id),
}); });
// Process each root index // Process each root index
rootIndices.forEach((rootIndex) => { rootIndices.forEach((rootIndex) => {
debug("Processing root index", { debug("Processing root index", {
rootId: rootIndex.id, rootId: rootIndex.id,
aTags: getMatchingTags(rootIndex, "a").length aTags: getMatchingTags(rootIndex, "a").length,
}); });
processIndexEvent(rootIndex, 0, state, maxLevel); processIndexEvent(rootIndex, 0, state, maxLevel);
}); });
@ -334,7 +348,7 @@ export function generateGraph(
debug("Graph generation complete", { debug("Graph generation complete", {
nodeCount: result.nodes.length, nodeCount: result.nodes.length,
linkCount: result.links.length linkCount: result.links.length,
}); });
return result; return result;

478
src/lib/ndk.ts

@ -1,16 +1,283 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; import NDK, {
NDKNip07Signer,
NDKRelay,
NDKRelayAuthPolicies,
NDKRelaySet,
NDKUser,
NDKEvent,
} from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store'; import { get, writable, type Writable } from 'svelte/store';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts'; import { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts';
import { feedType } from './stores'; import { feedType } from './stores';
import { userStore } from './stores/userStore';
import { userPubkey } from '$lib/stores/authStore.Svelte';
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[]>([]);
export const ndkSignedIn: Writable<boolean> = writable(false); /**
* 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();
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
*/
async authenticate(relay: NDKRelay): Promise<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, message);
}
});
// 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: any) => {
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,
message: string,
): 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 =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
const isHttp = window.location.protocol === "http:";
const isHttps = window.location.protocol === "https:";
console.debug("[NDK.ts] - Is localhost:", isLocalhost);
console.debug("[NDK.ts] - Protocol:", window.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:", window.location.protocol);
console.debug("[NDK.ts] - Hostname:", window.location.hostname);
console.debug("[NDK.ts] - Port:", window.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);
}
}
/**
* 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 async function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
const relay = new NDKRelay(secureUrl, undefined, new 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,
});
}, 5000);
relay.on("connect", () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`);
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;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
}
});
export const activePubkey: Writable<string | null> = writable(null); relay.on("disconnect", () => {
if (!connected) {
error = "Connection failed";
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl,
});
}
});
export const inboxRelays: Writable<string[]> = writable([]); // Log the actual WebSocket URL being used
export const outboxRelays: Writable<string[]> = writable([]); console.debug(`[NDK.ts] Attempting connection to: ${secureUrl}`);
relay.connect();
});
}
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
@ -47,7 +314,7 @@ 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}`;
} }
@ -57,14 +324,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
* @param inboxes The user's inbox relays. * @param inboxes The user's inbox relays.
* @param outboxes The user's outbox relays. * @param outboxes The user's outbox relays.
*/ */
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void { function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'inbox'), getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map(relay => relay.url)) JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
); );
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'outbox'), getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map(relay => relay.url)) JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
); );
} }
@ -76,24 +347,104 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR
*/ */
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]') JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
); );
const outboxes = new Set<string>( const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]') JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
); );
return [inboxes, outboxes]; return [inboxes, outboxes];
} }
export function clearPersistedRelays(user: NDKUser): void { export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox')); localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, 'outbox')); localStorage.removeItem(getRelayStorageKey(user, "outbox"));
}
/**
* Ensures a relay URL uses secure WebSocket protocol
* @param url The relay URL to secure
* @returns The URL with wss:// protocol
*/
function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present
const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) {
console.warn(
`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`,
);
}
return secureUrl;
}
/**
* Creates a relay with proper authentication handling
*/
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url);
// Add connection timeout and error handling
const relay = new NDKRelay(
secureUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
// Set up connection timeout
const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect();
}, 10000); // 10 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 getActiveRelays(ndk: NDK): NDKRelaySet { export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays const user = get(userStore);
// Filter out problematic relays that are known to cause connection issues
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
// Filter out gitcitadel.nostr1.com which is causing connection issues
if (relay.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay: ${relay}`);
return false;
}
return true;
});
};
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet( ? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => new NDKRelay( new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay(
relay, relay,
NDKRelayAuthPolicies.signIn({ ndk }), NDKRelayAuthPolicies.signIn({ ndk }),
ndk, ndk,
@ -101,7 +452,7 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
ndk ndk
) )
: new NDKRelaySet( : new NDKRelaySet(
new Set(standardRelays.map(relay => new NDKRelay( new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay(
relay, relay,
NDKRelayAuthPolicies.signIn({ ndk }), NDKRelayAuthPolicies.signIn({ ndk }),
ndk, ndk,
@ -117,21 +468,45 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
*/ */
export function initNdk(): NDK { export function initNdk(): NDK {
const startingPubkey = getPersistedLogin(); const startingPubkey = getPersistedLogin();
const [startingInboxes, _] = startingPubkey != null const [startingInboxes, _] =
startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) ? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null]; : [null, null];
// Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (
startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays
).map(ensureSecureWebSocket);
console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls);
const ndk = new NDK({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: true,
enableOutboxModel: true, enableOutboxModel: true,
explicitRelayUrls: startingInboxes != null explicitRelayUrls: secureRelayUrls,
? 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
ndk.connect()
.then(() => {
console.debug("[NDK.ts] NDK connected successfully");
})
.catch((error) => {
console.error("[NDK.ts] Failed to connect NDK:", error);
// Try to reconnect after a delay
setTimeout(() => {
console.debug("[NDK.ts] Attempting to reconnect...");
ndk.connect().catch((retryError) => {
console.error("[NDK.ts] Reconnection failed:", retryError);
});
}, 5000);
});
return ndk; return ndk;
} }
@ -142,7 +517,9 @@ export function initNdk(): NDK {
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because * @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. * 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,12 +527,14 @@ 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); const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
for (const relay of persistedInboxes) { for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay); ndk.addExplicitRelay(relay);
} }
@ -163,8 +542,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url)); inboxRelays.set(
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url)); Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes); persistRelays(signerUser, inboxes, outboxes);
@ -188,7 +571,9 @@ 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);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
} }
/** /**
@ -196,10 +581,10 @@ export function logout(user: NDKUser): void {
* relay sets. * relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. * @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/ */
async function getUserPreferredRelays( export async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = fallbackRelays fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
@ -217,27 +602,40 @@ async function getUserPreferredRelays(
const inboxRelays = new Set<NDKRelay>(); const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>(); const outboxRelays = new Set<NDKRelay>();
// Filter out problematic relays
const filterProblematicRelay = (url: string): boolean => {
if (url.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`);
return false;
}
return true;
};
if (relayList == null) { if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.(); const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => { Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk); if (filterProblematicRelay(url)) {
const relay = createRelayWithAuth(url, ndk);
if (relayType.read) inboxRelays.add(relay); if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}
}); });
} else { } else {
relayList.tags.forEach(tag => { relayList.tags.forEach((tag) => {
if (filterProblematicRelay(tag[1])) {
switch (tag[0]) { switch (tag[0]) {
case 'r': case "r":
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
case 'w': case "w":
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
default: default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); inboxRelays.add(createRelayWithAuth(tag[1], ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
} }
}
}); });
} }

429
src/lib/parser.ts

@ -1,5 +1,5 @@
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import asciidoctor from 'asciidoctor'; import asciidoctor from "asciidoctor";
import type { import type {
AbstractBlock, AbstractBlock,
AbstractNode, AbstractNode,
@ -9,11 +9,11 @@ import type {
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 "$lib/utils/nostrUtils";
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,
} }
/** /**
@ -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.
@ -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"
);
const advancedExtensions = 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(
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 {
@ -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,8 +902,8 @@ 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!),
]; ];
event.created_at = Date.now(); event.created_at = Date.now();
@ -878,172 +946,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 +1126,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,7 +1176,7 @@ 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;
@ -1123,7 +1192,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 +1201,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');
} }

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}

54
src/lib/snippets/UserSnippets.svelte

@ -1,19 +1,59 @@
<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}

5
src/lib/stores.ts

@ -7,14 +7,13 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays); export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = { const defaultVisibility = {
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() {
@ -24,7 +23,7 @@ function createVisibilityStore() {
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);

2
src/lib/stores/relayStore.ts

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

301
src/lib/stores/userStore.ts

@ -0,0 +1,301 @@
import { writable, get } from 'svelte/store';
import type { NostrProfile } from '$lib/utils/nostrUtils';
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk';
import { NDKNip07Signer, NDKRelayAuthPolicies, NDKRelaySet, NDKRelay } from '@nostr-dev-kit/ndk';
import { getUserMetadata } from '$lib/utils/nostrUtils';
import { ndkInstance } from '$lib/ndk';
import { loginStorageKey, fallbackRelays } from '$lib/consts';
import { nip19 } from 'nostr-tools';
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: any,
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>();
const outboxRelays = new Set<NDKRelay>();
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]: [string, any]) => {
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 getPersistedLoginMethod(): 'extension' | 'amber' | 'npub' | null {
return (localStorage.getItem(loginMethodStorageKey) as 'extension' | 'amber' | 'npub') ?? null;
}
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;
const profile = await getUserMetadata(npub);
// 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;
userStore.set({
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',
ndkUser: user,
signer,
signedIn: true,
});
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;
const profile = await getUserMetadata(npub, true); // Force fresh fetch
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;
userStore.set({
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',
ndkUser: user,
signer: amberSigner,
signedIn: true,
});
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;
}
const user = ndk.getUser({ npub });
const profile = await getUserMetadata(npub);
ndk.signer = undefined;
ndk.activeUser = user;
userStore.set({
pubkey: user.pubkey,
npub,
profile,
relays: { inbox: [], outbox: [] },
loginMethod: 'npub',
ndkUser: user,
signer: null,
signedIn: true,
});
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,
});
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = undefined;
ndk.signer = undefined;
}
console.log('Logout complete');
}

8
src/lib/types.ts

@ -6,4 +6,10 @@ export type Tab = {
data?: any; data?: any;
}; };
export type TabType = 'welcome' | 'find' | 'article' | 'user' | 'settings' | 'editor'; export type TabType =
| "welcome"
| "find"
| "article"
| "user"
| "settings"
| "editor";

26
src/lib/utils.ts

@ -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({
@ -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);
}; };
@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>(
*/ */
export function debounce<T extends (...args: any[]) => any>( export function debounce<T extends (...args: any[]) => any>(
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;

86
src/lib/utils/community_checker.ts

@ -0,0 +1,86 @@
import { communityRelay } 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 {
const relayUrl = communityRelay;
const ws = new WebSocket(relayUrl);
return await new Promise((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 = () => {
communityCache.set(pubkey, false);
ws.close();
resolve(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;
}

400
src/lib/utils/event_input_utils.ts

@ -0,0 +1,400 @@
import type { NDKEvent } from './nostrUtils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
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 all section headers (lines starting with '== ').
*/
function extractAsciiDocSectionHeaders(content: string): string[] {
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim());
}
/**
* 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;
let 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 "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from "$lib/utils/nostrUtils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils";
import { TIMEOUTS, VALIDATION } from "./search_constants";
/**
* 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
let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: any = 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 [];
}
}

132
src/lib/utils/indexEventCache.ts

@ -0,0 +1,132 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
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 {
const now = Date.now();
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";
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 window !== "undefined" &&
typeof window.hljs?.highlightAll === "function"
) {
setTimeout(() => window.hljs!.highlightAll(), 0);
}
if (
typeof window !== "undefined" &&
typeof (window as any).MathJax?.typesetPromise === "function"
) {
setTimeout(() => (window as any).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;
}

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

@ -1,13 +1,29 @@
import { parseBasicmarkup } from './basicMarkupParser'; import { parseBasicmarkup } from "./basicMarkupParser";
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 +33,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 +65,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 +82,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 +121,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 +156,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 +167,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 +177,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 +197,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 +242,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 +254,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,11 +282,11 @@ 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 (e: unknown) {
@ -251,24 +294,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
} }
} }
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,11 +325,11 @@ 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 (e: unknown) {
@ -291,19 +337,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
} }
} }
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 +371,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 +384,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>`;
} }
} }

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

@ -0,0 +1,213 @@
import { renderTikZ } from "./tikzRenderer";
import asciidoctor from "asciidoctor";
// Simple math rendering using MathJax CDN
function renderMath(content: string): string {
return `<div class="math-block" data-math="${encodeURIComponent(content)}">
<div class="math-content">${content}</div>
<script>
if (typeof MathJax !== 'undefined') {
MathJax.typesetPromise([document.querySelector('.math-content')]);
}
</script>
</div>`;
}
// 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 = asciidoctor();
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(treeProcessor: 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(treeProcessor: 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(treeProcessor: 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"),
);
}

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

@ -0,0 +1,136 @@
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
}
}

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

@ -1,6 +1,6 @@
import { processNostrIdentifiers } from '../nostrUtils'; import { processNostrIdentifiers } from "../nostrUtils";
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;
@ -214,17 +241,17 @@ function processBasicFormatting(content: string): string {
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,19 @@ 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 +427,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;
}

24
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,25 @@
* - 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 (kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind < EVENT_KINDS.ADDRESSABLE.MAX) {
return 'addressable'; return "addressable";
} }
if (kind >= 20000 && kind < 30000) { if (kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN && kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX) {
return 'ephemeral'; return "ephemeral";
} }
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { if ((kind >= EVENT_KINDS.REPLACEABLE.MIN && kind < EVENT_KINDS.REPLACEABLE.MAX) ||
return 'replaceable'; 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 +41,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";

431
src/lib/utils/nostrEventService.ts

@ -0,0 +1,431 @@
import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
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, parentInfo: ParentEventInfo): 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, parentInfo));
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, parentInfo));
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[][]
): 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' && window.nostr && window.nostr.signEvent) {
const signed = await window.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,
}
};
}
/**
* Publish event to a single relay
*/
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> {
const ws = new WebSocket(relayUrl);
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, TIMEOUTS.GENERAL);
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) {
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
}
/**
* Publish event to relays
*/
export async function publishEvent(
signedEvent: any,
useOtherRelays = false,
useFallbackRelays = false,
userRelayPreference = false
): Promise<EventPublishResult> {
// Determine which relays to use
let relays = userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
}
// Try to publish to relays
for (const relayUrl of relays) {
try {
await publishToRelay(relayUrl, signedEvent);
return {
success: true,
relay: relayUrl,
eventId: signedEvent.id
};
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
return {
success: false,
error: "Failed to publish to any relays"
};
}
/**
* 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
function getRelayString(relay: any): string {
if (!relay) return '';
if (typeof relay === 'string') return relay;
if (typeof relay.url === 'string') return relay.url;
return '';
}
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 '';
}

397
src/lib/utils/nostrUtils.ts

@ -1,22 +1,28 @@
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 "$lib/ndk";
import { npubCache } from './npubCache'; import { npubCache } from "./npubCache";
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 } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from '@noble/hashes/sha256'; import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from '@noble/curves/secp256k1'; import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from '@noble/hashes/utils'; import { bytesToHex } from "@noble/hashes/utils";
import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants';
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 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>'; 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,23 +40,23 @@ 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:/, '');
if (npubCache.has(cleanId)) { if (!force && npubCache.has(cleanId)) {
return npubCache.get(cleanId)!; return npubCache.get(cleanId)!;
} }
@ -71,27 +77,33 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
// 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 {
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] }); const profileEvent = await fetchEventWithFallback(ndk, {
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null; kinds: [0],
authors: [pubkey],
});
const profile =
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content)
: null;
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,
}; };
npubCache.set(cleanId, metadata); npubCache.set(cleanId, metadata);
@ -105,27 +117,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 +153,37 @@ 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, ...standardRelays,
...userRelays, ...userRelays,
...fallbackRelays ...fallbackRelays,
].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 +192,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 +205,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 +249,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 +267,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 +279,69 @@ 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 +349,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 +359,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 +379,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 +391,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,20 +407,25 @@ 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 // Get user relays if logged in
const userRelays = ndk.activeUser ? const userRelays = ndk.activeUser
Array.from(ndk.pool?.relays.values() || []) ? 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)
[]; .filter(url => !url.includes('gitcitadel.nostr1.com')) // Filter out problematic relay
: [];
// Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser;
const primaryRelays = isSignedIn ? standardRelays : anonymousRelays;
// Create three relay sets in priority order // Create three relay sets in priority order
const relaySets = [ const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in) NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort) NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort)
]; ];
try { try {
@ -332,24 +433,42 @@ export async function fetchEventWithFallback(
const triedRelaySets: string[] = []; const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set // Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> { async function tryFetchFromRelaySet(
relaySet: NDKRelaySetFromNDK,
setName: string,
): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null; if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName); triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { if (
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs); typeof filterOrId === "string" &&
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId)
) {
return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs);
} else { } else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; const filter =
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs); typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId;
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null; const results = await ndk
.fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs);
return results instanceof Set
? (Array.from(results)[0] as NDKEvent)
: null;
} }
} }
// Try each relay set in order // Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) { for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? 'standard relays' : const setName =
index === 1 ? 'user relays' : index === 0
'fallback relays'; ? isSignedIn
? "standard relays"
: "anonymous relays"
: index === 1
? "user relays"
: "fallback relays";
found = await tryFetchFromRelaySet(relaySet, setName); found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break; if (found) break;
@ -357,22 +476,32 @@ export async function fetchEventWithFallback(
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => { const relayUrls = relaySets
const setName = i === 0 ? 'standard relays' : .map((set, i) => {
i === 1 ? 'user relays' : const setName =
'fallback relays'; i === 0
const urls = Array.from(set.relays).map(r => r.url); ? isSignedIn
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null; ? "standard relays"
}).filter(Boolean).join(', then '); : "anonymous relays"
: i === 1
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`); ? "user relays"
: "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;
} }
// 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); console.error("Error in fetchEventWithFallback:", err);
return null; return null;
} }
} }
@ -383,10 +512,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;
@ -428,7 +557,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 +573,79 @@ 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;

328
src/lib/utils/profile_search.ts

@ -0,0 +1,328 @@
import { ndkInstance } from '$lib/ndk';
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { standardRelays, fallbackRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
import { checkCommunityStatus } from './community_checker';
import { TIMEOUTS } from './search_constants';
/**
* 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, ndk);
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, ndk: any): 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: any): Promise<NostrProfile[]> {
console.log('quickRelaySearch called with:', searchTerm);
const foundProfiles: NostrProfile[] = [];
// 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 = [...standardRelays, ...fallbackRelays]; // 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(async (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 (e) {
// 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);
}

141
src/lib/utils/relayDiagnostics.ts

@ -0,0 +1,141 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk';
import { TIMEOUTS } from './search_constants';
export interface RelayDiagnostic {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
responseTime?: number;
}
/**
* Tests connection to a single relay
*/
export async 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[]> {
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])];
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();
}

105
src/lib/utils/searchCache.ts

@ -0,0 +1,105 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
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 {
const now = Date.now();
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;

69
src/lib/utils/search_types.ts

@ -0,0 +1,69 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
/**
* Extended NostrProfile interface for search results
*/
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
pubkey?: string;
}
/**
* Search result interface for subscription-based searches
*/
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
}
/**
* Profile search result interface
*/
export interface ProfileSearchResult {
profiles: NostrProfile[];
Status: Record<string, boolean>;
}
/**
* Search subscription type
*/
export type SearchSubscriptionType = 'd' | 't' | 'n';
/**
* Search filter configuration
*/
export interface SearchFilter {
filter: any;
subscriptionType: string;
}
/**
* Second-order search parameters
*/
export interface SecondOrderSearchParams {
searchType: 'n' | 'd';
firstOrderEvents: NDKEvent[];
eventIds?: Set<string>;
addresses?: Set<string>;
targetPubkey?: string;
}
/**
* Search callback functions
*/
export interface SearchCallbacks {
onSecondOrderUpdate?: (result: SearchResult) => void;
onSubscriptionCreated?: (sub: any) => void;
}

25
src/lib/utils/search_utility.ts

@ -0,0 +1,25 @@
// Re-export all search functionality from modular files
export * from './search_types';
export * from './search_utils';
export * from './community_checker';
export * from './profile_search';
export * from './event_search';
export * from './subscription_search';
export * from './search_constants';
// Legacy exports for backward compatibility
export { searchProfiles } from './profile_search';
export { searchBySubscription } from './subscription_search';
export { searchEvent, searchNip05 } from './event_search';
export { checkCommunity } from './community_checker';
export {
wellKnownUrl,
lnurlpWellKnownUrl,
isValidNip05Address,
normalizeSearchTerm,
fieldMatches,
nip05Matches,
COMMON_DOMAINS,
isEmojiReaction,
createProfileFromEvent
} from './search_utils';

104
src/lib/utils/search_utils.ts

@ -0,0 +1,104 @@
/**
* Generate well-known NIP-05 URL
*/
export function wellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/nostr.json?name=${name}`;
}
/**
* Generate well-known LNURLp URL for Lightning Network addresses
*/
export function lnurlpWellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/lnurlp/${name}`;
}
/**
* Validate NIP-05 address format
*/
export function isValidNip05Address(address: string): boolean {
return /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(address);
}
/**
* Helper function to normalize search terms
*/
export function normalizeSearchTerm(term: string): string {
return term.toLowerCase().replace(/\s+/g, '');
}
/**
* Helper function to check if a profile field matches the search term
*/
export function fieldMatches(field: string, searchTerm: string): boolean {
if (!field) return false;
const fieldLower = field.toLowerCase();
const fieldNormalized = fieldLower.replace(/\s+/g, '');
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check exact match
if (fieldLower === searchTermLower) return true;
if (fieldNormalized === normalizedSearchTerm) return true;
// Check if field contains the search term
if (fieldLower.includes(searchTermLower)) return true;
if (fieldNormalized.includes(normalizedSearchTerm)) return true;
// Check individual words (handle spaces in display names)
const words = fieldLower.split(/\s+/);
return words.some(word => word.includes(searchTermLower));
}
/**
* Helper function to check if NIP-05 address matches the search term
*/
export function nip05Matches(nip05: string, searchTerm: string): boolean {
if (!nip05) return false;
const nip05Lower = nip05.toLowerCase();
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check if the part before @ contains the search term
const atIndex = nip05Lower.indexOf('@');
if (atIndex !== -1) {
const localPart = nip05Lower.substring(0, atIndex);
const localPartNormalized = localPart.replace(/\s+/g, '');
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm);
}
return false;
}
/**
* Common domains for NIP-05 lookups
*/
export const COMMON_DOMAINS = [
'gitcitadel.com',
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host'
] as const;
/**
* Check if an event is an emoji reaction (kind 7)
*/
export function isEmojiReaction(event: any): boolean {
return event.kind === 7;
}
/**
* Create a profile object from event data
*/
export function createProfileFromEvent(event: any, profileData: any): any {
return {
name: profileData.name,
displayName: profileData.displayName || profileData.display_name,
nip05: profileData.nip05,
picture: profileData.picture,
about: profileData.about,
banner: profileData.banner,
website: profileData.website,
lud16: profileData.lud16,
pubkey: event.pubkey
};
}

656
src/lib/utils/subscription_search.ts

@ -0,0 +1,656 @@
import { ndkInstance } from '$lib/ndk';
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils';
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants';
/**
* Search for events by subscription type (d, t, n)
*/
export async function searchBySubscription(
searchType: SearchSubscriptionType,
searchTerm: string,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal
): Promise<SearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", { searchType, searchTerm, normalizedSearchTerm });
// Check cache first
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult);
return cachedResult;
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error("subscription_search: NDK not initialized");
throw new Error('NDK not initialized');
}
console.log("subscription_search: NDK initialized, creating search state");
const searchState = createSearchState();
const cleanup = createCleanupFunction(searchState);
// Set a timeout to force completion after subscription search timeout
searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached");
cleanup();
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
// Check for abort signal
if (abortSignal?.aborted) {
console.log("subscription_search: Search aborted");
cleanup();
throw new Error('Search cancelled');
}
const searchFilter = await createSearchFilter(searchType, normalizedSearchTerm);
console.log("subscription_search: Created search filter:", searchFilter);
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk);
console.log("subscription_search: Created primary relay set with", primaryRelaySet.relays.size, "relays");
// Phase 1: Search primary relay
if (primaryRelaySet.relays.size > 0) {
try {
console.log("subscription_search: Searching primary relay with filter:", searchFilter.filter);
const primaryEvents = await ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
primaryRelaySet
);
console.log("subscription_search: Primary relay returned", primaryEvents.size, "events");
processPrimaryRelayResults(primaryEvents, searchType, searchFilter.subscriptionType, normalizedSearchTerm, searchState, abortSignal, cleanup);
// If we found results from primary relay, return them immediately
if (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from primary relay, returning immediately");
const immediateResult = createSearchResult(searchState, searchType, normalizedSearchTerm);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
return immediateResult;
} else {
console.log("subscription_search: No results from primary relay, continuing to Phase 2");
}
} catch (error) {
console.error(`subscription_search: Error searching primary relay:`, error);
}
} else {
console.log("subscription_search: No primary relays available, skipping Phase 1");
}
// Always do Phase 2: Search all other relays in parallel
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
}
/**
* Create search state object
*/
function createSearchState() {
return {
timeoutId: null as ReturnType<typeof setTimeout> | null,
firstOrderEvents: [] as NDKEvent[],
secondOrderEvents: [] as NDKEvent[],
tTagEvents: [] as NDKEvent[],
eventIds: new Set<string>(),
eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[],
isCompleted: false,
currentSubscription: null as any
};
}
/**
* Create cleanup function
*/
function createCleanupFunction(searchState: any) {
return () => {
if (searchState.timeoutId) {
clearTimeout(searchState.timeoutId);
searchState.timeoutId = null;
}
if (searchState.currentSubscription) {
try {
searchState.currentSubscription.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
searchState.currentSubscription = null;
}
};
}
/**
* Create search filter based on search type
*/
async function createSearchFilter(searchType: SearchSubscriptionType, normalizedSearchTerm: string): Promise<SearchFilter> {
console.log("subscription_search: Creating search filter for:", { searchType, normalizedSearchTerm });
switch (searchType) {
case 'd':
const dFilter = {
filter: { "#d": [normalizedSearchTerm] },
subscriptionType: 'd-tag'
};
console.log("subscription_search: Created d-tag filter:", dFilter);
return dFilter;
case 't':
const tFilter = {
filter: { "#t": [normalizedSearchTerm] },
subscriptionType: 't-tag'
};
console.log("subscription_search: Created t-tag filter:", tFilter);
return tFilter;
case 'n':
const nFilter = await createProfileSearchFilter(normalizedSearchTerm);
console.log("subscription_search: Created profile filter:", nFilter);
return nFilter;
default:
throw new Error(`Unknown search type: ${searchType}`);
}
}
/**
* Create profile search filter
*/
async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<SearchFilter> {
// For npub searches, try to decode the search term first
try {
const decoded = nip19.decode(normalizedSearchTerm);
if (decoded && decoded.type === 'npub') {
return {
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'npub-specific'
};
}
} catch (e) {
// Not a valid npub, continue with other strategies
}
// Try NIP-05 lookup first
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
return {
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'nip05-found'
};
}
} catch (e) {
// Continue to next domain
}
}
} catch (e) {
// Fallback to reasonable profile search
}
return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: 'profile'
};
}
/**
* Create primary relay set based on search type
*/
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet {
if (searchType === 'n') {
// For profile searches, use profile relays first
const profileRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/')
);
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
} else {
// For other searches, use community relay first
const communityRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + '/'
);
return new NDKRelaySet(new Set(communityRelaySet) as any, ndk);
}
}
/**
* Process primary relay results
*/
function processPrimaryRelayResults(
events: Set<NDKEvent>,
searchType: SearchSubscriptionType,
subscriptionType: string,
normalizedSearchTerm: string,
searchState: any,
abortSignal?: AbortSignal,
cleanup?: () => void
) {
console.log("subscription_search: Processing", events.size, "events from primary relay");
for (const event of events) {
// Check for abort signal
if (abortSignal?.aborted) {
cleanup?.();
throw new Error('Search cancelled');
}
try {
if (searchType === 'n') {
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
console.warn("subscription_search: Error processing event:", e);
// Invalid JSON or other error, skip
}
}
console.log("subscription_search: Processed events - firstOrder:", searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, "tTag:", searchState.tTagEvents.length);
}
/**
* Process profile event
*/
function processProfileEvent(event: NDKEvent, subscriptionType: string, normalizedSearchTerm: string, searchState: any) {
if (!event.content) return;
// If this is a specific npub search or NIP-05 found search, include all matching events
if (subscriptionType === 'npub-specific' || subscriptionType === 'nip05-found') {
searchState.foundProfiles.push(event);
return;
}
// For general profile searches, filter by content
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const username = profileData.username || '';
const about = profileData.about || '';
const bio = profileData.bio || '';
const description = profileData.description || '';
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesUsername = fieldMatches(username, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
const matchesBio = fieldMatches(bio, normalizedSearchTerm);
const matchesDescription = fieldMatches(description, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesUsername || matchesAbout || matchesBio || matchesDescription) {
searchState.foundProfiles.push(event);
}
}
/**
* Process content event
*/
function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType, searchState: any) {
if (isEmojiReaction(event)) return; // Skip emoji reactions
if (searchType === 'd') {
console.log("subscription_search: Processing d-tag event:", { id: event.id, kind: event.kind, pubkey: event.pubkey });
searchState.firstOrderEvents.push(event);
// Collect event IDs and addresses for second-order search
if (event.id) {
searchState.eventIds.add(event.id);
}
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
tags.forEach((tag: string[]) => {
if (tag[1]) {
searchState.eventAddresses.add(tag[1]);
}
});
} else if (searchType === 't') {
searchState.tTagEvents.push(event);
}
}
/**
* Check if search state has results
*/
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean {
if (searchType === 'n') {
return searchState.foundProfiles.length > 0;
} else if (searchType === 'd') {
return searchState.firstOrderEvents.length > 0;
} else if (searchType === 't') {
return searchState.tTagEvents.length > 0;
}
return false;
}
/**
* Create search result from state
*/
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult {
return {
events: searchType === 'n' ? searchState.foundProfiles : searchType === 't' ? searchState.tTagEvents : searchState.firstOrderEvents,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: normalizedSearchTerm
};
}
/**
* Search other relays in background
*/
async function searchOtherRelaysInBackground(
searchType: SearchSubscriptionType,
searchFilter: SearchFilter,
searchState: any,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal,
cleanup?: () => void
): Promise<SearchResult> {
const ndk = get(ndkInstance);
const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === 'n') {
// For profile searches, exclude profile relays from fallback search
return !profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/');
} else {
// For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/';
}
})),
ndk
);
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,
{ closeOnEose: true },
otherRelays
);
// Store the subscription for cleanup
searchState.currentSubscription = sub;
// Notify the component about the subscription for cleanup
if (callbacks?.onSubscriptionCreated) {
callbacks.onSubscriptionCreated(sub);
}
sub.on('event', (event: NDKEvent) => {
try {
if (searchType === 'n') {
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
return new Promise<SearchResult>((resolve) => {
sub.on('eose', () => {
const result = processEoseResults(searchType, searchState, searchFilter, callbacks);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
});
});
}
/**
* Process EOSE results
*/
function processEoseResults(
searchType: SearchSubscriptionType,
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks
): SearchResult {
if (searchType === 'n') {
return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === 'd') {
return processContentEoseResults(searchState, searchType);
} else if (searchType === 't') {
return processTTagEoseResults(searchState);
}
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
/**
* Process profile EOSE results
*/
function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, callbacks?: SearchCallbacks): SearchResult {
if (searchState.foundProfiles.length === 0) {
return createEmptySearchResult('n', searchState.normalizedSearchTerm);
}
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.foundProfiles) {
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) {
deduped[pubkey] = { event, created_at };
}
}
// Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at)
.map(x => x.event);
// Perform second-order search for npub searches
if (searchFilter.subscriptionType === 'npub-specific' || searchFilter.subscriptionType === 'nip05-found') {
const targetPubkey = dedupedProfiles[0]?.pubkey;
if (targetPubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), targetPubkey, callbacks);
}
} else if (searchFilter.subscriptionType === 'profile') {
// For general profile searches, perform second-order search for each found profile
for (const profile of dedupedProfiles) {
if (profile.pubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks);
}
}
}
return {
events: dedupedProfiles,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(dedupedProfiles.map(p => p.id)),
addresses: new Set(),
searchType: 'n',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process content EOSE results
*/
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult {
if (searchState.firstOrderEvents.length === 0) {
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
// Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.firstOrderEvents) {
const dTag = getMatchingTags(event, 'd')[0]?.[1] || '';
const key = `${event.kind}:${event.pubkey}:${dTag}`;
const created_at = event.created_at || 0;
if (!deduped[key] || deduped[key].created_at < created_at) {
deduped[key] = { event, created_at };
}
}
const dedupedEvents = Object.values(deduped).map(x => x.event);
// Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses);
}
return {
events: dedupedEvents,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process t-tag EOSE results
*/
function processTTagEoseResults(searchState: any): SearchResult {
if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult('t', searchState.normalizedSearchTerm);
}
return {
events: searchState.tTagEvents,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: 't',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Create empty search result
*/
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult {
return {
events: [],
secondOrder: [],
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: searchType,
searchTerm: searchTerm
};
}
/**
* Perform second-order search in background
*/
async function performSecondOrderSearchInBackground(
searchType: 'n' | 'd',
firstOrderEvents: NDKEvent[],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
targetPubkey?: string,
callbacks?: SearchCallbacks
) {
try {
const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = [];
// Set a timeout for second-order search
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Second-order search timeout')), TIMEOUTS.SECOND_ORDER_SEARCH);
});
const searchPromise = (async () => {
if (searchType === 'n' && targetPubkey) {
// Search for events that mention this pubkey via p-tags
const pTagFilter = { '#p': [targetPubkey] };
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents];
} else if (searchType === 'd') {
// Parallel fetch for #e and #a tag events
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk);
const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0
? ndk.fetchEvents(
{ '#e': Array.from(eventIds) },
{ closeOnEose: true },
relaySet
)
: Promise.resolve([]),
addresses.size > 0
? ndk.fetchEvents(
{ '#a': Array.from(addresses) },
{ closeOnEose: true },
relaySet
)
: Promise.resolve([]),
]);
// Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event));
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents, ...filteredATagEvents];
}
// Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach(event => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Remove any events already in first order
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id));
// Sort by creation date (newest first) and limit to newest results
const sortedSecondOrder = deduplicatedSecondOrder
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
// Update the search results with second-order events
const result: SearchResult = {
events: firstOrderEvents,
secondOrder: sortedSecondOrder,
tTagEvents: [],
eventIds: searchType === 'n' ? new Set(firstOrderEvents.map(p => p.id)) : eventIds,
addresses: searchType === 'n' ? new Set() : addresses,
searchType: searchType,
searchTerm: '' // This will be set by the caller
};
// Notify UI of updated results
if (callbacks?.onSecondOrderUpdate) {
callbacks.onSecondOrderUpdate(result);
}
})();
// Race between search and timeout
await Promise.race([searchPromise, timeoutPromise]);
} catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err);
}
}

27
src/routes/+layout.svelte

@ -7,12 +7,13 @@
import { HammerSolid } from "flowbite-svelte-icons"; import { HammerSolid } from "flowbite-svelte-icons";
// Get standard metadata for OpenGraph tags // Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria'; let title = "Library of Alexandria";
let currentUrl = $page.url.href; let currentUrl = $page.url.href;
// Get default image and summary for the Alexandria website // Get default image and summary for the Alexandria website
let image = '/screenshots/old_books.jpg'; let image = "/screenshots/old_books.jpg";
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'; let summary =
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
onMount(() => { onMount(() => {
const rect = document.body.getBoundingClientRect(); const rect = document.body.getBoundingClientRect();
@ -23,24 +24,24 @@
<svelte:head> <svelte:head>
<!-- Basic meta tags --> <!-- Basic meta tags -->
<title>{title}</title> <title>{title}</title>
<meta name="description" content="{summary}" /> <meta name="description" content={summary} />
<!-- OpenGraph meta tags --> <!-- OpenGraph meta tags -->
<meta property="og:title" content="{title}" /> <meta property="og:title" content={title} />
<meta property="og:description" content="{summary}" /> <meta property="og:description" content={summary} />
<meta property="og:url" content="{currentUrl}" /> <meta property="og:url" content={currentUrl} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Alexandria" /> <meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content="{image}" /> <meta property="og:image" content={image} />
<!-- Twitter Card meta tags --> <!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content="{summary}" /> <meta name="twitter:description" content={summary} />
<meta name="twitter:image" content="{image}" /> <meta name="twitter:image" content={image} />
</svelte:head> </svelte:head>
<div class={'leather mt-[76px] h-full w-full flex flex-col items-center'}> <div class={"leather mt-[76px] h-full w-full flex flex-col items-center"}>
<Navigation class='fixed top-0' /> <Navigation class="fixed top-0" />
<slot /> <slot />
</div> </div>

90
src/routes/+layout.ts

@ -1,32 +1,100 @@
import { feedTypeStorageKey } from '$lib/consts'; import { feedTypeStorageKey } from '$lib/consts';
import { FeedType } from '$lib/consts'; import { FeedType } from '$lib/consts';
import { getPersistedLogin, initNdk, loginWithExtension, ndkInstance } from '$lib/ndk'; import { getPersistedLogin, initNdk, ndkInstance } from '$lib/ndk';
import { loginWithExtension, loginWithAmber, loginWithNpub } from '$lib/stores/userStore';
import { loginMethodStorageKey } from '$lib/stores/userStore';
import Pharos, { pharosInstance } from '$lib/parser'; import Pharos, { pharosInstance } from '$lib/parser';
import { feedType } from '$lib/stores'; import { feedType } from '$lib/stores';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
import { get } from 'svelte/store';
export const ssr = false; export const ssr = false;
export const load: LayoutLoad = () => { export const load: LayoutLoad = () => {
const initialFeedType = localStorage.getItem(feedTypeStorageKey) as FeedType const initialFeedType =
?? FeedType.StandardRelays; (localStorage.getItem(feedTypeStorageKey) as FeedType) ??
FeedType.StandardRelays;
feedType.set(initialFeedType); feedType.set(initialFeedType);
const ndk = initNdk(); const ndk = initNdk();
ndkInstance.set(ndk); ndkInstance.set(ndk);
try { try {
// Michael J - 18 Jan 2025 - This will not work server-side, since the NIP-07 extension is only
// available in the browser, and the flags for persistent login are saved in the browser's
// local storage. If SSR is ever enabled, move this code block to run client-side.
const pubkey = getPersistedLogin(); const pubkey = getPersistedLogin();
if (pubkey) { const loginMethod = localStorage.getItem(loginMethodStorageKey);
// Michael J - 27 Jan 2025 - We don't await this call; it will run in the background and const logoutFlag = localStorage.getItem('alexandria/logout/flag');
// update Svelte stores to propagate data. console.log('Layout load - persisted pubkey:', pubkey);
loginWithExtension(pubkey); console.log('Layout load - persisted login method:', loginMethod);
console.log('Layout load - logout flag:', logoutFlag);
console.log('All localStorage keys:', Object.keys(localStorage));
if (pubkey && loginMethod && !logoutFlag) {
if (loginMethod === 'extension') {
console.log('Restoring extension login...');
loginWithExtension();
} else if (loginMethod === 'amber') {
// Attempt to restore Amber (NIP-46) session from localStorage
const relay = 'wss://relay.nsec.app';
const localNsec = localStorage.getItem('amber/nsec');
if (localNsec) {
import('@nostr-dev-kit/ndk').then(async ({ NDKNip46Signer, default: NDK }) => {
const ndk = get(ndkInstance);
try {
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria',
perms: 'sign_event:1;sign_event:4',
});
// Try to reconnect (blockUntilReady will resolve if Amber is running and session is valid)
await amberSigner.blockUntilReady();
const user = await amberSigner.user();
await loginWithAmber(amberSigner, user);
console.log('Amber session restored.');
} catch (err) {
// If reconnection fails, automatically fallback to npub-only mode
console.warn('Amber session could not be restored. Falling back to npub-only mode.');
try {
// Set the flag first, before login
localStorage.setItem('alexandria/amber/fallback', '1');
console.log('Set fallback flag in localStorage');
// Small delay to ensure flag is set
await new Promise(resolve => setTimeout(resolve, 100));
await loginWithNpub(pubkey);
console.log('Successfully fell back to npub-only mode.');
} catch (fallbackErr) {
console.error('Failed to fallback to npub-only mode:', fallbackErr);
}
}
});
} else {
// No session data, automatically fallback to npub-only mode
console.log('No Amber session data found. Falling back to npub-only mode.');
// Set the flag first, before login
localStorage.setItem('alexandria/amber/fallback', '1');
console.log('Set fallback flag in localStorage');
// Small delay to ensure flag is set
setTimeout(async () => {
try {
await loginWithNpub(pubkey);
console.log('Successfully fell back to npub-only mode.');
} catch (fallbackErr) {
console.error('Failed to fallback to npub-only mode:', fallbackErr);
}
}, 100);
}
} else if (loginMethod === 'npub') {
console.log('Restoring npub login...');
loginWithNpub(pubkey);
}
} else if (logoutFlag) {
console.log('Skipping auto-login due to logout flag');
localStorage.removeItem('alexandria/logout/flag');
} }
} catch (e) { } catch (e) {
console.warn(`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`); console.warn(`Failed to restore login: ${e}\n\nContinuing with anonymous session.`);
} }
const parser = new Pharos(ndk); const parser = new Pharos(ndk);

70
src/routes/+page.svelte

@ -1,66 +1,38 @@
<script lang='ts'> <script lang="ts">
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts'; import {
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte"; standardRelays,
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; fallbackRelays,
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; } from "$lib/consts";
import { Alert, Input } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
import { userStore } from '$lib/stores/userStore';
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
$effect(() => {
localStorage.setItem(feedTypeStorageKey, $feedType);
});
const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) {
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return '';
}
};
let searchQuery = $state(''); let searchQuery = $state('');
let user = $state($userStore);
userStore.subscribe(val => user = val);
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> <Alert
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' /> rounded={false}
<span class='font-medium'> id="alert-experimental"
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable. class="border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2"
>
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" />
<span class="font-medium">
Pardon our dust! The publication view is currently using an experimental
loader, and may be unstable.
</span> </span>
</Alert> </Alert>
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else}
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'> <div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
<ChevronDownOutline class='w-6 h-6' />
</Button>
<Input <Input
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Search publications by title or author..." placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base" class="flex-grow max-w-2xl min-w-[300px] text-base"
/> />
<Dropdown
class='w-fit p-2 space-y-2 text-sm'
triggeredBy="#feed-toggle-btn"
>
<li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li>
<li>
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio>
</li>
</Dropdown>
</div> </div>
{#if $feedType === FeedType.StandardRelays} <PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} userRelays={$ndkSignedIn ? $inboxRelays : []} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if}
{/if}
</main> </main>

22
src/routes/[...catchall]/+page.svelte

@ -1,13 +1,23 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { Button, P } from 'flowbite-svelte'; import { Button, P } from "flowbite-svelte";
</script> </script>
<div class="leather flex flex-col items-center justify-center min-h-screen text-center px-4"> <div
class="leather flex flex-col items-center justify-center min-h-screen text-center px-4"
>
<h1 class="h-leather mb-4">404 - Page Not Found</h1> <h1 class="h-leather mb-4">404 - Page Not Found</h1>
<P class="note-leather mb-6">The page you are looking for does not exist or has been moved.</P> <P class="note-leather mb-6"
>The page you are looking for does not exist or has been moved.</P
>
<div class="flex space-x-4"> <div class="flex space-x-4">
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button> <Button class="btn-leather !w-fit" on:click={() => goto("/")}
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button> >Return to Home</Button
>
<Button
class="btn-leather !w-fit"
outline
on:click={() => window.history.back()}>Go Back</Button
>
</div> </div>
</div> </div>

17
src/routes/about/+page.svelte

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { Heading, Img, P, A } from "flowbite-svelte"; import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from '$app/navigation';
import RelayStatus from "$lib/components/RelayStatus.svelte";
// Get the git tag version from environment variables // Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development"; const appVersion = import.meta.env.APP_VERSION || "development";
@ -15,7 +17,7 @@
> >
{#if isVersionKnown} {#if isVersionKnown}
<span <span
class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap" class="text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-nowrap"
>Version: {appVersion}</span >Version: {appVersion}</span
> >
{/if} {/if}
@ -34,9 +36,7 @@
</P> </P>
<P class="mb-3"> <P class="mb-3">
Please submit support issues on the <A href="/contact" Please submit support issues on the <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('/contact')}>Contact</button> page and follow us on <A
>Alexandria contact page</A
> and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel" href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank" > and <A href="https://geyser.fund/project/gitcitadel" target="_blank"
@ -45,11 +45,18 @@
</P> </P>
<P> <P>
We are easiest to contact over our Nostr address {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")}. Or, you can visit us on our <A We are easiest to contact over our Nostr address {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel",
)}. Or, you can visit us on our <A
href="https://gitcitadel.com" href="https://gitcitadel.com"
title="GitCitadel Homepage" title="GitCitadel Homepage"
target="_blank">homepage</A target="_blank">homepage</A
> and find out more about us, and the many projects we are working on. > and find out more about us, and the many projects we are working on.
</P> </P>
<div class="border-t pt-6">
<RelayStatus />
</div>
</main> </main>
</div> </div>

289
src/routes/contact/+page.svelte

@ -1,15 +1,16 @@
<script lang='ts'> <script lang="ts">
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk'; import { ndkInstance, ndkSignedIn } from '$lib/ndk';
import { userStore } from '$lib/stores/userStore';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
import type NDK from '@nostr-dev-kit/ndk'; import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue // @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte'; import LoginModal from "$lib/components/LoginModal.svelte";
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser'; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getMimeTags } from '$lib/utils/mime'; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from '$lib/snippets/UserSnippets.svelte'; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
// Function to close the success message // Function to close the success message
function closeSuccessMessage() { function closeSuccessMessage() {
@ -18,51 +19,57 @@
} }
function clearForm() { function clearForm() {
subject = ''; subject = "";
content = ''; content = "";
submissionError = ''; submissionError = "";
isExpanded = false; isExpanded = false;
activeTab = 'write'; activeTab = "write";
} }
let subject = $state(''); let subject = $state("");
let content = $state(''); let content = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let showLoginModal = $state(false); let showLoginModal = $state(false);
let submissionSuccess = $state(false); let submissionSuccess = $state(false);
let submissionError = $state(''); let submissionError = $state("");
let submittedEvent = $state<NDKEvent | null>(null); let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state(''); let issueLink = $state("");
let successfulRelays = $state<string[]>([]); let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false); let isExpanded = $state(false);
let activeTab = $state('write'); let activeTab = $state("write");
let showConfirmDialog = $state(false); let showConfirmDialog = $state(false);
// Store form data when user needs to login // Store form data when user needs to login
let savedFormData = { let savedFormData = {
subject: '', subject: "",
content: '' content: "",
}; };
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe(val => user = val);
// Repository event address from the task // Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Hard-coded relays to ensure we have working relays // Hard-coded relays to ensure we have working relays
const allRelays = [ const allRelays = [
'wss://relay.damus.io', "wss://relay.damus.io",
'wss://relay.nostr.band', "wss://relay.nostr.band",
'wss://nos.lol', "wss://nos.lol",
...standardRelays ...standardRelays,
]; ];
// Hard-coded repository owner pubkey and ID from the task // Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr // These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'; const repoOwnerPubkey =
const repoId = 'Alexandria'; "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1";
const repoId = "Alexandria";
// Function to normalize relay URLs by removing trailing slashes // Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string { function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, ''); return url.replace(/\/+$/, "");
} }
function toggleSize() { function toggleSize() {
@ -74,16 +81,16 @@
e.preventDefault(); e.preventDefault();
if (!subject || !content) { if (!subject || !content) {
submissionError = 'Please fill in all fields'; submissionError = "Please fill in all fields";
return; return;
} }
// Check if user is logged in // Check if user is logged in
if (!$ndkSignedIn) { if (!user.signedIn) {
// Save form data // Save form data
savedFormData = { savedFormData = {
subject, subject,
content content,
}; };
// Show login modal // Show login modal
@ -112,17 +119,17 @@
ndk: NDK, ndk: NDK,
relays: Set<string>, relays: Set<string>,
maxRetries: number = 3, maxRetries: number = 3,
timeout: number = 10000 timeout: number = 10000,
): Promise<string[]> { ): Promise<string[]> {
const successfulRelays: string[] = []; const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk); const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes // Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => { const publishPromises = Array.from(relays).map((relayUrl) => {
return new Promise<void>(resolve => { return new Promise<void>((resolve) => {
const relay = ndk.pool?.getRelay(relayUrl); const relay = ndk.pool?.getRelay(relayUrl);
if (relay) { if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => { relay.on("published", (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) { if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl); successfulRelays.push(relayUrl);
resolve(); resolve();
@ -140,13 +147,13 @@
// Start publishing with timeout // Start publishing with timeout
const publishPromise = event.publish(relaySet); const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout); setTimeout(() => reject(new Error("Publish timeout")), timeout);
}); });
await Promise.race([ await Promise.race([
publishPromise, publishPromise,
Promise.allSettled(publishPromises), Promise.allSettled(publishPromises),
timeoutPromise timeoutPromise,
]); ]);
if (successfulRelays.length > 0) { if (successfulRelays.length > 0) {
@ -155,11 +162,15 @@
if (attempt < maxRetries) { if (attempt < maxRetries) {
// Wait before retrying (exponential backoff) // Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000),
);
} }
} catch (error) { } catch (error) {
if (attempt === maxRetries && successfulRelays.length === 0) { if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts'); throw new Error(
"Failed to publish to any relays after multiple attempts",
);
} }
} }
} }
@ -169,18 +180,18 @@
async function submitIssue() { async function submitIssue() {
isSubmitting = true; isSubmitting = true;
submissionError = ''; submissionError = "";
submissionSuccess = false; submissionSuccess = false;
try { try {
// Get NDK instance // Get NDK instance
const ndk = $ndkInstance; const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
throw new Error('NDK instance not available'); throw new Error("NDK instance not available");
} }
if (!ndk.signer) { if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.'); throw new Error("No signer available. Make sure you are logged in.");
} }
// Create and prepare the event // Create and prepare the event
@ -189,9 +200,13 @@
// Collect all unique relays // Collect all unique relays
const uniqueRelays = new Set([ const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl), ...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values()) ...(ndk.pool
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol')) ? Array.from(ndk.pool.relays.values())
.map(relay => normalizeRelayUrl(relay.url)) : []) .filter(
(relay) => relay.url && !relay.url.includes("wss://nos.lol"),
)
.map((relay) => normalizeRelayUrl(relay.url))
: []),
]); ]);
try { try {
@ -209,10 +224,10 @@
clearForm(); clearForm();
submissionSuccess = true; submissionSuccess = true;
} catch (error) { } catch (error) {
throw new Error('Failed to publish event'); throw new Error("Failed to publish event");
} }
} catch (error: any) { } catch (error: any) {
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`; submissionError = `Error submitting issue: ${error.message || "Unknown error"}`;
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -224,20 +239,15 @@
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> { async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]); event.tags.push(["subject", subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]); event.tags.push(["alt", `git repository issue: ${subject}`]);
// Add repository reference with proper format // Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`; const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([ event.tags.push(["a", aTagValue, "", "root"]);
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value // Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]); event.tags.push(["p", repoOwnerPubkey]);
// Add MIME tags // Add MIME tags
const mimeTags = getMimeTags(1621); const mimeTags = getMimeTags(1621);
@ -250,7 +260,7 @@
try { try {
await event.sign(); await event.sign();
} catch (error) { } catch (error) {
throw new Error('Failed to sign event'); throw new Error("Failed to sign event");
} }
return event; return event;
@ -258,7 +268,7 @@
// Handle login completion // Handle login completion
$effect(() => { $effect(() => {
if ($ndkSignedIn && showLoginModal) { if (user.signedIn && showLoginModal) {
showLoginModal = false; showLoginModal = false;
// Restore saved form data // Restore saved form data
@ -269,44 +279,76 @@
submitIssue(); submitIssue();
} }
}); });
</script> </script>
<div class='w-full flex justify-center'> <div class="w-full flex justify-center">
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'> <main
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading> class="main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4"
>
<Heading tag="h1" class="h-leather mb-2">Contact GitCitadel</Heading>
<P class="mb-3"> <P class="mb-3">
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>. Make sure that you follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"
>Geyserfund</A
>.
</P> </P>
<P class="mb-3"> <P class="mb-3">
You can contact us on Nostr {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")} or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A> You can contact us on Nostr {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel",
)} or you can view submitted issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank">Alexandria repo page.</A
>
</P> </P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading> <Heading tag="h2" class="h-leather mt-4 mb-2">Submit an issue</Heading>
<P class="mb-3"> <P class="mb-3">
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. If you are logged into the Alexandria web application (using the button at
the top-right of the window), then you can use the form, below, to submit
an issue, that will appear on our repo page.
</P> </P>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off"> <form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div> <div>
<Label for="subject" class="mb-2">Subject</Label> <Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus /> <Input
id="subject"
class="w-full bg-white dark:bg-gray-800"
placeholder="Issue subject"
bind:value={subject}
required
autofocus
/>
</div> </div>
<div class="relative"> <div class="relative">
<Label for="content" class="mb-2">Description</Label> <Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"> <div
class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded
? 'h-[800px]'
: 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"
>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"> <div
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"
>
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
role="tablist"
>
<li class="mr-2" role="presentation"> <li class="mr-2" role="presentation">
<button <button
type="button" type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" class="inline-block p-4 rounded-t-lg {activeTab === 'write'
onclick={() => activeTab = 'write'} ? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "write")}
role="tab" role="tab"
> >
Write Write
@ -315,8 +357,11 @@
<li role="presentation"> <li role="presentation">
<button <button
type="button" type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" class="inline-block p-4 rounded-t-lg {activeTab ===
onclick={() => activeTab = 'preview'} 'preview'
? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "preview")}
role="tab" role="tab"
> >
Preview Preview
@ -326,11 +371,11 @@
</div> </div>
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'} {#if activeTab === "write"}
<div class="absolute inset-0 overflow-hidden"> <div class="absolute inset-0 overflow-hidden">
<Textarea <Textarea
id="content" id="content"
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-400 dark:focus:border-primary-500" class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-600 dark:focus:border-primary-400"
bind:value={content} bind:value={content}
required required
placeholder="Describe your issue in detail... placeholder="Describe your issue in detail...
@ -373,14 +418,19 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
/> />
</div> </div>
{:else} {:else}
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"> <div
class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"
>
{#key content} {#key content}
{#await parseAdvancedmarkup(content)} {#await parseAdvancedmarkup(content)}
<p>Loading preview...</p> <p>Loading preview...</p>
{:then html} {:then html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'} {@html html ||
'<p class="text-gray-700 dark:text-gray-300">Nothing to preview</p>'}
{:catch error} {:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p> <p class="text-red-500">
Error rendering preview: {error.message}
</p>
{/await} {/await}
{/key} {/key}
</div> </div>
@ -394,7 +444,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
color="light" color="light"
on:click={toggleSize} on:click={toggleSize}
> >
{isExpanded ? '⌃' : '⌄'} {isExpanded ? "⌃" : "⌄"}
</Button> </Button>
</div> </div>
</div> </div>
@ -413,29 +463,59 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div> </div>
{#if submissionSuccess && submittedEvent} {#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert"> <div
class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative"
role="alert"
>
<!-- Close button --> <!-- Close button -->
<button <button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100" class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeSuccessMessage} onclick={closeSuccessMessage}
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg> </svg>
</button> </button>
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <svg
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path> class="w-5 h-5 mr-2 text-success-700 dark:text-success-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg> </svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span> <span class="font-medium text-success-800 dark:text-success-200"
>Issue submitted successfully!</span
>
</div> </div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"> <div
class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
>
<div class="mb-2"> <div class="mb-2">
<span class="font-semibold">Subject:</span> <span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span> <span
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
"No subject"}</span
>
</div> </div>
<div> <div>
<span class="font-semibold">Description:</span> <span class="font-semibold">Description:</span>
@ -445,7 +525,9 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{:then html} {:then html}
{@html html} {@html html}
{:catch error} {:catch error}
<p class="text-red-500">Error rendering markup: {error.message}</p> <p class="text-red-500">
Error rendering markup: {error.message}
</p>
{/await} {/await}
</div> </div>
</div> </div>
@ -454,7 +536,11 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<div class="mb-3"> <div class="mb-3">
<span class="font-semibold">View your issue:</span> <span class="font-semibold">View your issue:</span>
<div class="mt-1"> <div class="mt-1">
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all"> <A
href={issueLink}
target="_blank"
class="hover:underline text-primary-600 dark:text-primary-500 break-all"
>
{issueLink} {issueLink}
</A> </A>
</div> </div>
@ -473,33 +559,26 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{/if} {/if}
{#if submissionError} {#if submissionError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{submissionError} {submissionError}
</div> </div>
{/if} {/if}
</form> </form>
</main> </main>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->
<Modal <Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full">
bind:open={showConfirmDialog}
size="sm"
autoclose={false}
class="w-full"
>
<div class="text-center"> <div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"> <h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300">
Would you like to submit the issue? Would you like to submit the issue?
</h3> </h3>
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}> <Button color="alternative" on:click={cancelSubmit}>Cancel</Button>
Cancel <Button color="primary" on:click={confirmSubmit}>Submit</Button>
</Button>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>
@ -507,7 +586,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- Login Modal --> <!-- Login Modal -->
<LoginModal <LoginModal
show={showLoginModal} show={showLoginModal}
onClose={() => showLoginModal = false} onClose={() => (showLoginModal = false)}
onLoginSuccess={() => { onLoginSuccess={() => {
// Restore saved form data // Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject; if (savedFormData.subject) subject = savedFormData.subject;

802
src/routes/events/+page.svelte

@ -2,16 +2,37 @@
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte'; import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte'; import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte'; import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte'; import CommentBox from '$lib/components/CommentBox.svelte';
import { userStore } from '$lib/stores/userStore';
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte';
import { userPubkey, isLoggedIn } from '$lib/stores/authStore.Svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils';
import { standardRelays } from '$lib/consts';
import { getEventType } from '$lib/utils/mime';
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte';
import { checkCommunity } from '$lib/utils/search_utility';
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let searchValue = $state<string | null>(null); let searchValue = $state<string | null>(null);
let dTagValue = $state<string | null>(null);
let event = $state<NDKEvent | null>(null); let event = $state<NDKEvent | null>(null);
let searchResults = $state<NDKEvent[]>([]);
let secondOrderResults = $state<NDKEvent[]>([]);
let tTagResults = $state<NDKEvent[]>([]);
let originalEventIds = $state<Set<string>>(new Set());
let originalAddresses = $state<Set<string>>(new Set());
let searchType = $state<string | null>(null);
let searchTerm = $state<string | null>(null);
let profile = $state<{ let profile = $state<{
name?: string; name?: string;
display_name?: string; display_name?: string;
@ -22,11 +43,29 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let userPubkey = $state<string | null>(null); let user = $state($userStore);
let userRelayPreference = $state(false); let userRelayPreference = $state(false);
let showSidePanel = $state(false);
let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({});
userStore.subscribe(val => user = val);
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true;
// Clear search results when showing a single event
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
searchType = null;
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) { if (newEvent.kind === 0) {
try { try {
profile = JSON.parse(newEvent.content); profile = JSON.parse(newEvent.content);
@ -38,42 +77,775 @@
} }
} }
onMount(async () => { // Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes
const id = $page.url.searchParams.get('id'); $effect(() => {
if (id) { const url = $page.url.searchParams;
searchValue = url.get('id') ?? url.get('d');
});
// Add support for t and n parameters
$effect(() => {
const url = $page.url.searchParams;
const tParam = url.get('t');
const nParam = url.get('n');
if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix
const decodedT = decodeURIComponent(tParam);
searchValue = `t:${decodedT}`;
}
if (nParam) {
// Decode the n parameter and set it as searchValue with n: prefix
const decodedN = decodeURIComponent(nParam);
searchValue = `n:${decodedN}`;
}
});
function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set(), searchTypeParam?: string, searchTermParam?: string) {
searchResults = results;
secondOrderResults = secondOrder;
tTagResults = tTagEvents;
originalEventIds = eventIds;
originalAddresses = addresses;
searchType = searchTypeParam || null;
searchTerm = searchTermParam || null;
// Track search progress
searchInProgress = loading || (results.length > 0 && secondOrder.length === 0);
// Show second-order search message when we have first-order results but no second-order yet
if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'n') {
secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`;
} else if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'd') {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
}
// Check community status for all search results
if (results.length > 0) {
checkCommunityStatusForResults(results);
}
if (secondOrder.length > 0) {
checkCommunityStatusForResults(secondOrder);
}
if (tTagEvents.length > 0) {
checkCommunityStatusForResults(tTagEvents);
}
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
}
function handleClear() {
searchType = null;
searchTerm = null;
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
event = null;
profile = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
communityStatus = {};
goto('/events', { replaceState: true });
}
function closeSidePanel() {
showSidePanel = false;
event = null;
profile = null;
searchInProgress = false;
secondOrderSearchMessage = null;
}
function navigateToPublication(dTag: string) {
goto(`/publications?d=${encodeURIComponent(dTag.toLowerCase())}`);
}
function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, "summary")[0]?.[1];
}
function getDeferralNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...']
return getMatchingTags(event, "deferral")[0]?.[1];
}
function getReferenceType(event: NDKEvent, originalEventIds: Set<string>, originalAddresses: Set<string>): string {
// Check if this event has e-tags referencing original events
const eTags = getMatchingTags(event, "e");
for (const tag of eTags) {
if (originalEventIds.has(tag[1])) {
return "Reply/Reference (e-tag)";
}
}
// Check if this event has a-tags or e-tags referencing original events
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
for (const tag of tags) {
if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)";
}
}
// Check if this event has content references
if (event.content) {
for (const id of originalEventIds) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, 'i');
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, 'i');
if (neventPattern.test(event.content) || notePattern.test(event.content)) {
return "Content Reference";
}
}
for (const address of originalAddresses) {
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, 'i');
if (naddrPattern.test(event.content)) {
return "Content Reference";
}
}
}
return "Reference";
}
function getNeventAddress(event: NDKEvent): string {
return neventEncode(event, standardRelays);
}
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, standardRelays);
} catch {
return null;
}
}
function getViewPublicationNaddr(event: NDKEvent): string | null {
// For deferred events, 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 shortenAddress(addr: string, head = 10, tail = 10): string {
if (!addr || addr.length <= head + tail + 3) return addr;
return addr.slice(0, head) + '…' + addr.slice(-tail);
}
function onLoadingChange(val: boolean) {
loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0);
}
/**
* Check community status for all search results
*/
async function checkCommunityStatusForResults(events: NDKEvent[]) {
const newCommunityStatus: Record<string, boolean> = {};
for (const event of events) {
if (event.pubkey && !communityStatus[event.pubkey]) {
try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) {
console.error('Error checking community status for', event.pubkey, error);
newCommunityStatus[event.pubkey] = false;
}
} else if (event.pubkey) {
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey];
}
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
function updateSearchFromURL() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n");
console.log("Events page URL update:", { id, dTag, tParam, nParam, searchValue });
if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
searchValue = id; searchValue = id;
dTagValue = null;
// Only close side panel if we're clearing the search
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
if (dTag !== dTagValue) {
console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag });
// Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
// For d-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
// Handle t parameter
if (tParam) {
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) {
console.log("T parameter changed, updating searchValue:", { old: searchValue, new: tSearchValue });
searchValue = tSearchValue;
dTagValue = null;
// For t-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle n parameter
if (nParam) {
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) {
console.log("N parameter changed, updating searchValue:", { old: searchValue, new: nSearchValue });
searchValue = nSearchValue;
dTagValue = null;
// For n-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
}
// Reset state if all parameters are absent
if (!id && !dTag && !tParam && !nParam) {
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
}
}
// Force search when URL changes
function handleUrlChange() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n");
console.log("Events page URL change:", { id, dTag, tParam, nParam, currentSearchValue: searchValue, currentDTagValue: dTagValue });
// Handle ID parameter changes
if (id !== searchValue) {
console.log("ID parameter changed:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle d-tag parameter changes
if (dTag !== dTagValue) {
console.log("d-tag parameter changed:", { old: dTagValue, new: dTag });
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
showSidePanel = false;
event = null;
profile = null;
}
// Handle t parameter changes
if (tParam) {
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) {
console.log("t parameter changed:", { old: searchValue, new: tSearchValue });
searchValue = tSearchValue;
dTagValue = null;
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle n parameter changes
if (nParam) {
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) {
console.log("n parameter changed:", { old: searchValue, new: nSearchValue });
searchValue = nSearchValue;
dTagValue = null;
showSidePanel = false;
event = null;
profile = null;
}
}
// Reset state if all parameters are absent
if (!id && !dTag && !tParam && !nParam) {
console.log("All parameters absent, resetting state");
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
searchValue = null;
dTagValue = null;
}
} }
// Get user's pubkey and relay preference from localStorage // Listen for URL changes
userPubkey = localStorage.getItem('userPubkey'); $effect(() => {
handleUrlChange();
});
onMount(() => {
userRelayPreference = localStorage.getItem('useUserRelays') === 'true'; userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
// Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error);
}); });
</script> </script>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> <div class="flex w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-80 min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading> <Heading tag="h1" class="h-leather mb-2">Events</Heading>
{#if showSidePanel}
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
Close Details
</button>
{/if}
</div> </div>
<P class="mb-3"> <P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID). Use this page to view any event (npub, nprofile, nevent, naddr, note,
pubkey, or eventID). You can also search for events by d-tag using the
format "d:tag-name".
</P> </P>
<EventSearch {loading} {error} {searchValue} {event} onEventFound={handleEventFound} /> <EventSearch
{#if event} {loading}
{error}
{searchValue}
{dTagValue}
{event}
onEventFound={handleEventFound}
onSearchResults={handleSearchResults}
onClear={handleClear}
onLoadingChange={onLoadingChange}
/>
{#if secondOrderSearchMessage}
<div class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg">
{secondOrderSearchMessage}
</div>
{/if}
{#if searchResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
{#if searchType === 'n'}
Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
{:else if searchType === 't'}
Search Results for t-tag: "{searchTerm}" ({searchResults.length} events)
{:else}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
{#each searchResults as result, index}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>{searchType === 'n' ? 'Profile' : 'Event'} {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<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}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
)}
</span>
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
: "Unknown date"}
</span>
</div>
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
>
{getSummary(result)}
</div>
{/if}
{#if getDeferralNaddr(result)}
<div
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
? "..."
: ""}
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
{#if secondOrderResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events)
</Heading>
{#if (searchType === 'n' || searchType === 'd') && secondOrderResults.length === 100}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing the 100 newest events. More results may be available.
</P>
{/if}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that reference, reply to, highlight, or quote the original events.
</P>
<div class="space-y-4">
{#each secondOrderResults as result, index}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>Reference {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<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}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
)}
</span>
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
: "Unknown date"}
</span>
</div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
{getReferenceType(result, originalEventIds, originalAddresses)}
</div>
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
>
{getSummary(result)}
</div>
{/if}
{#if getDeferralNaddr(result)}
<div
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
? "..."
: ""}
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
{#if tTagResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({tTagResults.length} events)
</Heading>
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag.
</P>
<div class="space-y-4">
{#each tTagResults as result, index}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>Tagged Event {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#if result.pubkey && communityStatus[result.pubkey]}
<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}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
)}
</span>
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
: "Unknown date"}
</span>
</div>
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
>
{getSummary(result)}
</div>
{/if}
{#if getDeferralNaddr(result)}
<div
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
onclick={(e) => {
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
<div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
? "..."
: ""}
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue && !searchInProgress}
<div class="mt-8">
<EventInput />
</div>
{/if}
</div>
</div>
<!-- Right Panel: Event Details -->
{#if showSidePanel && event}
<div class="flex-1 min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
</button>
</div>
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getViewPublicationNaddr(event)}
{#if naddrAddress}
<CopyToClipboard
displayText={shortenAddress(naddrAddress)}
copyText={naddrAddress}
/>
<div class="mt-2">
<ViewPublicationLink {event} />
</div>
{/if}
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} /> <EventDetails {event} {profile} {searchValue} />
<RelayActions {event} /> <RelayActions {event} />
{#if userPubkey}
{#if isLoggedIn && userPubkey}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading> <Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} /> <CommentBox {event} {userRelayPreference} />
</div> </div>
{:else} {:else}
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> <div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<P>Please sign in to add comments.</P> <P>Please sign in to add comments.</P>
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
</main> </div>
</div> </div>

1
src/routes/new/compose/+page.svelte

@ -1,4 +1,3 @@
<script lang='ts'> <script lang='ts'>
import { Heading, Button, Alert } from "flowbite-svelte"; import { Heading, Button, Alert } from "flowbite-svelte";
import { PaperPlaneOutline } from "flowbite-svelte-icons"; import { PaperPlaneOutline } from "flowbite-svelte-icons";

62
src/routes/new/edit/+page.svelte

@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Heading, Textarea, Toolbar, ToolbarButton, Tooltip } from "flowbite-svelte"; import {
import { CodeOutline, EyeSolid, PaperPlaneOutline } from "flowbite-svelte-icons"; Heading,
Textarea,
Toolbar,
ToolbarButton,
Tooltip,
} from "flowbite-svelte";
import {
CodeOutline,
EyeSolid,
PaperPlaneOutline,
} from "flowbite-svelte-icons";
import Preview from "$lib/components/Preview.svelte"; import Preview from "$lib/components/Preview.svelte";
import Pharos, { pharosInstance } from "$lib/parser"; import Pharos, { pharosInstance } from "$lib/parser";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
@ -44,44 +54,52 @@
} }
$pharosInstance.generate($ndkInstance.activeUser?.pubkey!); $pharosInstance.generate($ndkInstance.activeUser?.pubkey!);
goto('/new/compose'); goto("/new/compose");
} };
</script> </script>
<div class='w-full flex justify-center'> <div class="w-full flex justify-center">
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> <main class="main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4">
<Heading tag='h1' class='h-leather mb-2'>Edit</Heading> <Heading tag="h1" class="h-leather mb-2">Edit</Heading>
{#if isEditing} {#if isEditing}
<form> <form>
<Textarea <Textarea
id='article-content' id="article-content"
class='textarea-leather' class="textarea-leather"
rows={8} rows={8}
placeholder='Write AsciiDoc content' placeholder="Write AsciiDoc content"
bind:value={editorText} bind:value={editorText}
> >
<Toolbar slot='header' embedded> <Toolbar slot="header" embedded>
<ToolbarButton name='Preview' on:click={showPreview}> <ToolbarButton name="Preview" on:click={showPreview}>
<EyeSolid class='w-6 h-6' /> <EyeSolid class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
<form class='border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit'> <form
<Toolbar class='toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800'> class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit"
<ToolbarButton name='Edit' on:click={hidePreview}> >
<CodeOutline class='w-6 h-6' /> <Toolbar
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
>
<ToolbarButton name="Edit" on:click={hidePreview}>
<CodeOutline class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview sectionClass='m-2' rootId={rootIndexId} index={someIndexValue} /> <Preview
sectionClass="m-2"
rootId={rootIndexId}
index={someIndexValue}
/>
{/if} {/if}
</form> </form>
{/if} {/if}

40
src/routes/publication/+error.svelte

@ -1,29 +1,37 @@
<script lang='ts'> <script lang="ts">
import { invalidateAll, goto } from '$app/navigation'; import { invalidateAll, goto } from "$app/navigation";
import { Alert, P, Button } from 'flowbite-svelte'; import { Alert, P, Button } from "flowbite-svelte";
import { ExclamationCircleOutline } from 'flowbite-svelte-icons'; import { ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from '$app/state'; import { page } from "$app/state";
</script> </script>
<main> <main>
<Alert> <Alert>
<div class='flex items-center space-x-2'> <div class="flex items-center space-x-2">
<ExclamationCircleOutline class='w-6 h-6' /> <ExclamationCircleOutline class="w-6 h-6" />
<span class='text-lg font-medium'> <span class="text-lg font-medium"> Failed to load publication. </span>
Failed to load publication.
</span>
</div> </div>
<P size='sm'> <P size="sm">
Alexandria failed to find one or more of the events comprising this publication. Alexandria failed to find one or more of the events comprising this
publication.
</P> </P>
<P size='xs'> <P size="xs">
{page.error?.message} {page.error?.message}
</P> </P>
<div class='flex space-x-2'> <div class="flex space-x-2">
<Button class='btn-leather !w-fit' size='sm' onclick={() => invalidateAll()}> <Button
class="btn-leather !w-fit"
size="sm"
onclick={() => invalidateAll()}
>
Try Again Try Again
</Button> </Button>
<Button class='btn-leather !w-fit' size='sm' outline onclick={() => goto('/')}> <Button
class="btn-leather !w-fit"
size="sm"
outline
onclick={() => goto("/")}
>
Return home Return home
</Button> </Button>
</div> </div>

1
src/routes/publication/+page.svelte

@ -68,6 +68,7 @@
{#await data.waitable} {#await data.waitable}
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" /> <TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then} {:then}
{@const debugInfo = console.debug(`[Publication Page] Data loaded, rendering Publication component with publicationType: ${data.publicationType}, rootAddress: ${data.indexEvent.tagAddress()}`)}
<Publication <Publication
rootAddress={data.indexEvent.tagAddress()} rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType} publicationType={data.publicationType}

46
src/routes/publication/+page.ts

@ -1,28 +1,28 @@
import { error } from '@sveltejs/kit'; import { error } from "@sveltejs/kit";
import type { Load } from '@sveltejs/kit'; import type { Load } from "@sveltejs/kit";
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 { getActiveRelays } from '$lib/ndk'; import { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
/** /**
* Decodes an naddr identifier and returns a filter object * Decodes an naddr identifier and returns a filter object
*/ */
function decodeNaddr(id: string) { function decodeNaddr(id: string) {
try { try {
if (!id.startsWith('naddr')) return {}; if (!id.startsWith("naddr")) return {};
const decoded = nip19.decode(id); const decoded = nip19.decode(id);
if (decoded.type !== 'naddr') return {}; if (decoded.type !== "naddr") return {};
const data = decoded.data; const data = decoded.data;
return { return {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [data.pubkey],
'#d': [data.identifier] "#d": [data.identifier],
}; };
} catch (e) { } catch (e) {
console.error('Failed to decode naddr:', e); console.error("Failed to decode naddr:", e);
return null; return null;
} }
} }
@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
const hasFilter = Object.keys(filter).length > 0; const hasFilter = Object.keys(filter).length > 0;
try { try {
const event = await (hasFilter ? const event = await (hasFilter
ndk.fetchEvent(filter) : ? ndk.fetchEvent(filter)
ndk.fetchEvent(id)); : ndk.fetchEvent(id));
if (!event) { if (!event) {
throw new Error(`Event not found for ID: ${id}`); throw new Error(`Event not found for ID: ${id}`);
@ -69,9 +69,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try { try {
const event = await ndk.fetchEvent( const event = await ndk.fetchEvent(
{ '#d': [dTag] }, { "#d": [dTag] },
{ closeOnEose: false }, { closeOnEose: false },
getActiveRelays(ndk) getActiveRelays(ndk),
); );
if (!event) { if (!event) {
@ -83,13 +83,19 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
} }
} }
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => { export const load: Load = async ({
const id = url.searchParams.get('id'); url,
const dTag = url.searchParams.get('d'); parent,
}: {
url: URL;
parent: () => Promise<any>;
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk, parser } = await parent(); const { ndk, parser } = await parent();
if (!id && !dTag) { if (!id && !dTag) {
throw error(400, 'No publication root event ID or d tag provided.'); throw error(400, "No publication root event ID or d tag provided.");
} }
// Fetch the event based on available parameters // Fetch the event based on available parameters
@ -97,7 +103,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id) ? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!); : await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1]; const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
const fetchPromise = parser.fetch(indexEvent); const fetchPromise = parser.fetch(indexEvent);
return { return {

28
src/routes/start/+page.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Heading, Img, P, A } from "flowbite-svelte"; import { Heading, Img, P, A } from "flowbite-svelte";
import { goto } from '$app/navigation';
// Get the git tag version from environment variables // Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development"; const appVersion = import.meta.env.APP_VERSION || "development";
@ -15,7 +16,7 @@
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading> <Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="mb-4"> <P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user Alexandria opens up to the <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('./')}>landing page</button>, where the user
can: login (top-right), select whether to only view the publications can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank" hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A >thecitadel document relay</A
@ -54,10 +55,9 @@
Each content section (30041 or 30818) is also a level in the table of Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. reading view. This allows for navigation within the publication.
Publications of type "blog" have a ToC which emphasizes that each entry Publications of type "blog" have a ToC which emphasizes that each entry is
is a blog post. a blog post. (This functionality has been temporarily disabled, but the
TOC is visible.)
(This functionality has been temporarily disabled, but the TOC is visible.)
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="flex flex-col items-center space-y-4 my-4">
@ -87,9 +87,9 @@
</P> </P>
<P class="mb-3"> <P class="mb-3">
An example of a book is <A An example of a book is <a
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition" href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</A >Jane Eyre</a
> >
</P> </P>
@ -123,9 +123,9 @@
</P> </P>
<P class="mb-3"> <P class="mb-3">
An example of a research paper is <A An example of a research paper is <a
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1" href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</A >Less Partnering, Less Children, or Both?</a
> >
</P> </P>
@ -141,11 +141,11 @@
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading> <Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="mb-3"> <P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A Our own team uses Alexandria to document the app, to display our <a
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</a
>, as well as to store copies of our most interesting <A >, as well as to store copies of our most interesting <a
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1" href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</A >technical specifications</a
>. >.
</P> </P>
@ -163,7 +163,7 @@
<P class="mb-3"> <P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same one about the <button class="underline text-primary-700 bg-transparent border-none p-0" onclick={() => goto('/publication?d=sybil')}>Sybil utility</button> use the same
Asciidoc format as other publications but are specifically designed for interconnected, Asciidoc format as other publications but are specifically designed for interconnected,
evolving content. evolving content.
</P> </P>

20
src/routes/visualize/+page.svelte

@ -49,7 +49,7 @@
const indexEvents = await $ndkInstance.fetchEvents( const indexEvents = await $ndkInstance.fetchEvents(
{ {
kinds: [INDEX_EVENT_KIND], kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit limit: $networkFetchLimit,
}, },
{ {
groupable: true, groupable: true,
@ -66,10 +66,15 @@
// Step 3: Extract content event IDs from index events // Step 3: Extract content event IDs from index events
const contentEventIds = new Set<string>(); const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => { validIndexEvents.forEach((event) => {
const aTags = event.getMatchingTags("a"); // Handle both "a" tags (NIP-62) and "e" tags (legacy)
debug(`Event ${event.id} has ${aTags.length} a-tags`); let tags = event.getMatchingTags("a");
if (tags.length === 0) {
tags = event.getMatchingTags("e");
}
debug(`Event ${event.id} has ${tags.length} tags (${tags.length > 0 ? (event.getMatchingTags("a").length > 0 ? "a" : "e") : "none"})`);
aTags.forEach((tag) => { tags.forEach((tag) => {
const eventId = tag[3]; const eventId = tag[3];
if (eventId) { if (eventId) {
contentEventIds.add(eventId); contentEventIds.add(eventId);
@ -79,7 +84,9 @@
debug("Content event IDs to fetch:", contentEventIds.size); debug("Content event IDs to fetch:", contentEventIds.size);
// Step 4: Fetch the referenced content events // Step 4: Fetch the referenced content events
debug(`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(', ')})`); debug(
`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(", ")})`,
);
const contentEvents = await $ndkInstance.fetchEvents( const contentEvents = await $ndkInstance.fetchEvents(
{ {
kinds: CONTENT_EVENT_KINDS, kinds: CONTENT_EVENT_KINDS,
@ -104,7 +111,6 @@
} }
} }
// Fetch events when component mounts // Fetch events when component mounts
onMount(() => { onMount(() => {
debug("Component mounted"); debug("Component mounted");
@ -123,7 +129,7 @@
<div role="status"> <div role="status">
<svg <svg
aria-hidden="true" aria-hidden="true"
class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" class="w-8 h-8 text-gray-300 animate-spin dark:text-gray-500 fill-blue-600"
viewBox="0 0 100 101" viewBox="0 0 100 101"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

2
src/styles/publications.css

@ -1,7 +1,7 @@
@layer components { @layer components {
/* AsciiDoc content */ /* AsciiDoc content */
.publication-leather p a { .publication-leather p a {
@apply underline hover:text-primary-500 dark:hover:text-primary-400; @apply underline hover:text-primary-600 dark:hover:text-primary-400;
} }
.publication-leather section p { .publication-leather section p {

2
src/styles/scrollbar.css

@ -14,7 +14,7 @@
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
@apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;; @apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;
border-radius: 6px; /* Rounded scrollbar */ border-radius: 6px; /* Rounded scrollbar */
} }
} }

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

Loading…
Cancel
Save