Browse Source

Merge remote-tracking branch 'origin/fix-merge' - preserving boss's improvements including NDK state sharing, code cleanup, and auth store refactoring

master
silberengel 7 months ago
parent
commit
14169961a5
  1. 40
      README.md
  2. 2187
      deno.lock
  3. 7
      playwright.config.ts
  4. 82
      src/app.css
  5. 4
      src/app.d.ts
  6. 10
      src/app.html
  7. 45
      src/lib/components/CommentViewer.svelte
  8. 100
      src/lib/components/ContentWithEmbeddedEvents.svelte
  9. 83
      src/lib/components/EmbeddedEventRenderer.svelte
  10. 427
      src/lib/components/EventDetails.svelte
  11. 20
      src/lib/components/EventInput.svelte
  12. 13
      src/lib/components/EventSearch.svelte
  13. 5
      src/lib/components/LoginModal.svelte
  14. 76
      src/lib/components/Notifications.svelte
  15. 22
      src/lib/components/RelayActions.svelte
  16. 9
      src/lib/components/RelayDisplay.svelte
  17. 6
      src/lib/components/RelayStatus.svelte
  18. 40
      src/lib/components/embedded_events/EmbeddedEvent.svelte
  19. 317
      src/lib/components/embedded_events/EmbeddedSnippets.svelte
  20. 7
      src/lib/components/publications/PublicationFeed.svelte
  21. 1
      src/lib/components/publications/PublicationSection.svelte
  22. 10
      src/lib/components/publications/table_of_contents.svelte.ts
  23. 7
      src/lib/components/util/Interactions.svelte
  24. 12
      src/lib/components/util/Profile.svelte
  25. 7
      src/lib/consts.ts
  26. 85
      src/lib/data_structures/docs/relay_selector_design.md
  27. 228
      src/lib/data_structures/publication_tree.ts
  28. 49
      src/lib/data_structures/websocket_pool.ts
  29. 51
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  30. 35
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  31. 99
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  32. 51
      src/lib/navigator/EventNetwork/utils/starForceSimulation.ts
  33. 88
      src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts
  34. 17
      src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
  35. 253
      src/lib/ndk.ts
  36. 4
      src/lib/parser.ts
  37. 10
      src/lib/services/event_search_service.ts
  38. 65
      src/lib/services/publisher.ts
  39. 20
      src/lib/services/search_state_manager.ts
  40. 2
      src/lib/state.ts
  41. 11
      src/lib/stores/authStore.Svelte.ts
  42. 22
      src/lib/stores/networkStore.ts
  43. 575
      src/lib/stores/userStore.ts
  44. 27
      src/lib/stores/visualizationConfig.ts
  45. 14
      src/lib/utils.ts
  46. 187
      src/lib/utils/asciidoc_metadata.ts
  47. 33
      src/lib/utils/displayLimits.ts
  48. 97
      src/lib/utils/eventColors.ts
  49. 94
      src/lib/utils/eventDeduplication.ts
  50. 53
      src/lib/utils/event_input_utils.ts
  51. 55
      src/lib/utils/event_kind_utils.ts
  52. 74
      src/lib/utils/event_search.ts
  53. 4
      src/lib/utils/image_utils.ts
  54. 78
      src/lib/utils/kind24_utils.ts
  55. 58
      src/lib/utils/markup/MarkupInfo.md
  56. 3
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  57. 44
      src/lib/utils/markup/advancedMarkupParser.ts
  58. 20
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  59. 42
      src/lib/utils/markup/basicMarkupParser.ts
  60. 47
      src/lib/utils/markup/embeddedMarkupParser.ts
  61. 97
      src/lib/utils/markup/markupServices.ts
  62. 4
      src/lib/utils/markup/tikzRenderer.ts
  63. 86
      src/lib/utils/network_detection.ts
  64. 41
      src/lib/utils/nostrEventService.ts
  65. 122
      src/lib/utils/nostrUtils.ts
  66. 24
      src/lib/utils/nostr_identifiers.ts
  67. 306
      src/lib/utils/notification_utils.ts
  68. 20
      src/lib/utils/npubCache.ts
  69. 43
      src/lib/utils/profileCache.ts
  70. 788
      src/lib/utils/profile_search.ts
  71. 7
      src/lib/utils/relayDiagnostics.ts
  72. 64
      src/lib/utils/relay_info_service.ts
  73. 334
      src/lib/utils/relay_management.ts
  74. 8
      src/lib/utils/search_result_formatter.ts
  75. 14
      src/lib/utils/search_utility.ts
  76. 744
      src/lib/utils/subscription_search.ts
  77. 72
      src/lib/utils/tag_event_fetch.ts
  78. 49
      src/lib/utils/websocket_utils.ts
  79. 29
      src/routes/+layout.svelte
  80. 139
      src/routes/+layout.ts
  81. 7
      src/routes/contact/+page.svelte
  82. 232
      src/routes/events/+page.svelte
  83. 6
      src/routes/my-notes/+page.svelte
  84. 10
      src/routes/new/edit/+page.svelte
  85. 5
      src/routes/proxy+layout.ts
  86. 2
      src/routes/publication/+page.server.ts
  87. 24
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  88. 53
      src/routes/publication/[type]/[identifier]/+page.ts
  89. 23
      src/routes/visualize/+page.svelte
  90. 6
      src/routes/visualize/+page.ts
  91. 5
      src/styles/events.css
  92. 8
      src/styles/notifications.css
  93. 18
      src/styles/publications.css
  94. 6
      src/styles/scrollbar.css
  95. 28
      src/styles/visualize.css
  96. 85
      test_data/LaTeXtestfile.md
  97. 24
      tests/e2e/my_notes_layout.pw.spec.ts
  98. 195
      tests/unit/ZettelEditor.test.ts
  99. 336
      tests/unit/eventInput30040.test.ts
  100. 2
      tests/unit/latexRendering.test.ts
  101. Some files were not shown because too many files have changed in this diff Show More

40
README.md

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

2187
deno.lock

File diff suppressed because it is too large Load Diff

7
playwright.config.ts

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

82
src/app.css

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

4
src/app.d.ts vendored

@ -23,7 +23,9 @@ declare global {
var MathJax: any; var MathJax: any;
var nostr: NDKNip07Signer & { var nostr: NDKNip07Signer & {
getRelays: () => Promise<Record<string, Record<string, boolean | undefined>>>; getRelays: () => Promise<
Record<string, Record<string, boolean | undefined>>
>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
signEvent: (event: any) => Promise<any>; signEvent: (event: any) => Promise<any>;
}; };

10
src/app.html

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

45
src/lib/components/CommentViewer.svelte

@ -2,16 +2,16 @@
import { Button, P, Heading } from "flowbite-svelte"; import { Button, P, Heading } from "flowbite-svelte";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { neventEncode } from "$lib/utils"; import { neventEncode } from "$lib/utils";
import { activeInboxRelays, ndkInstance } from "$lib/ndk"; import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { parseRepostContent, parseContent as parseNotificationContent } from "$lib/utils/notification_utils";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
// AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation // AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation
// This component fetches and displays threaded comments with proper hierarchy // This component fetches and displays threaded comments with proper hierarchy
// Uses simple, reliable profile fetching and efficient state management // Uses simple, reliable profile fetching and efficient state management
@ -126,15 +126,15 @@
// Get all available relays for a more comprehensive search // Get all available relays for a more comprehensive search
// Use the full NDK pool relays instead of just active relays // Use the full NDK pool relays instead of just active relays
const ndkPoolRelays = Array.from($ndkInstance.pool.relays.values()).map(relay => relay.url); const ndkPoolRelays = Array.from(ndk.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays); console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Try all filters to find comments with full relay set // Try all filters to find comments with full relay set
activeSub = $ndkInstance.subscribe(filters); activeSub = ndk.subscribe(filters);
// Also try a direct search for the specific comment we're looking for // Also try a direct search for the specific comment we're looking for
console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`); console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`);
const specificCommentSub = $ndkInstance.subscribe({ const specificCommentSub = ndk.subscribe({
ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"] ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"]
}); });
@ -293,7 +293,7 @@
try { try {
// Try a broader search to see if there are any events that might be comments // Try a broader search to see if there are any events that might be comments
const testSub = $ndkInstance.subscribe({ const testSub = ndk.subscribe({
kinds: [1, 1111, 9802], kinds: [1, 1111, 9802],
"#e": [event.id], "#e": [event.id],
limit: 10, limit: 10,
@ -464,7 +464,7 @@
console.log(`[CommentViewer] Fetching nested replies for event:`, eventId); console.log(`[CommentViewer] Fetching nested replies for event:`, eventId);
// Search for replies to this specific event // Search for replies to this specific event
const nestedSub = $ndkInstance.subscribe({ const nestedSub = ndk.subscribe({
kinds: [1, 1111, 9802], kinds: [1, 1111, 9802],
"#e": [eventId], "#e": [eventId],
limit: 50, limit: 50,
@ -508,7 +508,7 @@
if (dTag) { if (dTag) {
const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`; const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
const nip22Sub = $ndkInstance.subscribe({ const nip22Sub = ndk.subscribe({
kinds: [1111, 9802], kinds: [1111, 9802],
"#a": [eventAddress], "#a": [eventAddress],
limit: 50, limit: 50,
@ -654,19 +654,6 @@
return `${actualLevel * 16}px`; return `${actualLevel * 16}px`;
} }
async function parseContent(content: string, eventKind?: number): Promise<string> {
if (!content) return "";
// Use parseRepostContent for kind 6 and 16 events (reposts)
if (eventKind === 6 || eventKind === 16) {
return await parseRepostContent(content);
} else {
return await parseNotificationContent(content);
}
}
// AI-NOTE: 2025-01-24 - Get highlight source information // AI-NOTE: 2025-01-24 - Get highlight source information
function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null { function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null {
// Check for e-tags (nostr events) // Check for e-tags (nostr events)
@ -785,11 +772,7 @@
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2"> <div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Comment:</span> <span class="font-medium">Comment:</span>
</div> </div>
{#await parseContent(node.event.getMatchingTags("comment")[0]?.[1] || "") then parsedContent} <EmbeddedEvent nostrIdentifier={node.event.getMatchingTags("comment")[0]?.[1]} nestingLevel={0} />
{@html parsedContent}
{:catch}
{@html node.event.getMatchingTags("comment")[0]?.[1] || ""}
{/await}
</div> </div>
{:else} {:else}
<!-- Simple highlight --> <!-- Simple highlight -->
@ -829,11 +812,7 @@
</div> </div>
{:else} {:else}
<!-- Regular comment content --> <!-- Regular comment content -->
{#await parseContent(node.event.content || "", node.event.kind) then parsedContent} <EmbeddedEvent nostrIdentifier={node.event.id} nestingLevel={0} />
{@html parsedContent}
{:catch}
{@html node.event.content || ""}
{/await}
{/if} {/if}
</div> </div>
</div> </div>

100
src/lib/components/ContentWithEmbeddedEvents.svelte

@ -1,100 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import EmbeddedEvent from "./EmbeddedEvent.svelte";
const {
content,
nestingLevel = 0,
} = $props<{
content: string;
nestingLevel?: number;
}>();
let parsedContent = $state("");
let embeddedEvents = $state<Array<{
id: string;
nostrId: string;
nestingLevel: number;
}>>([]);
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// AI-NOTE: 2025-01-24 - Component for rendering content with embedded Nostr events
// Processes content and replaces nostr: links with EmbeddedEvent components
$effect(() => {
if (content) {
processContent();
}
});
async function processContent() {
try {
// First parse the basic markup
parsedContent = await parseBasicmarkup(content);
// Then find and extract embedded events
extractEmbeddedEvents();
} catch (error) {
console.error("Error processing content:", error);
parsedContent = content; // Fallback to raw content
}
}
function extractEmbeddedEvents() {
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
const events: Array<{
id: string;
nostrId: string;
nestingLevel: number;
}> = [];
let match;
while ((match = nostrPattern.exec(parsedContent)) !== null) {
const nostrId = match[0];
const componentId = `embedded-event-${Math.random().toString(36).substr(2, 9)}`;
events.push({
id: componentId,
nostrId,
nestingLevel: nestingLevel,
});
// Replace the nostr: link with a placeholder
parsedContent = parsedContent.replace(
nostrId,
`<div class="embedded-event-placeholder" data-component-id="${componentId}"></div>`
);
}
embeddedEvents = events;
}
function renderEmbeddedEvent(eventInfo: { id: string; nostrId: string; nestingLevel: number }) {
if (eventInfo.nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
return `<a href="/events?id=${eventInfo.nostrId}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${eventInfo.nostrId}</a>`;
}
// Return a placeholder that will be replaced by the component
return `<div class="embedded-event-placeholder" data-component-id="${eventInfo.id}"></div>`;
}
</script>
<div class="content-with-embedded-events min-w-0 overflow-hidden">
{@html parsedContent}
<!-- Render embedded events -->
{#each embeddedEvents as eventInfo}
<div class="my-4 min-w-0 overflow-hidden" data-component-id={eventInfo.id}>
<EmbeddedEvent
nostrIdentifier={eventInfo.nostrId}
nestingLevel={eventInfo.nestingLevel}
/>
</div>
{/each}
</div>

83
src/lib/components/EmbeddedEventRenderer.svelte

@ -1,83 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import EmbeddedEvent from "./EmbeddedEvent.svelte";
const {
content,
nestingLevel = 0,
} = $props<{
content: string;
nestingLevel?: number;
}>();
let embeddedEvents = $state<Array<{
id: string;
nostrId: string;
nestingLevel: number;
}>>([]);
// AI-NOTE: 2025-01-24 - Component that renders content and replaces embedded event placeholders
// with actual EmbeddedEvent components
$effect(() => {
if (content) {
extractEmbeddedEvents();
}
});
function extractEmbeddedEvents() {
const placeholderPattern = /<div class="embedded-event-placeholder" data-nostr-id="([^"]+)" data-nesting-level="(\d+)" id="([^"]+)"><\/div>/g;
const events: Array<{
id: string;
nostrId: string;
nestingLevel: number;
}> = [];
let match;
while ((match = placeholderPattern.exec(content)) !== null) {
const nostrId = match[1];
const level = parseInt(match[2], 10);
const componentId = match[3];
// Only process event-related identifiers (note, nevent, naddr)
if (nostrId.match(/^nostr:(note|nevent|naddr)/)) {
events.push({
id: componentId,
nostrId,
nestingLevel: level,
});
}
}
embeddedEvents = events;
}
function renderContent() {
let renderedContent = content;
// Replace placeholders with component references
embeddedEvents.forEach(eventInfo => {
const placeholder = `<div class="embedded-event-placeholder" data-nostr-id="${eventInfo.nostrId}" data-nesting-level="${eventInfo.nestingLevel}" id="${eventInfo.id}"></div>`;
const componentRef = `<div class="embedded-event-component" data-component-id="${eventInfo.id}"></div>`;
renderedContent = renderedContent.replace(placeholder, componentRef);
});
return renderedContent;
}
</script>
<div class="embedded-event-renderer">
{@html renderContent()}
<!-- Render embedded events -->
{#each embeddedEvents as eventInfo}
<div class="my-4" data-component-id={eventInfo.id}>
<EmbeddedEvent
nostrIdentifier={eventInfo.nostrId}
nestingLevel={eventInfo.nestingLevel}
/>
</div>
{/each}
</div>

427
src/lib/components/EventDetails.svelte

@ -1,11 +1,8 @@
<script lang="ts"> <script lang="ts">
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { getMimeTags } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { nip19 } from "nostr-tools";
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
@ -17,15 +14,11 @@
import { navigateToEvent } from "$lib/utils/nostrEventService"; import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte"; import Notifications from "$lib/components/Notifications.svelte";
import { parseRepostContent } from "$lib/utils/notification_utils"; import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
import { checkCommunity } from "$lib/utils/search_utility";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
const { const {
event, event,
profile = null, profile = null,
searchValue = null,
communityStatusMap = {},
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile?: { profile?: {
@ -38,67 +31,12 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null; } | null;
searchValue?: string | null;
communityStatusMap?: Record<string, boolean>;
}>(); }>();
let showFullContent = $state(false);
let parsedContent = $state("");
let contentProcessing = $state(false);
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let communityStatus = $state<boolean | null>(null); let showFullContent = $state(false);
let isInUserLists = $state<boolean | null>(null); let shouldTruncate = $derived(event.content.length > 250 && !showFullContent);
// Determine if content should be truncated
let shouldTruncate = $state(false);
$effect(() => {
shouldTruncate = event.content.length > 250 && !showFullContent;
});
// Check community status and user list status for the event author
$effect(() => {
if (event?.pubkey) {
// First check if we have cached profileData with user list information
const cachedProfileData = (event as any).profileData;
console.log(`[EventDetails] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData);
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = cachedProfileData.isInUserLists;
console.log(`[EventDetails] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
} else {
console.log(`[EventDetails] No cached user list data, fetching for ${event.pubkey}`);
// Fallback to fetching user lists
fetchCurrentUserLists()
.then((userLists) => {
console.log(`[EventDetails] Fetched ${userLists.length} user lists for ${event.pubkey}`);
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists);
console.log(`[EventDetails] Final user list status for ${event.pubkey}: ${isInUserLists}`);
})
.catch((error) => {
console.error(`[EventDetails] Error fetching user lists for ${event.pubkey}:`, error);
isInUserLists = false;
});
}
// Check community status - use cached data if available
if (communityStatusMap[event.pubkey] !== undefined) {
communityStatus = communityStatusMap[event.pubkey];
console.log(`[EventDetails] Using cached community status for ${event.pubkey}: ${communityStatus}`);
} else {
// Fallback to checking community status
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
}
});
// AI-NOTE: Event metadata extraction functions
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1]; const titleTag = getMatchingTags(event, "title")[0]?.[1];
@ -139,188 +77,125 @@
return getMatchingTags(event, "summary")[0]?.[1] || ""; return getMatchingTags(event, "summary")[0]?.[1] || "";
} }
function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, "t").map((tag: string[]) => tag[1]);
}
function getEventTypeDisplay(event: NDKEvent): string { function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0); const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
} }
// AI-NOTE: Tag processing utilities function getTagButtonInfo(tag: string[]): {
function isValidHexString(str: string): boolean { text: string;
return /^[0-9a-fA-F]{64}$/.test(str); gotoValue?: string;
} } {
if (tag[0] === "a" && tag.length > 1) {
function createMockEvent(id: string, kind: number = 1): any { const parts = tag[1].split(":");
return { if (parts.length >= 3) {
id, const [kind, pubkey, d] = parts;
kind, // Validate that pubkey is a valid hex string
content: "", if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
tags: [], try {
pubkey: "", const mockEvent = {
sig: "", kind: +kind,
};
}
function createMockAddressableEvent(kind: number, pubkey: string, d: string): any {
return {
kind,
pubkey, pubkey,
tags: [["d", d]], tags: [["d", d]],
content: "", content: "",
id: "", id: "",
sig: "", sig: "",
}; } as any;
}
function renderTag(tag: string[]): string {
const [tagType, tagValue] = tag;
if (!tagValue) {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tagType}:${tagValue}</span>`;
}
try {
switch (tagType) {
case "a": {
const parts = tagValue.split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
if (pubkey && isValidHexString(pubkey)) {
const mockEvent = createMockAddressableEvent(+kind, pubkey, d);
const naddr = naddrEncode(mockEvent, $activeInboxRelays); const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tagValue}</a>`; 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]}` };
} }
break; } else {
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
} }
case "e": } else if (tag[0] === "e" && tag.length > 1) {
case "note": { // Validate that event ID is a valid hex string
if (isValidHexString(tagValue)) { if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
const mockEvent = createMockEvent(tagValue); try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays); const nevent = neventEncode(mockEvent, $activeInboxRelays);
const prefix = tagType === "note" ? "note:" : "e:"; return {
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>${prefix}${tagValue}</a>`; text: `e:${tag[1]}`,
} gotoValue: nevent,
break; };
}
case "d": {
return `<a href='/events?d=${encodeURIComponent(tagValue)}' class='underline text-primary-700'>d:${tagValue}</a>`;
}
}
} catch (error) { } catch (error) {
console.warn(`Failed to encode ${tagType} tag:`, tagValue, error); console.warn("Failed to encode nevent for e tag:", tag[1], error);
} return { text: `e:${tag[1]}` };
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tagType}:${tagValue}</span>`;
} }
} else {
function getTagButtonInfo(tag: string[]): { console.warn("Invalid event ID in e tag:", tag[1]);
text: string; return { text: `e:${tag[1]}` };
gotoValue?: string;
} {
const [tagType, tagValue] = tag;
if (!tagValue) {
return { text: `${tagType}:${tagValue}` };
} }
} 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 { try {
switch (tagType) { const mockEvent = {
case "a": { id: tag[1],
const parts = tagValue.split(":"); kind: 1,
if (parts.length >= 3) { content: "",
const [kind, pubkey, d] = parts; tags: [],
if (pubkey && isValidHexString(pubkey)) { pubkey: "",
const mockEvent = createMockAddressableEvent(+kind, pubkey, d); sig: "",
const naddr = naddrEncode(mockEvent, $activeInboxRelays); } as any;
return { text: `a:${tagValue}`, gotoValue: naddr };
}
}
break;
}
case "e":
case "note": {
if (isValidHexString(tagValue)) {
const mockEvent = createMockEvent(tagValue);
const nevent = neventEncode(mockEvent, $activeInboxRelays); const nevent = neventEncode(mockEvent, $activeInboxRelays);
const prefix = tagType === "note" ? "note:" : "e:";
return { text: `${prefix}${tagValue}`, gotoValue: nevent };
}
break;
}
case "p": {
const npub = toNpub(tagValue);
return { return {
text: `p:${npub || tagValue}`, text: `note:${tag[1]}`,
gotoValue: npub || undefined, gotoValue: nevent,
}; };
}
case "d": {
return { text: `d:${tagValue}`, gotoValue: `d:${tagValue}` };
}
case "t": {
return { text: `t:${tagValue}`, gotoValue: `t:${tagValue}` };
}
}
} catch (error) { } catch (error) {
console.warn(`Failed to encode ${tagType} tag:`, tagValue, error); console.warn("Failed to encode nevent for note tag:", tag[1], error);
} return { text: `note:${tag[1]}` };
return { text: `${tagType}:${tagValue}` };
} }
// AI-NOTE: URL generation functions
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function getNprofileUrl(pubkey: string): string {
return nprofileEncode(pubkey, $activeInboxRelays);
}
// AI-NOTE: Content processing effect
$effect(() => {
if (event && event.kind !== 0 && event.content) {
contentProcessing = true;
// Use parseRepostContent for kind 6 and 16 events (reposts)
if (event.kind === 6 || event.kind === 16) {
parseRepostContent(event.content).then((html) => {
parsedContent = html;
contentProcessing = false;
}).catch((error) => {
console.error('Error parsing repost content:', error);
contentProcessing = false;
});
} else { } else {
// Use embedded markup parser for better Nostr event support console.warn("Invalid event ID in note tag:", tag[1]);
parseEmbeddedMarkup(event.content, 0).then((html) => { return { text: `note:${tag[1]}` };
parsedContent = html;
contentProcessing = false;
}).catch((error) => {
console.error('Error parsing embedded markup:', error);
contentProcessing = false;
});
} }
} else { } else if (tag[0] === "d" && tag.length > 1) {
contentProcessing = false; // 'd' tags are used for identifiers in addressable events
parsedContent = ""; 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]}` };
} }
});
// AI-NOTE: Author metadata effect
$effect(() => { $effect(() => {
if (!event?.pubkey) { if (!event?.pubkey) {
authorDisplayName = undefined; authorDisplayName = undefined;
return; return;
} }
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => { getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName = authorDisplayName =
profile.displayName || profile.displayName ||
@ -330,90 +205,51 @@
}); });
}); });
// AI-NOTE: Identifier helpers // --- Identifier helpers ---
function getIdentifiers( function getIdentifiers(
event: NDKEvent, event: NDKEvent,
profile: any, profile: any,
): { label: string; value: string; link?: string }[] { ): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = []; const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) { if (event.kind === 0) {
// Profile event identifiers // NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub
const npub = toNpub(event.pubkey); const npub = toNpub(event.pubkey);
if (npub) { if (npub)
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` }); ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` });
} // nprofile
// Decode npub to get raw hex string for nprofile encoding
let rawPubkey = event.pubkey;
if (event.pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(event.pubkey);
if (decoded.type === 'npub') {
rawPubkey = decoded.data;
}
} catch (error) {
console.warn('Failed to decode npub for nprofile encoding:', error);
}
}
ids.push({ ids.push({
label: "nprofile", label: "nprofile",
value: nprofileEncode(rawPubkey, $activeInboxRelays), value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(rawPubkey, $activeInboxRelays)}`, link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
}); });
// nevent
// For nevent encoding, we need to ensure the event has proper hex strings
try {
const nevent = neventEncode(event, $activeInboxRelays);
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: nevent, value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${nevent}`, link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
}); });
} catch (error) { // hex pubkey
console.warn('Failed to encode nevent for profile event:', error);
// Fallback: just show the event ID
ids.push({ label: "event id", value: event.id });
}
ids.push({ label: "pubkey", value: event.pubkey }); ids.push({ label: "pubkey", value: event.pubkey });
} else { } else {
// Non-profile event identifiers // nevent
// For nevent encoding, we need to ensure the event has proper hex strings
try {
const nevent = neventEncode(event, $activeInboxRelays);
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: nevent, value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${nevent}`, link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
}); });
} catch (error) {
console.warn('Failed to encode nevent for non-profile event:', error);
// Fallback: just show the event ID
ids.push({ label: "event id", value: event.id });
}
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, $activeInboxRelays); const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id
ids.push({ label: "id", value: event.id }); ids.push({ label: "id", value: event.id });
} }
return ids; return ids;
} }
function isCurrentSearch(value: string): boolean {
if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue);
}
// AI-NOTE: Navigation handler for internal links
onMount(() => { onMount(() => {
function handleInternalLinkClick(event: MouseEvent) { function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -442,58 +278,20 @@
<Notifications {event} /> <Notifications {event} />
{/if} {/if}
{#if !(event.kind === 0)}
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0 flex items-center gap-2" <span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.display_name || undefined, profile?.display_name || undefined,
)} )}</span
{#if isInUserLists === true}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if isInUserLists === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
{#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}
</span>
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" <span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span >Author: {profile?.display_name || event.pubkey}</span
> >
{/if} {/if}
</div> </div>
{/if}
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span> <span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
@ -519,11 +317,8 @@
<div class="flex flex-col space-y-1 min-w-0"> <div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span> <span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> <div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{#if contentProcessing}
<div class="text-gray-500 dark:text-gray-400 italic">Processing content...</div>
{:else}
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}> <div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} /> <EmbeddedEvent nostrIdentifier={event.id} nestingLevel={0} />
</div> </div>
{#if shouldTruncate} {#if shouldTruncate}
<button <button
@ -531,18 +326,16 @@
onclick={() => (showFullContent = true)}>Show more</button onclick={() => (showFullContent = true)}>Show more</button
> >
{/if} {/if}
{/if}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Show ProfileHeader for all events except profile events (kind 0) when in search context to avoid redundancy --> <!-- If event is profile -->
{#if (event.kind === 0)} {#if event.kind === 0}
<ProfileHeader <ProfileHeader
{event} {event}
{profile} {profile}
{communityStatusMap}
/> />
{/if} {/if}

20
src/lib/components/EventInput.svelte

@ -13,23 +13,24 @@
get30040FixGuidance, get30040FixGuidance,
} from "$lib/utils/event_input_utils"; } from "$lib/utils/event_input_utils";
import { import {
extractDocumentMetadata,
extractSmartMetadata, extractSmartMetadata,
metadataToTags, metadataToTags,
removeMetadataFromContent removeMetadataFromContent
} from "$lib/utils/asciidoc_metadata"; } from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { anonymousRelays } from "$lib/consts"; import { anonymousRelays } from "$lib/consts";
const ndk = getNdkContext();
let kind = $state<number>(30040); let kind = $state<number>(30040);
let tags = $state<[string, string][]>([]); let tags = $state<[string, string][]>([]);
let content = $state(""); let content = $state("");
@ -162,8 +163,11 @@
} }
function validate(): { valid: boolean; reason?: string; warning?: string } { function validate(): { valid: boolean; reason?: string; warning?: string } {
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey;
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!pubkey) return { valid: false, reason: "Not logged in." }; if (!pubkey) return { valid: false, reason: "Not logged in." };
if (!content.trim()) return { valid: false, reason: "Content required." }; if (!content.trim()) return { valid: false, reason: "Content required." };
@ -217,9 +221,11 @@
createdAt = Math.floor(Date.now() / 1000); createdAt = Math.floor(Date.now() / 1000);
try { try {
const ndk = get(ndkInstance); const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey;
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!ndk || !pubkey) { if (!ndk || !pubkey) {
error = "NDK or pubkey missing."; error = "NDK or pubkey missing.";
loading = false; loading = false;

13
src/lib/components/EventSearch.svelte

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Input, Button } from "flowbite-svelte"; import { Input, Button } from "flowbite-svelte";
import { Spinner } from "flowbite-svelte"; import { Spinner } from "flowbite-svelte";
@ -10,12 +9,10 @@
searchNip05, searchNip05,
} from "$lib/utils/search_utility"; } from "$lib/utils/search_utility";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, ndkInstance } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { searchRelays } from "$lib/consts";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import type { SearchResult } from '$lib/utils/search_types'; import type NDK from '@nostr-dev-kit/ndk';
import { userStore } from "$lib/stores/userStore";
import { get } from "svelte/store";
// Props definition // Props definition
let { let {
loading, loading,
@ -48,6 +45,8 @@
onLoadingChange?: (loading: boolean) => void; onLoadingChange?: (loading: boolean) => void;
} = $props(); } = $props();
const ndk = getNdkContext();
// Component state // Component state
let searchQuery = $state(""); let searchQuery = $state("");
let localError = $state<string | null>(null); let localError = $state<string | null>(null);
@ -462,7 +461,6 @@
// This ensures searches can proceed even if some relay types are not available // This ensures searches can proceed even if some relay types are not available
while (retryCount < maxRetries) { while (retryCount < maxRetries) {
// Check if we have any relays in the NDK pool // Check if we have any relays in the NDK pool
const ndk = get(ndkInstance);
if (ndk && ndk.pool && ndk.pool.relays && ndk.pool.relays.size > 0) { if (ndk && ndk.pool && ndk.pool.relays && ndk.pool.relays.size > 0) {
console.debug(`EventSearch: Found ${ndk.pool.relays.size} relays in NDK pool`); console.debug(`EventSearch: Found ${ndk.pool.relays.size} relays in NDK pool`);
break; break;
@ -481,7 +479,6 @@
// AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let the search functions handle fallbacks // AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let the search functions handle fallbacks
// The search functions will use all available relays including fallback relays // The search functions will use all available relays including fallback relays
const ndk = get(ndkInstance);
const poolRelayCount = ndk?.pool?.relays?.size || 0; const poolRelayCount = ndk?.pool?.relays?.size || 0;
console.log("EventSearch: Relay status for search:", { console.log("EventSearch: Relay status for search:", {

5
src/lib/components/LoginModal.svelte

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

76
src/lib/components/Notifications.svelte

@ -1,39 +1,33 @@
<script lang="ts"> <script lang="ts">
import "../../styles/notifications.css"; import "../../styles/notifications.css";
import { onMount } from "svelte";
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { ndkInstance, activeInboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { communityRelays, localRelays, anonymousRelays, searchRelays } from "$lib/consts"; import { anonymousRelays } from "$lib/consts";
import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; import { getKind24RelaySet } from "$lib/utils/kind24_utils";
import { createSignedEvent } from "$lib/utils/nostrEventService"; import { createSignedEvent } from "$lib/utils/nostrEventService";
import RelayDisplay from "$lib/components/RelayDisplay.svelte";
import RelayInfoList from "$lib/components/RelayInfoList.svelte";
import { Modal, Button } from "flowbite-svelte"; import { Modal, Button } from "flowbite-svelte";
import { searchProfiles } from "$lib/utils/search_utility"; import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types"; import type { NostrProfile } from "$lib/utils/search_types";
import { PlusOutline, ReplyOutline, UserOutline } from "flowbite-svelte-icons"; import { PlusOutline, ReplyOutline, UserOutline } from "flowbite-svelte-icons";
import { import {
truncateContent,
truncateRenderedContent,
parseContent,
parseRepostContent,
renderQuotedContent,
getNotificationType, getNotificationType,
fetchAuthorProfiles fetchAuthorProfiles,
} from "$lib/utils/notification_utils"; quotedContent,
} from "$lib/components/embedded_events/EmbeddedSnippets.svelte";
import { buildCompleteRelaySet } from "$lib/utils/relay_management"; import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { formatDate, neventEncode } from "$lib/utils"; import { formatDate, neventEncode } from "$lib/utils";
import { toNpub, getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; import { NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte"; import { getNdkContext } from "$lib/ndk";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
// Handle navigation events from quoted messages // Handle navigation events from quoted messages
$effect(() => { $effect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -60,7 +54,6 @@
let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map()); let authorProfiles = $state<Map<string, { name?: string; displayName?: string; picture?: string }>>(new Map());
let filteredByUser = $state<string | null>(null); let filteredByUser = $state<string | null>(null);
// New Message Modal state // New Message Modal state
let showNewMessageModal = $state(false); let showNewMessageModal = $state(false);
let newMessageContent = $state<string>(""); let newMessageContent = $state<string>("");
@ -68,7 +61,6 @@
let newMessageRelays = $state<string[]>([]); let newMessageRelays = $state<string[]>([]);
let isComposingMessage = $state(false); let isComposingMessage = $state(false);
let replyToMessage = $state<NDKEvent | null>(null); let replyToMessage = $state<NDKEvent | null>(null);
let quotedContent = $state<string>("");
// Recipient Selection Modal state // Recipient Selection Modal state
let showRecipientModal = $state(false); let showRecipientModal = $state(false);
@ -165,8 +157,6 @@
filteredByUser = null; filteredByUser = null;
} }
// AI-NOTE: New Message Modal Functions // AI-NOTE: New Message Modal Functions
function openNewMessageModal(messageToReplyTo?: NDKEvent) { function openNewMessageModal(messageToReplyTo?: NDKEvent) {
showNewMessageModal = true; showNewMessageModal = true;
@ -178,11 +168,6 @@
// If replying, set up the quote and pre-select all original recipients plus sender // If replying, set up the quote and pre-select all original recipients plus sender
if (messageToReplyTo) { if (messageToReplyTo) {
// Store clean content for UI display (no markdown formatting)
quotedContent = messageToReplyTo.content.length > 200
? messageToReplyTo.content.slice(0, 200) + "..."
: messageToReplyTo.content;
// Collect all recipients: original sender + all p-tag recipients // Collect all recipients: original sender + all p-tag recipients
const recipientPubkeys = new Set<string>(); const recipientPubkeys = new Set<string>();
@ -217,8 +202,6 @@
}).filter(recipient => recipient.pubkey); // Ensure we have valid pubkeys }).filter(recipient => recipient.pubkey); // Ensure we have valid pubkeys
console.log(`Pre-loaded ${selectedRecipients.length} recipients for reply:`, selectedRecipients.map(r => r.displayName || r.name || r.pubkey?.slice(0, 8))); console.log(`Pre-loaded ${selectedRecipients.length} recipients for reply:`, selectedRecipients.map(r => r.displayName || r.name || r.pubkey?.slice(0, 8)));
} else {
quotedContent = "";
} }
} }
@ -229,7 +212,6 @@
newMessageRelays = []; newMessageRelays = [];
isComposingMessage = false; isComposingMessage = false;
replyToMessage = null; replyToMessage = null;
quotedContent = "";
} }
// AI-NOTE: Recipient Selection Modal Functions // AI-NOTE: Recipient Selection Modal Functions
@ -485,7 +467,6 @@
error = null; error = null;
try { try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available"); if (!ndk) throw new Error("No NDK instance available");
const userStoreValue = get(userStore); const userStoreValue = get(userStore);
@ -522,7 +503,7 @@
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 100); .slice(0, 100);
authorProfiles = await fetchAuthorProfiles(notifications); authorProfiles = await fetchAuthorProfiles(notifications, ndk);
} catch (err) { } catch (err) {
console.error("[Notifications] Error fetching notifications:", err); console.error("[Notifications] Error fetching notifications:", err);
error = err instanceof Error ? err.message : "Failed to fetch notifications"; error = err instanceof Error ? err.message : "Failed to fetch notifications";
@ -539,7 +520,6 @@
error = null; error = null;
try { try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("No NDK instance available"); if (!ndk) throw new Error("No NDK instance available");
const userStoreValue = get(userStore); const userStoreValue = get(userStore);
@ -570,7 +550,7 @@
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, 200); .slice(0, 200);
authorProfiles = await fetchAuthorProfiles(publicMessages); authorProfiles = await fetchAuthorProfiles(publicMessages, ndk);
} catch (err) { } catch (err) {
console.error("[PublicMessages] Error fetching public messages:", err); console.error("[PublicMessages] Error fetching public messages:", err);
error = err instanceof Error ? err.message : "Failed to fetch public messages"; error = err instanceof Error ? err.message : "Failed to fetch public messages";
@ -579,8 +559,6 @@
} }
} }
// Check if user is viewing their own profile // Check if user is viewing their own profile
$effect(() => { $effect(() => {
if ($userStore.signedIn && $userStore.pubkey && event.pubkey) { if ($userStore.signedIn && $userStore.pubkey && event.pubkey) {
@ -606,8 +584,6 @@
} }
}); });
// AI-NOTE: Refactored to avoid blocking $effect with async operations // AI-NOTE: Refactored to avoid blocking $effect with async operations
// Calculate relay set when recipients change - non-blocking approach // Calculate relay set when recipients change - non-blocking approach
$effect(() => { $effect(() => {
@ -661,7 +637,6 @@
// If no relays found from NIP-65, use fallback relays // If no relays found from NIP-65, use fallback relays
if (uniqueRelays.length === 0) { if (uniqueRelays.length === 0) {
console.log("[Relay Effect] No NIP-65 relays found, using fallback"); console.log("[Relay Effect] No NIP-65 relays found, using fallback");
const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
const userStoreValue = get(userStore); const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
@ -677,7 +652,6 @@
} catch (error) { } catch (error) {
console.error("[Relay Effect] Error getting relay set:", error); console.error("[Relay Effect] Error getting relay set:", error);
console.log("[Relay Effect] Using fallback relays due to error"); console.log("[Relay Effect] Using fallback relays due to error");
const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
const userStoreValue = get(userStore); const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
@ -837,21 +811,13 @@
{#if message.getMatchingTags("q").length > 0} {#if message.getMatchingTags("q").length > 0}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
{#await renderQuotedContent(message, publicMessages) then quotedHtml} {@render quotedContent(message, publicMessages, ndk)}
{@html quotedHtml}
{:catch}
<!-- Fallback if quoted content fails to render -->
{/await}
</div> </div>
{/if} {/if}
{#if message.content} {#if message.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2"> <div class="px-2">
{#await ((message.kind === 6 || message.kind === 16) ? parseRepostContent(message.content) : parseContent(message.content)) then parsedContent} <EmbeddedEvent nostrIdentifier={message.id} nestingLevel={0} />
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html message.content}
{/await}
</div> </div>
</div> </div>
{/if} {/if}
@ -928,11 +894,7 @@
{#if notification.content} {#if notification.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2"> <div class="px-2">
{#await ((notification.kind === 6 || notification.kind === 16) ? parseRepostContent(notification.content) : parseContent(notification.content)) then parsedContent} <EmbeddedEvent nostrIdentifier={notification.id} nestingLevel={0} />
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html truncateContent(notification.content)}
{/await}
</div> </div>
</div> </div>
{/if} {/if}
@ -963,15 +925,11 @@
</div> </div>
<!-- Quoted Content Display --> <!-- Quoted Content Display -->
{#if quotedContent} {#if replyToMessage}
<div class="quoted-content mb-4 p-3 rounded-r-lg"> <div class="quoted-content mb-4 p-3 rounded-r-lg">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div> <div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div>
<div class="text-sm text-gray-800 dark:text-gray-200"> <div class="text-sm text-gray-800 dark:text-gray-200">
{#await parseContent(quotedContent) then parsedContent} <EmbeddedEvent nostrIdentifier={replyToMessage.id} nestingLevel={0} />
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html quotedContent}
{/await}
</div> </div>
</div> </div>
{/if} {/if}

22
src/lib/components/RelayActions.svelte

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

9
src/lib/components/RelayDisplay.svelte

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

6
src/lib/components/RelayStatus.svelte

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

40
src/lib/components/EmbeddedEvent.svelte → src/lib/components/embedded_events/EmbeddedEvent.svelte

@ -1,19 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parsedContent } from "$lib/components/embedded_events/EmbeddedSnippets.svelte";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser"; import { naddrEncode } from "$lib/utils";
import { parseRepostContent } from "$lib/utils/notification_utils"; import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getEventType } from "$lib/utils/mime"; import { getEventType } from "$lib/utils/mime";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { get } from "svelte/store"; import { repostKinds } from "$lib/consts";
const { const {
nostrIdentifier, nostrIdentifier,
@ -23,6 +19,8 @@
nestingLevel?: number; nestingLevel?: number;
}>(); }>();
const ndk = getNdkContext();
let event = $state<NDKEvent | null>(null); let event = $state<NDKEvent | null>(null);
let profile = $state<{ let profile = $state<{
name?: string; name?: string;
@ -36,7 +34,6 @@
} | null>(null); } | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let parsedContent = $state("");
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
// Maximum nesting level allowed // Maximum nesting level allowed
@ -62,7 +59,6 @@
error = null; error = null;
try { try {
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
throw new Error("No NDK instance available"); throw new Error("No NDK instance available");
} }
@ -120,16 +116,6 @@
} }
} }
// Parse content if available
if (event?.content) {
if (event.kind === 6 || event.kind === 16) {
parsedContent = await parseRepostContent(event.content);
} else {
// Use embedded markup parser for nested events
parsedContent = await parseEmbeddedMarkup(event.content, nestingLevel + 1);
}
}
// Parse profile if it's a profile event // Parse profile if it's a profile event
if (event?.kind === 0) { if (event?.kind === 0) {
try { try {
@ -196,10 +182,6 @@
} }
} }
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string { function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays); return naddrEncode(event, $activeInboxRelays);
} }
@ -303,17 +285,15 @@
{/if} {/if}
<!-- Content for text events --> <!-- Content for text events -->
{#if event.kind === 1 && parsedContent} {#if event.kind === 1 || repostKinds.includes(event.kind)}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden"> <div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<EmbeddedEventRenderer content={parsedContent.slice(0, 300)} nestingLevel={nestingLevel + 1} /> {@render parsedContent(event.content.slice(0, 300))}
{#if parsedContent.length > 300} {#if event.content.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span> <span class="text-gray-500 dark:text-gray-400">...</span>
{/if} {/if}
</div> </div>
{/if}
<!-- Profile content --> <!-- Profile content -->
{#if event.kind === 0 && profile} {:else if event.kind === 0 && profile}
<div class="space-y-2 min-w-0 overflow-hidden"> <div class="space-y-2 min-w-0 overflow-hidden">
{#if profile.picture} {#if profile.picture}
<img <img

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

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

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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { filterValidIndexEvents, debounceAsync } from "$lib/utils"; import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte"; import ArticleHeader from "./PublicationHeader.svelte";
@ -10,7 +10,7 @@
toNpub, toNpub,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache"; import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache"; import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility"; import { isValidNip05Address } from "$lib/utils/search_utility";
@ -23,6 +23,8 @@
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void; onEventCountUpdate?: (counts: { displayed: number; total: number }) => void;
}>(); }>();
const ndk = getNdkContext();
// Component state // Component state
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
@ -35,7 +37,6 @@
// Relay management // Relay management
let allRelays: string[] = $state([]); let allRelays: string[] = $state([]);
let ndk = $derived($ndkInstance);
// Event management // Event management
let allIndexEvents: NDKEvent[] = $state([]); let allIndexEvents: NDKEvent[] = $state([]);

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

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { import {
contentParagraph, contentParagraph,
sectionHeading, sectionHeading,

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

@ -159,7 +159,7 @@ export class TableOfContents {
// Handle any other nodes that have already been resolved in parallel. // Handle any other nodes that have already been resolved in parallel.
await Promise.all( await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) => Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address), this.#buildTocEntryFromResolvedNode(address)
), ),
); );
@ -274,10 +274,10 @@ export class TableOfContents {
}); });
entry.children.sort((a, b) => { entry.children.sort((a, b) => {
const aOrdinal = const aOrdinal = addressToOrdinal.get(a.address) ??
addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
const bOrdinal = const bOrdinal = addressToOrdinal.get(b.address) ??
addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal; return aOrdinal - bOrdinal;
}); });
} }

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

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

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

@ -8,22 +8,21 @@
loginWithAmber, loginWithAmber,
loginWithNpub loginWithNpub
} from "$lib/stores/userStore"; } from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import { import {
ArrowRightToBracketOutline, ArrowRightToBracketOutline,
UserOutline, UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata } from "$lib/utils/nostrUtils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
let { pubkey, isNav = false } = $props<{ pubkey?: string, isNav?: boolean }>(); const ndk = getNdkContext();
let { isNav = false } = $props<{ isNav?: boolean }>();
// UI state for login functionality // UI state for login functionality
let isLoadingExtension: boolean = $state(false); let isLoadingExtension: boolean = $state(false);
@ -205,7 +204,6 @@
} }
// Try using NDK's built-in profile fetching first // Try using NDK's built-in profile fetching first
const ndk = get(ndkInstance);
if (ndk && userState.ndkUser) { if (ndk && userState.ndkUser) {
console.log("Using NDK's built-in profile fetching"); console.log("Using NDK's built-in profile fetching");
const userProfile = await userState.ndkUser.fetchProfile(); const userProfile = await userState.ndkUser.fetchProfile();
@ -298,7 +296,7 @@
isLoadingExtension = true; isLoadingExtension = true;
isLoadingAmber = false; isLoadingAmber = false;
try { try {
await loginWithExtension(); await loginWithExtension(ndk);
} catch (err: unknown) { } catch (err: unknown) {
showResultMessage( showResultMessage(
`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`, `❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`,

7
src/lib/consts.ts

@ -3,6 +3,7 @@
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [30041, 30818, 30023]; export const zettelKinds = [30041, 30818, 30023];
export const repostKinds = [6, 16];
export const communityRelays = [ export const communityRelays = [
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",
@ -16,7 +17,7 @@ export const searchRelays = [
"wss://nostr.wine", "wss://nostr.wine",
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.nostr.band", "wss://relay.nostr.band",
"wss://freelay.sovbit.host" "wss://freelay.sovbit.host",
]; ];
export const secondaryRelays = [ export const secondaryRelays = [
@ -32,7 +33,7 @@ export const secondaryRelays = [
export const anonymousRelays = [ export const anonymousRelays = [
"wss://freelay.sovbit.host", "wss://freelay.sovbit.host",
"wss://thecitadel.nostr1.com" "wss://thecitadel.nostr1.com",
]; ];
export const lowbandwidthRelays = [ export const lowbandwidthRelays = [
@ -44,7 +45,7 @@ export const lowbandwidthRelays = [
export const localRelays: string[] = [ export const localRelays: string[] = [
"ws://localhost:8080", "ws://localhost:8080",
"ws://localhost:4869", "ws://localhost:4869",
"ws://localhost:3334" "ws://localhost:3334",
]; ];
export enum FeedType { export enum FeedType {

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

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

228
src/lib/data_structures/publication_tree.ts

@ -2,7 +2,10 @@ import { Lazy } from "./lazy.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import { fetchEventById } from "../utils/websocket_utils.ts"; import { fetchEventById } from "../utils/websocket_utils.ts";
import { fetchEventWithFallback, NDKRelaySetFromNDK } from "../utils/nostrUtils.ts"; import {
fetchEventWithFallback,
NDKRelaySetFromNDK,
} from "../utils/nostrUtils.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { searchRelays, secondaryRelays } from "../consts.ts"; import { searchRelays, secondaryRelays } from "../consts.ts";
@ -486,7 +489,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
continue; continue;
} }
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -494,7 +500,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -533,7 +542,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
} while (this.#cursor.tryMoveToParent()); } while (this.#cursor.tryMoveToParent());
if (this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error) { if (
this.#cursor.target &&
this.#cursor.target.status === PublicationTreeNodeStatus.Error
) {
return { done: false, value: null }; return { done: false, value: null };
} }
@ -588,46 +600,83 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
.filter((tag) => tag[0] === "a") .filter((tag) => tag[0] === "a")
.map((tag) => tag[1]); .map((tag) => tag[1]);
console.debug(`[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`, currentEvent.tags); console.debug(
console.debug(`[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`, currentChildAddresses); `[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`,
currentEvent.tags,
);
console.debug(
`[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`,
currentChildAddresses,
);
// If no a-tags found, try e-tags as fallback // If no a-tags found, try e-tags as fallback
if (currentChildAddresses.length === 0) { if (currentChildAddresses.length === 0) {
const eTags = currentEvent.tags const eTags = currentEvent.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])); .filter((tag) =>
tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])
);
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`, eTags.map(tag => tag[1])); console.debug(
`[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`,
eTags.map((tag) => tag[1]),
);
// For e-tags with hex IDs, fetch the referenced events to get their addresses // For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => { const eTagPromises = eTags.map(async (tag) => {
try { try {
console.debug(`[PublicationTree] Fetching event for e-tag ${tag[1]} in depthFirstRetrieve`); console.debug(
`[PublicationTree] Fetching event for e-tag ${
tag[1]
} in depthFirstRetrieve`,
);
const referencedEvent = await fetchEventById(tag[1]); const referencedEvent = await fetchEventById(tag[1]);
if (referencedEvent) { if (referencedEvent) {
// Construct the proper address format from the referenced event // Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1]; const dTag = referencedEvent.tags.find((tag) => tag[0] === "d")
?.[1];
if (dTag) { if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; const address =
console.debug(`[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`); `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(
`[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`,
);
return address; return address;
} else { } else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag in depthFirstRetrieve`); console.debug(
`[PublicationTree] Referenced event ${
tag[1]
} has no d-tag in depthFirstRetrieve`,
);
} }
} else { } else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve - event not found`); console.debug(
`[PublicationTree] Failed to fetch event for e-tag ${
tag[1]
} in depthFirstRetrieve - event not found`,
);
} }
return null; return null;
} catch (error) { } catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve:`, error); console.warn(
`[PublicationTree] Failed to fetch event for e-tag ${
tag[1]
} in depthFirstRetrieve:`,
error,
);
return null; return null;
} }
}); });
const resolvedAddresses = await Promise.all(eTagPromises); const resolvedAddresses = await Promise.all(eTagPromises);
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[]; const validAddresses = resolvedAddresses.filter((addr) =>
addr !== null
) as string[];
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`, validAddresses); console.debug(
`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`,
validAddresses,
);
if (validAddresses.length > 0) { if (validAddresses.length > 0) {
currentChildAddresses.push(...validAddresses); currentChildAddresses.push(...validAddresses);
@ -646,8 +695,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Augment the tree with the children of the current event. // Augment the tree with the children of the current event.
const childPromises = currentChildAddresses const childPromises = currentChildAddresses
.filter(childAddress => !this.#nodes.has(childAddress)) .filter((childAddress) => !this.#nodes.has(childAddress))
.map(childAddress => this.#addNode(childAddress, currentNode!)); .map((childAddress) => this.#addNode(childAddress, currentNode!));
await Promise.all(childPromises); await Promise.all(childPromises);
@ -663,7 +712,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
const lazyNode = new Lazy<PublicationTreeNode>(() => const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode), this.#resolveNode(address, parentNode)
); );
parentNode.children!.push(lazyNode); parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode); this.#nodes.set(address, lazyNode);
@ -698,7 +747,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
authors: [pubkey], authors: [pubkey],
"#d": [dTag], "#d": [dTag],
}, 5000) // 5 second timeout for publication events }, 5000) // 5 second timeout for publication events
.then(fetchedEvent => { .then((fetchedEvent) => {
if (fetchedEvent) { if (fetchedEvent) {
// Cache the event if found // Cache the event if found
this.#eventCache.set(address, fetchedEvent); this.#eventCache.set(address, fetchedEvent);
@ -711,20 +760,37 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
); );
// If still not found, try a more aggressive search using search relays // If still not found, try a more aggressive search using search relays
return this.#trySearchRelayFallback(address, kind, pubkey, dTag, parentNode); return this.#trySearchRelayFallback(
address,
kind,
pubkey,
dTag,
parentNode,
);
} }
return this.#buildNodeFromEvent(event, address, parentNode); return this.#buildNodeFromEvent(event, address, parentNode);
}) })
.catch(error => { .catch((error) => {
console.warn(`[PublicationTree] Error fetching event for address ${address}:`, error); console.warn(
`[PublicationTree] Error fetching event for address ${address}:`,
error,
);
// Try search relay fallback even on error // Try search relay fallback even on error
return this.#trySearchRelayFallback(address, kind, pubkey, dTag, parentNode); return this.#trySearchRelayFallback(
address,
kind,
pubkey,
dTag,
parentNode,
);
}); });
} }
return Promise.resolve(this.#buildNodeFromEvent(event, address, parentNode)); return Promise.resolve(
this.#buildNodeFromEvent(event, address, parentNode),
);
} }
/** /**
@ -736,34 +802,50 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
kind: string, kind: string,
pubkey: string, pubkey: string,
dTag: string, dTag: string,
parentNode: PublicationTreeNode parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> { ): Promise<PublicationTreeNode> {
try { try {
console.log(`[PublicationTree] Trying search relay fallback for address: ${address}`); console.log(
`[PublicationTree] Trying search relay fallback for address: ${address}`,
);
// Get current relay configuration // Get current relay configuration
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
// Create a comprehensive relay set including search relays // Create a comprehensive relay set including search relays
const allRelays = [...inboxRelays, ...outboxRelays, ...searchRelays, ...secondaryRelays]; const allRelays = [
...inboxRelays,
...outboxRelays,
...searchRelays,
...secondaryRelays,
];
const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates
console.log(`[PublicationTree] Trying ${uniqueRelays.length} relays for fallback search:`, uniqueRelays); console.log(
`[PublicationTree] Trying ${uniqueRelays.length} relays for fallback search:`,
uniqueRelays,
);
// Try each relay individually with a shorter timeout // Try each relay individually with a shorter timeout
for (const relay of uniqueRelays) { for (const relay of uniqueRelays) {
try { try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], this.#ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], this.#ndk);
const fetchedEvent = await this.#ndk.fetchEvent({ const fetchedEvent = await this.#ndk.fetchEvent(
{
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
"#d": [dTag], "#d": [dTag],
}, undefined, relaySet).withTimeout(3000); // 3 second timeout per relay },
undefined,
relaySet,
).withTimeout(3000); // 3 second timeout per relay
if (fetchedEvent) { if (fetchedEvent) {
console.log(`[PublicationTree] Found event ${fetchedEvent.id} on search relay: ${relay}`); console.log(
`[PublicationTree] Found event ${fetchedEvent.id} on search relay: ${relay}`,
);
// Cache the event // Cache the event
this.#eventCache.set(address, fetchedEvent); this.#eventCache.set(address, fetchedEvent);
@ -772,13 +854,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return this.#buildNodeFromEvent(fetchedEvent, address, parentNode); return this.#buildNodeFromEvent(fetchedEvent, address, parentNode);
} }
} catch (error) { } catch (error) {
console.debug(`[PublicationTree] Failed to fetch from relay ${relay}:`, error); console.debug(
`[PublicationTree] Failed to fetch from relay ${relay}:`,
error,
);
continue; // Try next relay continue; // Try next relay
} }
} }
// If we get here, the event was not found on any relay // If we get here, the event was not found on any relay
console.warn(`[PublicationTree] Event with address ${address} not found on any relay after fallback search.`); console.warn(
`[PublicationTree] Event with address ${address} not found on any relay after fallback search.`,
);
return { return {
type: PublicationTreeNodeType.Leaf, type: PublicationTreeNodeType.Leaf,
@ -787,9 +874,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
parent: parentNode, parent: parentNode,
children: [], children: [],
}; };
} catch (error) { } catch (error) {
console.error(`[PublicationTree] Error in search relay fallback for ${address}:`, error); console.error(
`[PublicationTree] Error in search relay fallback for ${address}:`,
error,
);
return { return {
type: PublicationTreeNodeType.Leaf, type: PublicationTreeNodeType.Leaf,
@ -808,7 +897,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#buildNodeFromEvent( #buildNodeFromEvent(
event: NDKEvent, event: NDKEvent,
address: string, address: string,
parentNode: PublicationTreeNode parentNode: PublicationTreeNode,
): PublicationTreeNode { ): PublicationTreeNode {
this.#events.set(address, event); this.#events.set(address, event);
@ -816,15 +905,26 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
.filter((tag) => tag[0] === "a") .filter((tag) => tag[0] === "a")
.map((tag) => tag[1]); .map((tag) => tag[1]);
console.debug(`[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`, event.tags); console.debug(
console.debug(`[PublicationTree] Found ${childAddresses.length} a-tags:`, childAddresses); `[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`,
event.tags,
);
console.debug(
`[PublicationTree] Found ${childAddresses.length} a-tags:`,
childAddresses,
);
// If no a-tags found, try e-tags as fallback // If no a-tags found, try e-tags as fallback
if (childAddresses.length === 0) { if (childAddresses.length === 0) {
const eTags = event.tags const eTags = event.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])); .filter((tag) =>
tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])
);
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`, eTags.map(tag => tag[1])); console.debug(
`[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`,
eTags.map((tag) => tag[1]),
);
// For e-tags with hex IDs, fetch the referenced events to get their addresses // For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => { const eTagPromises = eTags.map(async (tag) => {
@ -834,20 +934,31 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (referencedEvent) { if (referencedEvent) {
// Construct the proper address format from the referenced event // Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1]; const dTag = referencedEvent.tags.find((tag) => tag[0] === "d")
?.[1];
if (dTag) { if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; const address =
console.debug(`[PublicationTree] Constructed address from e-tag: ${address}`); `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(
`[PublicationTree] Constructed address from e-tag: ${address}`,
);
return address; return address;
} else { } else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag`); console.debug(
`[PublicationTree] Referenced event ${tag[1]} has no d-tag`,
);
} }
} else { } else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`); console.debug(
`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`,
);
} }
return null; return null;
} catch (error) { } catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`, error); console.warn(
`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`,
error,
);
return null; return null;
} }
}); });
@ -855,7 +966,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Note: We can't await here since this is a synchronous method // Note: We can't await here since this is a synchronous method
// The e-tag resolution will happen when the children are processed // The e-tag resolution will happen when the children are processed
// For now, we'll add the e-tags as potential child addresses // For now, we'll add the e-tags as potential child addresses
const eTagAddresses = eTags.map(tag => tag[1]); const eTagAddresses = eTags.map((tag) => tag[1]);
childAddresses.push(...eTagAddresses); childAddresses.push(...eTagAddresses);
} }
@ -868,11 +979,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}; };
// Add children asynchronously // Add children asynchronously
const childPromises = childAddresses.map(address => const childPromises = childAddresses.map((address) =>
this.addEventByAddress(address, event) this.addEventByAddress(address, event)
); );
Promise.all(childPromises).catch(error => { Promise.all(childPromises).catch((error) => {
console.warn(`[PublicationTree] Error adding children for ${address}:`, error); console.warn(
`[PublicationTree] Error adding children for ${address}:`,
error,
);
}); });
this.#nodeResolvedObservers.forEach((observer) => observer(address)); this.#nodeResolvedObservers.forEach((observer) => observer(address));
@ -881,10 +995,14 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && ( if (
event.kind === 30040 && (
event.tags.some((tag) => tag[0] === "a") || event.tags.some((tag) => tag[0] === "a") ||
event.tags.some((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])) event.tags.some((tag) =>
)) { tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1])
)
)
) {
return PublicationTreeNodeType.Branch; return PublicationTreeNodeType.Branch;
} }

49
src/lib/data_structures/websocket_pool.ts

@ -42,7 +42,10 @@ export class WebSocketPool {
* @param maxConnections - The maximum number of simultaneous WebSocket connections. Defaults to * @param maxConnections - The maximum number of simultaneous WebSocket connections. Defaults to
* 16. * 16.
*/ */
private constructor(idleTimeoutMs: number = 60000, maxConnections: number = 16) { private constructor(
idleTimeoutMs: number = 60000,
maxConnections: number = 16,
) {
this.#idleTimeoutMs = idleTimeoutMs; this.#idleTimeoutMs = idleTimeoutMs;
this.#maxConnections = maxConnections; this.#maxConnections = maxConnections;
} }
@ -71,15 +74,17 @@ export class WebSocketPool {
} }
if (limit == null || isNaN(limit)) { if (limit == null || isNaN(limit)) {
throw new Error('[WebSocketPool] Connection limit must be a number.'); throw new Error("[WebSocketPool] Connection limit must be a number.");
} }
if (limit <= 0) { if (limit <= 0) {
throw new Error('[WebSocketPool] Connection limit must be greater than 0.'); throw new Error(
"[WebSocketPool] Connection limit must be greater than 0.",
);
} }
if (!Number.isInteger(limit)) { if (!Number.isInteger(limit)) {
throw new Error('[WebSocketPool] Connection limit must be an integer.'); throw new Error("[WebSocketPool] Connection limit must be an integer.");
} }
this.#maxConnections = limit; this.#maxConnections = limit;
@ -106,15 +111,15 @@ export class WebSocketPool {
} }
if (timeoutMs == null || isNaN(timeoutMs)) { if (timeoutMs == null || isNaN(timeoutMs)) {
throw new Error('[WebSocketPool] Idle timeout must be a number.'); throw new Error("[WebSocketPool] Idle timeout must be a number.");
} }
if (timeoutMs <= 0) { if (timeoutMs <= 0) {
throw new Error('[WebSocketPool] Idle timeout must be greater than 0.'); throw new Error("[WebSocketPool] Idle timeout must be greater than 0.");
} }
if (!Number.isInteger(timeoutMs)) { if (!Number.isInteger(timeoutMs)) {
throw new Error('[WebSocketPool] Idle timeout must be an integer.'); throw new Error("[WebSocketPool] Idle timeout must be an integer.");
} }
this.#idleTimeoutMs = timeoutMs; this.#idleTimeoutMs = timeoutMs;
@ -163,7 +168,7 @@ export class WebSocketPool {
return newHandle.ws; return newHandle.ws;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`[WebSocketPool] Failed to acquire connection for ${normalizedUrl}: ${error}` `[WebSocketPool] Failed to acquire connection for ${normalizedUrl}: ${error}`,
); );
} }
} }
@ -179,7 +184,9 @@ export class WebSocketPool {
const normalizedUrl = this.#normalizeUrl(ws.url); const normalizedUrl = this.#normalizeUrl(ws.url);
const handle = this.#pool.get(normalizedUrl); const handle = this.#pool.get(normalizedUrl);
if (!handle) { if (!handle) {
throw new Error('[WebSocketPool] Attempted to release an unmanaged WebSocket connection.'); throw new Error(
"[WebSocketPool] Attempted to release an unmanaged WebSocket connection.",
);
} }
if (--handle.refCount === 0) { if (--handle.refCount === 0) {
@ -191,7 +198,9 @@ export class WebSocketPool {
* Closes all WebSocket connections and "drains" the pool. * Closes all WebSocket connections and "drains" the pool.
*/ */
public drain(): void { public drain(): void {
console.debug(`[WebSocketPool] Draining pool with ${this.#pool.size} connections and ${this.#waitingQueue.length} waiting requests`); console.debug(
`[WebSocketPool] Draining pool with ${this.#pool.size} connections and ${this.#waitingQueue.length} waiting requests`,
);
// Clear all idle timers first // Clear all idle timers first
for (const handle of this.#pool.values()) { for (const handle of this.#pool.values()) {
@ -200,7 +209,7 @@ export class WebSocketPool {
// Reject all waiting requests // Reject all waiting requests
for (const { reject } of this.#waitingQueue) { for (const { reject } of this.#waitingQueue) {
reject(new Error('[WebSocketPool] Draining pool.')); reject(new Error("[WebSocketPool] Draining pool."));
} }
this.#waitingQueue = []; this.#waitingQueue = [];
@ -212,7 +221,7 @@ export class WebSocketPool {
} }
this.#pool.clear(); this.#pool.clear();
console.debug('[WebSocketPool] Pool drained successfully'); console.debug("[WebSocketPool] Pool drained successfully");
} }
// #endregion // #endregion
@ -239,7 +248,9 @@ export class WebSocketPool {
this.#removeSocket(handle); this.#removeSocket(handle);
this.#processWaitingQueue(); this.#processWaitingQueue();
reject( reject(
new Error(`[WebSocketPool] WebSocket connection failed for ${url}: ${event.type}`) new Error(
`[WebSocketPool] WebSocket connection failed for ${url}: ${event.type}`,
),
); );
}; };
} catch (error) { } catch (error) {
@ -265,7 +276,9 @@ export class WebSocketPool {
const url = this.#normalizeUrl(handle.ws.url); const url = this.#normalizeUrl(handle.ws.url);
this.#pool.delete(url); this.#pool.delete(url);
console.debug(`[WebSocketPool] Removed socket for ${url}, pool size: ${this.#pool.size}`); console.debug(
`[WebSocketPool] Removed socket for ${url}, pool size: ${this.#pool.size}`,
);
this.#processWaitingQueue(); this.#processWaitingQueue();
} }
@ -283,7 +296,9 @@ export class WebSocketPool {
handle.idleTimer = setTimeout(() => { handle.idleTimer = setTimeout(() => {
const refCount = handle.refCount; const refCount = handle.refCount;
if (refCount === 0 && handle.ws.readyState === WebSocket.OPEN) { if (refCount === 0 && handle.ws.readyState === WebSocket.OPEN) {
console.debug(`[WebSocketPool] Closing idle connection to ${handle.ws.url}`); console.debug(
`[WebSocketPool] Closing idle connection to ${handle.ws.url}`,
);
handle.ws.close(); handle.ws.close();
this.#removeSocket(handle); this.#removeSocket(handle);
} }
@ -331,7 +346,7 @@ export class WebSocketPool {
#checkOut(handle: WebSocketHandle): void { #checkOut(handle: WebSocketHandle): void {
if (handle.refCount == null) { if (handle.refCount == null) {
throw new Error('[WebSocketPool] Handle refCount unexpectedly null.'); throw new Error("[WebSocketPool] Handle refCount unexpectedly null.");
} }
++handle.refCount; ++handle.refCount;
@ -346,7 +361,7 @@ export class WebSocketPool {
// The logic to remove a trailing slash for connection coalescing can be kept, // The logic to remove a trailing slash for connection coalescing can be kept,
// but should be done on the normalized string. // but should be done on the normalized string.
if (urlObj.pathname !== '/' && normalized.endsWith('/')) { if (urlObj.pathname !== "/" && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1); normalized = normalized.slice(0, -1);
} }

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

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

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

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

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

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

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

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

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

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

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

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

253
src/lib/ndk.ts

@ -1,30 +1,32 @@
import NDK, { import NDK, {
NDKEvent,
NDKNip07Signer, NDKNip07Signer,
NDKRelay, NDKRelay,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
NDKRelaySet, NDKRelaySet,
NDKUser, NDKUser,
NDKEvent,
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { writable, get, type Writable } from "svelte/store"; import { get, type Writable, writable } from "svelte/store";
import { import { anonymousRelays, loginStorageKey } from "./consts.ts";
loginStorageKey,
anonymousRelays,
} from "./consts.ts";
import { import {
buildCompleteRelaySet, buildCompleteRelaySet,
testRelayConnection,
deduplicateRelayUrls, deduplicateRelayUrls,
testRelayConnection,
} from "./utils/relay_management.ts"; } from "./utils/relay_management.ts";
import { userStore } from "./stores/userStore.ts";
import { userPubkey } from "./stores/authStore.Svelte.ts";
import {
startNetworkStatusMonitoring,
stopNetworkStatusMonitoring,
} from "./stores/networkStore.ts";
import { WebSocketPool } from "./data_structures/websocket_pool.ts";
import { getContext, setContext } from "svelte";
// Re-export testRelayConnection for components that need it // Re-export testRelayConnection for components that need it
export { testRelayConnection }; export { testRelayConnection };
import { userStore } from "./stores/userStore.ts";
import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore.ts";
import { WebSocketPool } from "./data_structures/websocket_pool.ts";
export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false); export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]); export const inboxRelays = writable<string[]>([]);
export const outboxRelays = writable<string[]>([]); export const outboxRelays = writable<string[]>([]);
@ -32,18 +34,33 @@ export const outboxRelays = writable<string[]>([]);
export const activeInboxRelays = writable<string[]>([]); export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]); export const activeOutboxRelays = writable<string[]>([]);
const NDK_CONTEXT_KEY = "ndk";
export function getNdkContext(): NDK {
return getContext(NDK_CONTEXT_KEY) as NDK;
}
export function setNdkContext(ndk: NDK): void {
setContext(NDK_CONTEXT_KEY, ndk);
}
// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation // AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation
let persistentRelaySet: { inboxRelays: string[]; outboxRelays: string[] } | null = null; let persistentRelaySet:
| { inboxRelays: string[]; outboxRelays: string[] }
| null = null;
let relaySetLastUpdated: number = 0; let relaySetLastUpdated: number = 0;
const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const RELAY_SET_STORAGE_KEY = 'alexandria/relay_set_cache'; const RELAY_SET_STORAGE_KEY = "alexandria/relay_set_cache";
/** /**
* Load persistent relay set from localStorage * Load persistent relay set from localStorage
*/ */
function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null; lastUpdated: number } { function loadPersistentRelaySet(): {
relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null;
lastUpdated: number;
} {
// Only load from localStorage on client-side // Only load from localStorage on client-side
if (typeof window === 'undefined') return { relaySet: null, lastUpdated: 0 }; if (typeof window === "undefined") return { relaySet: null, lastUpdated: 0 };
try { try {
const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY); const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY);
@ -60,7 +77,7 @@ function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRe
return { relaySet: data.relaySet, lastUpdated: data.timestamp }; return { relaySet: data.relaySet, lastUpdated: data.timestamp };
} catch (error) { } catch (error) {
console.warn('[NDK.ts] Failed to load persistent relay set:', error); console.warn("[NDK.ts] Failed to load persistent relay set:", error);
localStorage.removeItem(RELAY_SET_STORAGE_KEY); localStorage.removeItem(RELAY_SET_STORAGE_KEY);
return { relaySet: null, lastUpdated: 0 }; return { relaySet: null, lastUpdated: 0 };
} }
@ -69,18 +86,20 @@ function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRe
/** /**
* Save persistent relay set to localStorage * Save persistent relay set to localStorage
*/ */
function savePersistentRelaySet(relaySet: { inboxRelays: string[]; outboxRelays: string[] }): void { function savePersistentRelaySet(
relaySet: { inboxRelays: string[]; outboxRelays: string[] },
): void {
// Only save to localStorage on client-side // Only save to localStorage on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
try { try {
const data = { const data = {
relaySet, relaySet,
timestamp: Date.now() timestamp: Date.now(),
}; };
localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data));
} catch (error) { } catch (error) {
console.warn('[NDK.ts] Failed to save persistent relay set:', error); console.warn("[NDK.ts] Failed to save persistent relay set:", error);
} }
} }
@ -89,12 +108,12 @@ function savePersistentRelaySet(relaySet: { inboxRelays: string[]; outboxRelays:
*/ */
function clearPersistentRelaySet(): void { function clearPersistentRelaySet(): void {
// Only clear from localStorage on client-side // Only clear from localStorage on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
try { try {
localStorage.removeItem(RELAY_SET_STORAGE_KEY); localStorage.removeItem(RELAY_SET_STORAGE_KEY);
} catch (error) { } catch (error) {
console.warn('[NDK.ts] Failed to clear persistent relay set:', error); console.warn("[NDK.ts] Failed to clear persistent relay set:", error);
} }
} }
@ -228,8 +247,7 @@ class CustomRelayAuthPolicy {
export function checkEnvironmentForWebSocketDowngrade(): void { export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug("[NDK.ts] Environment Check for WebSocket Protocol:"); console.debug("[NDK.ts] Environment Check for WebSocket Protocol:");
const isLocalhost = const isLocalhost = globalThis.location.hostname === "localhost" ||
globalThis.location.hostname === "localhost" ||
globalThis.location.hostname === "127.0.0.1"; globalThis.location.hostname === "127.0.0.1";
const isHttp = globalThis.location.protocol === "http:"; const isHttp = globalThis.location.protocol === "http:";
const isHttps = globalThis.location.protocol === "https:"; const isHttps = globalThis.location.protocol === "https:";
@ -279,8 +297,6 @@ export function checkWebSocketSupport(): void {
} }
} }
/** /**
* Gets the user's pubkey from local storage, if it exists. * Gets the user's pubkey from local storage, if it exists.
* @returns The user's pubkey, or null if there is no logged-in user. * @returns The user's pubkey, or null if there is no logged-in user.
@ -289,7 +305,7 @@ export function checkWebSocketSupport(): void {
*/ */
export function getPersistedLogin(): string | null { export function getPersistedLogin(): string | null {
// Only access localStorage on client-side // Only access localStorage on client-side
if (typeof window === 'undefined') return null; if (typeof window === "undefined") return null;
const pubkey = localStorage.getItem(loginStorageKey); const pubkey = localStorage.getItem(loginStorageKey);
return pubkey; return pubkey;
@ -303,7 +319,7 @@ export function getPersistedLogin(): string | null {
*/ */
export function persistLogin(user: NDKUser): void { export function persistLogin(user: NDKUser): void {
// Only access localStorage on client-side // Only access localStorage on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
localStorage.setItem(loginStorageKey, user.pubkey); localStorage.setItem(loginStorageKey, user.pubkey);
} }
@ -314,7 +330,7 @@ export function persistLogin(user: NDKUser): void {
*/ */
export function clearLogin(): void { export function clearLogin(): void {
// Only access localStorage on client-side // Only access localStorage on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
localStorage.removeItem(loginStorageKey); localStorage.removeItem(loginStorageKey);
} }
@ -331,7 +347,7 @@ function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
export function clearPersistedRelays(user: NDKUser): void { export function clearPersistedRelays(user: NDKUser): void {
// Only access localStorage on client-side // Only access localStorage on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
localStorage.removeItem(getRelayStorageKey(user, "inbox")); localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, "outbox")); localStorage.removeItem(getRelayStorageKey(user, "outbox"));
@ -344,7 +360,7 @@ export function clearPersistedRelays(user: NDKUser): void {
*/ */
function ensureSecureWebSocket(url: string): string { function ensureSecureWebSocket(url: string): string {
// For localhost, always use ws:// (never wss://) // For localhost, always use ws:// (never wss://)
if (url.includes('localhost') || url.includes('127.0.0.1')) { if (url.includes("localhost") || url.includes("127.0.0.1")) {
// Convert any wss://localhost to ws://localhost // Convert any wss://localhost to ws://localhost
return url.replace(/^wss:\/\//, "ws://"); return url.replace(/^wss:\/\//, "ws://");
} }
@ -367,7 +383,7 @@ function ensureSecureWebSocket(url: string): string {
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
try { try {
// Reduce verbosity in development - only log relay creation if debug mode is enabled // Reduce verbosity in development - only log relay creation if debug mode is enabled
if (process.env.NODE_ENV === 'development' && process.env.DEBUG_RELAYS) { if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`); console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
} }
@ -385,7 +401,9 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
try { try {
// Only log connection timeouts if debug mode is enabled // Only log connection timeouts if debug mode is enabled
if (process.env.NODE_ENV === 'development' && process.env.DEBUG_RELAYS) { if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Connection timeout for ${secureUrl}`); console.debug(`[NDK.ts] Connection timeout for ${secureUrl}`);
} }
relay.disconnect(); relay.disconnect();
@ -400,7 +418,9 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
relay.on("connect", () => { relay.on("connect", () => {
try { try {
// Only log successful connections if debug mode is enabled // Only log successful connections if debug mode is enabled
if (process.env.NODE_ENV === 'development' && process.env.DEBUG_RELAYS) { if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
} }
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
@ -413,7 +433,9 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
relay.on("connect", () => { relay.on("connect", () => {
try { try {
// Only log successful connections if debug mode is enabled // Only log successful connections if debug mode is enabled
if (process.env.NODE_ENV === 'development' && process.env.DEBUG_RELAYS) { if (
process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS
) {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
} }
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
@ -436,46 +458,66 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
return relay; return relay;
} catch (error) { } catch (error) {
// If relay creation fails, try to use an anonymous relay as fallback // If relay creation fails, try to use an anonymous relay as fallback
console.debug(`[NDK.ts] Failed to create relay for ${url}, trying anonymous relay fallback`); console.debug(
`[NDK.ts] Failed to create relay for ${url}, trying anonymous relay fallback`,
);
// Find an anonymous relay that's not the same as the failed URL // Find an anonymous relay that's not the same as the failed URL
const fallbackUrl = anonymousRelays.find(relay => relay !== url) || anonymousRelays[0]; const fallbackUrl = anonymousRelays.find((relay) => relay !== url) ||
anonymousRelays[0];
if (fallbackUrl) { if (fallbackUrl) {
console.debug(`[NDK.ts] Using anonymous relay as fallback: ${fallbackUrl}`); console.debug(
`[NDK.ts] Using anonymous relay as fallback: ${fallbackUrl}`,
);
try { try {
const fallbackRelay = new NDKRelay(fallbackUrl, NDKRelayAuthPolicies.signIn({ ndk }), ndk); const fallbackRelay = new NDKRelay(
fallbackUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
return fallbackRelay; return fallbackRelay;
} catch (fallbackError) { } catch (fallbackError) {
console.debug(`[NDK.ts] Fallback relay creation also failed: ${fallbackError}`); console.debug(
`[NDK.ts] Fallback relay creation also failed: ${fallbackError}`,
);
} }
} }
// If all else fails, create a minimal relay that will fail gracefully // If all else fails, create a minimal relay that will fail gracefully
console.debug(`[NDK.ts] All fallback attempts failed, creating minimal relay for ${url}`); console.debug(
`[NDK.ts] All fallback attempts failed, creating minimal relay for ${url}`,
);
const minimalRelay = new NDKRelay(url, undefined, ndk); const minimalRelay = new NDKRelay(url, undefined, ndk);
return minimalRelay; return minimalRelay;
} }
} }
/** /**
* Gets the active relay set for the current user * Gets the active relay set for the current user
* @param ndk NDK instance * @param ndk NDK instance
* @returns Promise that resolves to object with inbox and outbox relay arrays * @returns Promise that resolves to object with inbox and outbox relay arrays
*/ */
export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> { export async function getActiveRelaySet(
ndk: NDK,
): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
const user = get(userStore); const user = get(userStore);
console.debug('[NDK.ts] getActiveRelaySet: User state:', { signedIn: user.signedIn, hasNdkUser: !!user.ndkUser, pubkey: user.pubkey }); console.debug("[NDK.ts] getActiveRelaySet: User state:", {
signedIn: user.signedIn,
hasNdkUser: !!user.ndkUser,
pubkey: user.pubkey,
});
if (user.signedIn && user.ndkUser) { if (user.signedIn && user.ndkUser) {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:', user.ndkUser.pubkey); console.debug(
"[NDK.ts] getActiveRelaySet: Building relay set for authenticated user:",
user.ndkUser.pubkey,
);
return await buildCompleteRelaySet(ndk, user.ndkUser); return await buildCompleteRelaySet(ndk, user.ndkUser);
} else { } else {
console.debug('[NDK.ts] getActiveRelaySet: Building relay set for anonymous user'); console.debug(
"[NDK.ts] getActiveRelaySet: Building relay set for anonymous user",
);
return await buildCompleteRelaySet(ndk, null); return await buildCompleteRelaySet(ndk, null);
} }
} }
@ -485,7 +527,10 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string
* @param ndk NDK instance * @param ndk NDK instance
* @param forceUpdate Force update even if cached (default: false) * @param forceUpdate Force update even if cached (default: false)
*/ */
export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = false): Promise<void> { export async function updateActiveRelayStores(
ndk: NDK,
forceUpdate: boolean = false,
): Promise<void> {
try { try {
// AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation // AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation
const now = Date.now(); const now = Date.now();
@ -499,17 +544,19 @@ export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = f
} }
if (!forceUpdate && persistentRelaySet && !cacheExpired) { if (!forceUpdate && persistentRelaySet && !cacheExpired) {
console.debug('[NDK.ts] updateActiveRelayStores: Using cached relay set'); console.debug("[NDK.ts] updateActiveRelayStores: Using cached relay set");
activeInboxRelays.set(persistentRelaySet.inboxRelays); activeInboxRelays.set(persistentRelaySet.inboxRelays);
activeOutboxRelays.set(persistentRelaySet.outboxRelays); activeOutboxRelays.set(persistentRelaySet.outboxRelays);
return; return;
} }
console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update'); console.debug(
"[NDK.ts] updateActiveRelayStores: Starting relay store update",
);
// Get the active relay set from the relay management system // Get the active relay set from the relay management system
const relaySet = await getActiveRelaySet(ndk); const relaySet = await getActiveRelaySet(ndk);
console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet); console.debug("[NDK.ts] updateActiveRelayStores: Got relay set:", relaySet);
// Cache the relay set // Cache the relay set
persistentRelaySet = relaySet; persistentRelaySet = relaySet;
@ -519,13 +566,25 @@ export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = f
// Update the stores with the new relay configuration // Update the stores with the new relay configuration
activeInboxRelays.set(relaySet.inboxRelays); activeInboxRelays.set(relaySet.inboxRelays);
activeOutboxRelays.set(relaySet.outboxRelays); activeOutboxRelays.set(relaySet.outboxRelays);
console.debug('[NDK.ts] updateActiveRelayStores: Updated stores with inbox:', relaySet.inboxRelays.length, 'outbox:', relaySet.outboxRelays.length); console.debug(
"[NDK.ts] updateActiveRelayStores: Updated stores with inbox:",
relaySet.inboxRelays.length,
"outbox:",
relaySet.outboxRelays.length,
);
// Add relays to NDK pool (deduplicated) // Add relays to NDK pool (deduplicated)
const allRelayUrls = deduplicateRelayUrls([...relaySet.inboxRelays, ...relaySet.outboxRelays]); const allRelayUrls = deduplicateRelayUrls([
...relaySet.inboxRelays,
...relaySet.outboxRelays,
]);
// Reduce verbosity in development - only log relay addition if debug mode is enabled // Reduce verbosity in development - only log relay addition if debug mode is enabled
if (process.env.NODE_ENV === 'development' && process.env.DEBUG_RELAYS) { if (process.env.NODE_ENV === "development" && process.env.DEBUG_RELAYS) {
console.debug('[NDK.ts] updateActiveRelayStores: Adding', allRelayUrls.length, 'relays to NDK pool'); console.debug(
"[NDK.ts] updateActiveRelayStores: Adding",
allRelayUrls.length,
"relays to NDK pool",
);
} }
for (const url of allRelayUrls) { for (const url of allRelayUrls) {
@ -533,13 +592,23 @@ export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = f
const relay = createRelayWithAuth(url, ndk); const relay = createRelayWithAuth(url, ndk);
ndk.pool?.addRelay(relay); ndk.pool?.addRelay(relay);
} catch (error) { } catch (error) {
console.debug('[NDK.ts] updateActiveRelayStores: Failed to add relay', url, ':', error); console.debug(
"[NDK.ts] updateActiveRelayStores: Failed to add relay",
url,
":",
error,
);
} }
} }
console.debug('[NDK.ts] updateActiveRelayStores: Relay store update completed'); console.debug(
"[NDK.ts] updateActiveRelayStores: Relay store update completed",
);
} catch (error) { } catch (error) {
console.warn('[NDK.ts] updateActiveRelayStores: Error updating relay stores:', error); console.warn(
"[NDK.ts] updateActiveRelayStores: Error updating relay stores:",
error,
);
} }
} }
@ -550,22 +619,24 @@ export function logCurrentRelayConfiguration(): void {
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
console.log('🔌 Current Relay Configuration:'); console.log("🔌 Current Relay Configuration:");
console.log('📥 Inbox Relays:', inboxRelays); console.log("📥 Inbox Relays:", inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays); console.log("📤 Outbox Relays:", outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); console.log(
`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`,
);
} }
/** /**
* Clears the relay set cache to force a rebuild * Clears the relay set cache to force a rebuild
*/ */
export function clearRelaySetCache(): void { export function clearRelaySetCache(): void {
console.debug('[NDK.ts] Clearing relay set cache'); console.debug("[NDK.ts] Clearing relay set cache");
persistentRelaySet = null; persistentRelaySet = null;
relaySetLastUpdated = 0; relaySetLastUpdated = 0;
// Clear from localStorage as well (client-side only) // Clear from localStorage as well (client-side only)
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
localStorage.removeItem('alexandria/relay_set_cache'); localStorage.removeItem("alexandria/relay_set_cache");
} }
} }
@ -574,7 +645,7 @@ export function clearRelaySetCache(): void {
* @param ndk NDK instance * @param ndk NDK instance
*/ */
export async function refreshRelayStores(ndk: NDK): Promise<void> { export async function refreshRelayStores(ndk: NDK): Promise<void> {
console.debug('[NDK.ts] Refreshing relay stores due to user state change'); console.debug("[NDK.ts] Refreshing relay stores due to user state change");
clearRelaySetCache(); // Clear cache when user state changes clearRelaySetCache(); // Clear cache when user state changes
await updateActiveRelayStores(ndk, true); // Force update await updateActiveRelayStores(ndk, true); // Force update
} }
@ -583,8 +654,12 @@ export async function refreshRelayStores(ndk: NDK): Promise<void> {
* Updates relay stores when network condition changes * Updates relay stores when network condition changes
* @param ndk NDK instance * @param ndk NDK instance
*/ */
export async function refreshRelayStoresOnNetworkChange(ndk: NDK): Promise<void> { export async function refreshRelayStoresOnNetworkChange(
console.debug('[NDK.ts] Refreshing relay stores due to network condition change'); ndk: NDK,
): Promise<void> {
console.debug(
"[NDK.ts] Refreshing relay stores due to network condition change",
);
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
} }
@ -604,7 +679,7 @@ export function startNetworkMonitoringForRelays(): void {
* @returns NDKRelaySet * @returns NDKRelaySet
*/ */
function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet { function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
const relays = relayUrls.map(url => const relays = relayUrls.map((url) =>
new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk) new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk)
); );
@ -619,7 +694,7 @@ function createRelaySetFromUrls(relayUrls: string[], ndk: NDK): NDKRelaySet {
*/ */
export async function getActiveRelaySetAsNDKRelaySet( export async function getActiveRelaySetAsNDKRelaySet(
ndk: NDK, ndk: NDK,
useInbox: boolean = true useInbox: boolean = true,
): Promise<NDKRelaySet> { ): Promise<NDKRelaySet> {
const relaySet = await getActiveRelaySet(ndk); const relaySet = await getActiveRelaySet(ndk);
const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays; const urls = useInbox ? relaySet.inboxRelays : relaySet.outboxRelays;
@ -648,7 +723,7 @@ export function initNdk(): NDK {
const attemptConnection = async () => { const attemptConnection = async () => {
// Only attempt connection on client-side // Only attempt connection on client-side
if (typeof window === 'undefined') { if (typeof window === "undefined") {
console.debug("[NDK.ts] Skipping NDK connection during SSR"); console.debug("[NDK.ts] Skipping NDK connection during SSR");
return; return;
} }
@ -666,13 +741,17 @@ export function initNdk(): NDK {
// Only retry a limited number of times // Only retry a limited number of times
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
retryCount++; retryCount++;
console.debug(`[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`); console.debug(
`[NDK.ts] Attempting to reconnect (${retryCount}/${maxRetries})...`,
);
// Use a more reasonable retry delay and prevent memory leaks // Use a more reasonable retry delay and prevent memory leaks
setTimeout(() => { setTimeout(() => {
attemptConnection(); attemptConnection();
}, 2000 * retryCount); // Exponential backoff }, 2000 * retryCount); // Exponential backoff
} else { } else {
console.warn("[NDK.ts] Max retries reached, continuing with limited functionality"); console.warn(
"[NDK.ts] Max retries reached, continuing with limited functionality",
);
// Still try to update relay stores even if connection failed // Still try to update relay stores even if connection failed
try { try {
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
@ -685,7 +764,7 @@ export function initNdk(): NDK {
}; };
// Only attempt connection on client-side // Only attempt connection on client-side
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
attemptConnection(); attemptConnection();
} }
@ -694,12 +773,14 @@ export function initNdk(): NDK {
ndkSignedIn.set(userState.signedIn); ndkSignedIn.set(userState.signedIn);
// Refresh relay stores when user state changes // Refresh relay stores when user state changes
const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
try { try {
await refreshRelayStores(ndk); await refreshRelayStores(ndk);
} catch (error) { } catch (error) {
console.warn('[NDK.ts] Failed to refresh relay stores on user state change:', error); console.warn(
"[NDK.ts] Failed to refresh relay stores on user state change:",
error,
);
} }
} }
}); });
@ -714,7 +795,7 @@ export function initNdk(): NDK {
export function cleanupNdk(): void { export function cleanupNdk(): void {
console.debug("[NDK.ts] Cleaning up NDK resources"); console.debug("[NDK.ts] Cleaning up NDK resources");
const ndk = get(ndkInstance); const ndk = getNdkContext();
if (ndk) { if (ndk) {
try { try {
// Disconnect from all relays // Disconnect from all relays
@ -746,7 +827,7 @@ export async function loginWithExtension(
pubkey?: string, pubkey?: string,
): Promise<NDKUser | null> { ): Promise<NDKUser | null> {
try { try {
const ndk = get(ndkInstance); const ndk = getNdkContext();
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
const signerUser = await signer.user(); const signerUser = await signer.user();
@ -755,7 +836,8 @@ export async function loginWithExtension(
console.debug("[NDK.ts] Switching pubkeys from last login."); console.debug("[NDK.ts] Switching pubkeys from last login.");
} }
activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
@ -765,7 +847,7 @@ export async function loginWithExtension(
ndk.signer = signer; ndk.signer = signer;
ndk.activeUser = user; ndk.activeUser = user;
ndkInstance.set(ndk); setNdkContext(ndk);
ndkSignedIn.set(true); ndkSignedIn.set(true);
return user; return user;
@ -781,7 +863,8 @@ export async function loginWithExtension(
export function logout(user: NDKUser): void { export function logout(user: NDKUser): void {
clearLogin(); clearLogin();
clearPersistedRelays(user); clearPersistedRelays(user);
activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false); ndkSignedIn.set(false);
// Clear relay stores // Clear relay stores
@ -798,7 +881,5 @@ export function logout(user: NDKUser): void {
// Re-initialize with anonymous instance // Re-initialize with anonymous instance
const newNdk = initNdk(); const newNdk = initNdk();
ndkInstance.set(newNdk); setNdkContext(newNdk);
} }

4
src/lib/parser.ts

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

10
src/lib/services/event_search_service.ts

@ -34,7 +34,11 @@ export class EventSearchService {
/** /**
* Checks if a search value matches the current event * Checks if a search value matches the current event
*/ */
isCurrentEventMatch(searchValue: string, event: any, relays: string[]): boolean { isCurrentEventMatch(
searchValue: string,
event: any,
relays: string[],
): boolean {
const currentEventId = event.id; const currentEventId = event.id;
let currentNaddr = null; let currentNaddr = null;
let currentNevent = null; let currentNevent = null;
@ -42,7 +46,9 @@ export class EventSearchService {
let currentNprofile = null; let currentNprofile = null;
try { try {
const { neventEncode, naddrEncode, nprofileEncode } = require("$lib/utils"); const { neventEncode, naddrEncode, nprofileEncode } = require(
"$lib/utils",
);
const { getMatchingTags, toNpub } = require("$lib/utils/nostrUtils"); const { getMatchingTags, toNpub } = require("$lib/utils/nostrUtils");
currentNevent = neventEncode(event, relays); currentNevent = neventEncode(event, relays);

65
src/lib/services/publisher.ts

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

20
src/lib/services/search_state_manager.ts

@ -13,7 +13,7 @@ export class SearchStateManager {
searchResultCount: number | null; searchResultCount: number | null;
searchResultType: string | null; searchResultType: string | null;
}, },
onLoadingChange?: (loading: boolean) => void onLoadingChange?: (loading: boolean) => void,
): void { ): void {
if (onLoadingChange) { if (onLoadingChange) {
onLoadingChange(state.searching); onLoadingChange(state.searching);
@ -25,10 +25,16 @@ export class SearchStateManager {
*/ */
resetSearchState( resetSearchState(
callbacks: { callbacks: {
onSearchResults: (events: any[], secondOrder: any[], tTagEvents: any[], eventIds: Set<string>, addresses: Set<string>) => void; onSearchResults: (
events: any[],
secondOrder: any[],
tTagEvents: any[],
eventIds: Set<string>,
addresses: Set<string>,
) => void;
cleanupSearch: () => void; cleanupSearch: () => void;
clearTimeout: () => void; clearTimeout: () => void;
} },
): void { ): void {
callbacks.cleanupSearch(); callbacks.cleanupSearch();
callbacks.onSearchResults([], [], [], new Set(), new Set()); callbacks.onSearchResults([], [], [], new Set(), new Set());
@ -46,16 +52,18 @@ export class SearchStateManager {
cleanupSearch: () => void; cleanupSearch: () => void;
updateSearchState: (state: any) => void; updateSearchState: (state: any) => void;
resetProcessingFlags: () => void; resetProcessingFlags: () => void;
} },
): void { ): void {
const errorMessage = error instanceof Error ? error.message : defaultMessage; const errorMessage = error instanceof Error
? error.message
: defaultMessage;
callbacks.setLocalError(errorMessage); callbacks.setLocalError(errorMessage);
callbacks.cleanupSearch(); callbacks.cleanupSearch();
callbacks.updateSearchState({ callbacks.updateSearchState({
searching: false, searching: false,
searchCompleted: false, searchCompleted: false,
searchResultCount: null, searchResultCount: null,
searchResultType: null searchResultType: null,
}); });
callbacks.resetProcessingFlags(); callbacks.resetProcessingFlags();
} }

2
src/lib/state.ts

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

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

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

22
src/lib/stores/networkStore.ts

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

575
src/lib/stores/userStore.ts

@ -1,36 +1,34 @@
import { writable, get } from 'svelte/store'; import { get, writable } from "svelte/store";
import type { NostrProfile } from '../utils/search_types.ts'; import type { NostrProfile } from "../utils/nostrUtils.ts";
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk'; import type { NDKSigner, NDKUser } from "@nostr-dev-kit/ndk";
import NDK, { import NDK, {
NDKNip07Signer, NDKNip07Signer,
NDKRelay,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
NDKRelaySet, NDKRelaySet,
NDKRelay, } from "@nostr-dev-kit/ndk";
} from '@nostr-dev-kit/ndk'; import { getUserMetadata } from "../utils/nostrUtils.ts";
import { getUserMetadata } from '../utils/nostrUtils.ts'; import {
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from '../ndk.ts'; activeInboxRelays,
import { loginStorageKey } from '../consts.ts'; activeOutboxRelays,
import { nip19 } from 'nostr-tools'; updateActiveRelayStores,
import { fetchCurrentUserLists } from '../utils/user_lists.ts'; } from "../ndk.ts";
import { npubCache } from '../utils/npubCache.ts'; import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools";
// AI-NOTE: UserStore consolidation - This file contains all user-related state management import { userPubkey } from "../stores/authStore.Svelte.ts";
// including authentication, profile management, relay preferences, and user lists caching.
export type LoginMethod = 'extension' | 'amber' | 'npub';
export interface UserState { export interface UserState {
pubkey: string | null; pubkey: string | null;
npub: string | null; npub: string | null;
profile: NostrProfile | null; profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] }; relays: { inbox: string[]; outbox: string[] };
loginMethod: LoginMethod | null; loginMethod: "extension" | "amber" | "npub" | null;
ndkUser: NDKUser | null; ndkUser: NDKUser | null;
signer: NDKSigner | null; signer: NDKSigner | null;
signedIn: boolean; signedIn: boolean;
} }
const initialUserState: UserState = { export const userStore = writable<UserState>({
pubkey: null, pubkey: null,
npub: null, npub: null,
profile: null, profile: null,
@ -39,87 +37,56 @@ const initialUserState: UserState = {
ndkUser: null, ndkUser: null,
signer: null, signer: null,
signedIn: false, signedIn: false,
}; });
export const userStore = writable<UserState>(initialUserState);
// Storage keys
export const loginMethodStorageKey = 'alexandria/login/method';
const LOGOUT_FLAG_KEY = 'alexandria/logout/flag';
// Performance optimization: Cache for relay storage keys
const relayStorageKeyCache = new Map<string, { inbox: string; outbox: string }>();
/**
* Get relay storage key for a user, with caching for performance
*/
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
const cacheKey = user.pubkey;
let cached = relayStorageKeyCache.get(cacheKey);
if (!cached) {
const baseKey = `${loginStorageKey}/${user.pubkey}`;
cached = {
inbox: `${baseKey}/inbox`,
outbox: `${baseKey}/outbox`,
};
relayStorageKeyCache.set(cacheKey, cached);
}
return type === 'inbox' ? cached.inbox : cached.outbox;
}
/** // Helper functions for relay management
* Safely access localStorage (client-side only) function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
*/ return `${loginStorageKey}/${user.pubkey}/${type}`;
function safeLocalStorage(): Storage | null {
return typeof window !== 'undefined' ? window.localStorage : null;
} }
/**
* Persist relay preferences to localStorage
*/
function persistRelays( function persistRelays(
user: NDKUser, user: NDKUser,
inboxes: Set<NDKRelay>, inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>,
): void { ): void {
const storage = safeLocalStorage(); // Only access localStorage on client-side
if (!storage) return; if (typeof window === "undefined") return;
const inboxUrls = Array.from(inboxes).map((relay) => relay.url);
const outboxUrls = Array.from(outboxes).map((relay) => relay.url);
storage.setItem(getRelayStorageKey(user, 'inbox'), JSON.stringify(inboxUrls)); localStorage.setItem(
storage.setItem(getRelayStorageKey(user, 'outbox'), JSON.stringify(outboxUrls)); 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)),
);
} }
/**
* Get persisted relay preferences from localStorage
*/
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const storage = safeLocalStorage(); // Only access localStorage on client-side
if (!storage) { if (typeof window === "undefined") {
return [new Set<string>(), new Set<string>()]; return [new Set<string>(), new Set<string>()];
} }
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(storage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]'), JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
); );
const outboxes = new Set<string>( const outboxes = new Set<string>(
JSON.parse(storage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]'), JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
); );
return [inboxes, outboxes]; return [inboxes, outboxes];
} }
/**
* Fetch user's preferred relays from Nostr network
*/
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)], fallbacks: readonly string[] = [
...get(activeInboxRelays),
...get(activeOutboxRelays),
],
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
@ -137,11 +104,9 @@ async function getUserPreferredRelays(
const inboxRelays = new Set<NDKRelay>(); const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>(); const outboxRelays = new Set<NDKRelay>();
if (!relayList) { if (relayList == null) {
// Fallback to extension relays if available
const relayMap = await globalThis.nostr?.getRelays?.(); const relayMap = await globalThis.nostr?.getRelays?.();
if (relayMap) { Object.entries(relayMap ?? {}).forEach(
Object.entries(relayMap).forEach(
([url, relayType]: [string, Record<string, boolean | undefined>]) => { ([url, relayType]: [string, Record<string, boolean | undefined>]) => {
const relay = new NDKRelay( const relay = new NDKRelay(
url, url,
@ -152,23 +117,26 @@ async function getUserPreferredRelays(
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}, },
); );
}
} else { } else {
// Parse relay list from event
relayList.tags.forEach((tag: string[]) => { relayList.tags.forEach((tag: string[]) => {
const relay = new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk);
switch (tag[0]) { switch (tag[0]) {
case 'r': case "r":
inboxRelays.add(relay); inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
case 'w': case "w":
outboxRelays.add(relay); outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
default: default:
// Default: add to both inboxRelays.add(
inboxRelays.add(relay); new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
outboxRelays.add(relay); );
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
} }
}); });
@ -177,358 +145,337 @@ async function getUserPreferredRelays(
return [inboxRelays, outboxRelays]; return [inboxRelays, outboxRelays];
} }
/** // --- Unified login/logout helpers ---
* Persist login information to localStorage
*/
function persistLogin(user: NDKUser, method: LoginMethod): void {
const storage = safeLocalStorage();
if (!storage) return;
storage.setItem(loginStorageKey, user.pubkey); export const loginMethodStorageKey = "alexandria/login/method";
storage.setItem(loginMethodStorageKey, method);
}
/** function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
* Clear login information from localStorage // Only access localStorage on client-side
*/ if (typeof window === "undefined") return;
function clearLogin(): void {
const storage = safeLocalStorage();
if (!storage) return;
storage.removeItem(loginStorageKey); localStorage.setItem(loginStorageKey, user.pubkey);
storage.removeItem(loginMethodStorageKey); localStorage.setItem(loginMethodStorageKey, method);
} }
/** function clearLogin() {
* Fetch user profile with fallback localStorage.removeItem(loginStorageKey);
*/ localStorage.removeItem(loginMethodStorageKey);
async function fetchUserProfile(npub: string): Promise<NostrProfile> {
try {
return await getUserMetadata(npub, true);
} catch (error) {
console.warn('Failed to fetch user metadata:', error);
// Fallback profile
return {
name: npub.slice(0, 8) + '...' + npub.slice(-4),
displayName: npub.slice(0, 8) + '...' + npub.slice(-4),
};
}
} }
/** /**
* Fetch and cache user lists in background * Login with NIP-07 browser extension
*/ */
async function fetchUserListsAndUpdateCache(userPubkey: string): Promise<void> { export async function loginWithExtension(ndk: NDK) {
try { if (!ndk) throw new Error("NDK not initialized");
console.log('Fetching user lists and updating profile cache for:', userPubkey); // Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const userLists = await fetchCurrentUserLists(); const user = await signer.user();
console.log(`Found ${userLists.length} user lists`); const npub = user.npub;
// Collect all unique pubkeys
const allPubkeys = new Set<string>();
userLists.forEach(list => {
list.pubkeys.forEach(pubkey => allPubkeys.add(pubkey));
});
console.log(`Found ${allPubkeys.size} unique pubkeys in user lists`); console.log("Login with extension - fetching profile for npub:", npub);
// Batch fetch profiles for performance // Try to fetch user metadata, but don't fail if it times out
const batchSize = 20; let profile: NostrProfile | null = null;
const pubkeyArray = Array.from(allPubkeys); try {
const ndk = get(ndkInstance); console.log("Login with extension - attempting to fetch profile...");
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with extension - using fallback profile:", profile);
}
if (!ndk) return; // 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;
for (let i = 0; i < pubkeyArray.length; i += batchSize) { const userState = {
const batch = pubkeyArray.slice(i, i + batchSize); pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "extension" as const,
ndkUser: user,
signer,
signedIn: true,
};
try { console.log("Login with extension - setting userStore with:", userState);
const events = await ndk.fetchEvents({ userStore.set(userState);
kinds: [0], userPubkey.set(user.pubkey);
authors: batch,
});
// Cache profiles // Update relay stores with the new user's relays
for (const event of events) {
if (event.content) {
try { try {
const profileData = JSON.parse(event.content); console.debug(
const npub = nip19.npubEncode(event.pubkey); "[userStore.ts] loginWithExtension: Updating relay stores for authenticated user",
npubCache.set(npub, profileData); );
} catch (e) { await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
console.warn('Failed to parse profile data:', e);
}
}
}
} catch (error) { } catch (error) {
console.warn('Failed to fetch batch of profiles:', error); console.warn(
} "[userStore.ts] loginWithExtension: Failed to update relay stores:",
error,
);
} }
console.log('User lists and profile cache update completed'); clearLogin();
} catch (error) { // Only access localStorage on client-side
console.warn('Failed to fetch user lists and update cache:', error); if (typeof window !== "undefined") {
localStorage.removeItem("alexandria/logout/flag");
} }
persistLogin(user, "extension");
} }
/** /**
* Common login logic to reduce code duplication * Login with Amber (NIP-46)
*/ */
async function performLogin( export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser, ndk: NDK) {
user: NDKUser, if (!ndk) throw new Error("NDK not initialized");
signer: NDKSigner | null, // Only clear previous login state after successful login
method: LoginMethod,
): Promise<void> {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
const npub = user.npub; const npub = user.npub;
console.log(`Login with ${method} - fetching profile for npub:`, npub);
// Fetch profile console.log("Login with Amber - fetching profile for npub:", npub);
const profile = await fetchUserProfile(npub);
console.log(`Login with ${method} - fetched profile:`, profile);
// Handle relays let profile: NostrProfile | null = null;
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user); try {
persistedInboxes.forEach(relay => ndk.addExplicitRelay(relay)); profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with Amber - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during Amber login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with Amber - using fallback profile:", profile);
}
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes); persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
// Set NDK state
ndk.signer = signer || undefined;
ndk.activeUser = user; ndk.activeUser = user;
// Create user state const userState = {
const userState: UserState = {
pubkey: user.pubkey, pubkey: user.pubkey,
npub, npub,
profile, profile,
relays: { relays: {
inbox: Array.from(inboxes || persistedInboxes).map((relay) => relay.url), inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes || persistedOutboxes).map((relay) => relay.url), outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
}, },
loginMethod: method, loginMethod: "amber" as const,
ndkUser: user, ndkUser: user,
signer, signer: amberSigner,
signedIn: true, signedIn: true,
}; };
console.log(`Login with ${method} - setting userStore with:`, userState); console.log("Login with Amber - setting userStore with:", userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores // Update relay stores with the new user's relays
try { try {
console.debug(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Updating relay stores`); console.debug(
await updateActiveRelayStores(ndk, true); "[userStore.ts] loginWithAmber: Updating relay stores for authenticated user",
);
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
} catch (error) { } catch (error) {
console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to update relay stores:`, error); console.warn(
"[userStore.ts] loginWithAmber: Failed to update relay stores:",
error,
);
} }
// Background tasks
fetchUserListsAndUpdateCache(user.pubkey).catch(error => {
console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to fetch user lists:`, error);
});
// Cleanup and persist
clearLogin(); clearLogin();
const storage = safeLocalStorage(); // Only access localStorage on client-side
if (storage) { if (typeof window !== "undefined") {
storage.removeItem(LOGOUT_FLAG_KEY); localStorage.removeItem("alexandria/logout/flag");
}
persistLogin(user, method);
} }
persistLogin(user, "amber");
/**
* Login with NIP-07 browser extension
*/
export async function loginWithExtension(): Promise<void> {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
const signer = new NDKNip07Signer();
const user = await signer.user();
await performLogin(user, signer, 'extension');
}
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser): Promise<void> {
await performLogin(user, amberSigner, 'amber');
} }
/** /**
* Login with npub (read-only) * Login with npub (read-only)
*/ */
export async function loginWithNpub(pubkeyOrNpub: string): Promise<void> { export async function loginWithNpub(pubkeyOrNpub: string, ndk: NDK) {
const ndk = get(ndkInstance); if (!ndk) {
if (!ndk) throw new Error('NDK not initialized'); throw new Error("NDK not initialized");
}
// Decode pubkey
let hexPubkey: string; let hexPubkey: string;
if (pubkeyOrNpub.startsWith('npub1')) { if (pubkeyOrNpub.startsWith("npub1")) {
try { try {
const decoded = nip19.decode(pubkeyOrNpub); const decoded = nip19.decode(pubkeyOrNpub);
if (decoded.type !== 'npub') { if (decoded.type !== "npub") {
throw new Error('Invalid npub format'); throw new Error("Invalid npub format");
} }
hexPubkey = decoded.data; hexPubkey = decoded.data;
} catch (e) { } catch (e) {
console.error('Failed to decode npub:', pubkeyOrNpub, e); console.error("Failed to decode npub:", pubkeyOrNpub, e);
throw e; throw e;
} }
} else { } else {
hexPubkey = pubkeyOrNpub; hexPubkey = pubkeyOrNpub;
} }
// Encode npub
let npub: string; let npub: string;
try { try {
npub = nip19.npubEncode(hexPubkey); npub = nip19.npubEncode(hexPubkey);
} catch (e) { } catch (e) {
console.error('Failed to encode npub from hex pubkey:', hexPubkey, e); console.error("Failed to encode npub from hex pubkey:", hexPubkey, e);
throw e; throw e;
} }
console.log('Login with npub - fetching profile for npub:', npub); console.log("Login with npub - fetching profile for npub:", npub);
const user = ndk.getUser({ npub }); const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null;
// Update relay stores first // First, update relay stores to ensure we have relays available
try { try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores'); console.debug(
"[userStore.ts] loginWithNpub: Updating relay stores for authenticated user",
);
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error); console.warn(
"[userStore.ts] loginWithNpub: Failed to update relay stores:",
error,
);
} }
// Wait for relay stores to initialize // Wait a moment for relay stores to be properly initialized
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
// Fetch profile try {
const profile = await fetchUserProfile(npub); profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with npub - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during npub login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with npub - using fallback profile:", profile);
}
// Set NDK state (no signer for read-only)
ndk.signer = undefined; ndk.signer = undefined;
ndk.activeUser = user; ndk.activeUser = user;
// Create user state const userState = {
const userState: UserState = {
pubkey: user.pubkey, pubkey: user.pubkey,
npub, npub,
profile, profile,
relays: { inbox: [], outbox: [] }, relays: { inbox: [], outbox: [] },
loginMethod: 'npub', loginMethod: "npub" as const,
ndkUser: user, ndkUser: user,
signer: null, signer: null,
signedIn: true, signedIn: true,
}; };
console.log('Login with npub - setting userStore with:', userState); console.log("Login with npub - setting userStore with:", userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Background tasks
fetchUserListsAndUpdateCache(user.pubkey).catch(error => {
console.warn('[userStore.ts] loginWithNpub: Failed to fetch user lists:', error);
});
// Cleanup and persist
clearLogin(); clearLogin();
const storage = safeLocalStorage(); // Only access localStorage on client-side
if (storage) { if (typeof window !== "undefined") {
storage.removeItem(LOGOUT_FLAG_KEY); localStorage.removeItem("alexandria/logout/flag");
} }
persistLogin(user, 'npub'); persistLogin(user, "npub");
} }
/** /**
* Logout and clear all user state * Logout and clear all user state
*/ */
export function logoutUser(): void { export function logoutUser(ndk: NDK) {
console.log('Logging out user...'); console.log("Logging out user...");
const currentUser = get(userStore); const currentUser = get(userStore);
// Clear localStorage // Only access localStorage on client-side
const storage = safeLocalStorage(); if (typeof window !== "undefined") {
if (storage) {
if (currentUser.ndkUser) { if (currentUser.ndkUser) {
// Clear persisted relays // Clear persisted relays for the user
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox')); localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox')); localStorage.removeItem(
getRelayStorageKey(currentUser.ndkUser, "outbox"),
);
} }
// Clear login data // Clear all possible login states from localStorage
clearLogin(); clearLogin();
// Clear any other potential login keys // Also clear any other potential login keys that might exist
const keysToRemove: string[] = []; const keysToRemove = [];
for (let i = 0; i < storage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = storage.key(i); const key = localStorage.key(i);
if (key && ( if (
key.includes('login') || key &&
key.includes('nostr') || (key.includes("login") ||
key.includes('user') || key.includes("nostr") ||
key.includes('alexandria') || key.includes("user") ||
key === 'pubkey' key.includes("alexandria") ||
)) { key === "pubkey")
) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
// Clear specific keys // Specifically target the login storage key
keysToRemove.push('alexandria/login/pubkey', 'alexandria/login/method'); keysToRemove.push("alexandria/login/pubkey");
keysToRemove.forEach(key => { keysToRemove.push("alexandria/login/method");
console.log('Removing localStorage key:', key);
storage.removeItem(key); keysToRemove.forEach((key) => {
console.log("Removing localStorage key:", key);
localStorage.removeItem(key);
}); });
// Clear Amber-specific flags // Clear Amber-specific flags
storage.removeItem('alexandria/amber/fallback'); localStorage.removeItem("alexandria/amber/fallback");
// Set logout flag // Set a flag to prevent auto-login on next page load
storage.setItem(LOGOUT_FLAG_KEY, 'true'); localStorage.setItem("alexandria/logout/flag", "true");
console.log('Cleared all login data from localStorage'); console.log("Cleared all login data from localStorage");
} }
// Clear cache userStore.set({
relayStorageKeyCache.clear(); pubkey: null,
npub: null,
// Reset user store profile: null,
userStore.set(initialUserState); relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
userPubkey.set(null);
// Clear NDK state
const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
ndk.activeUser = undefined; ndk.activeUser = undefined;
ndk.signer = undefined; ndk.signer = undefined;
} }
console.log('Logout complete'); console.log("Logout complete");
}
/**
* Reset user store to initial state
*/
export function resetUserStore(): void {
userStore.set(initialUserState);
relayStorageKeyCache.clear();
}
/**
* Get current user state
*/
export function getCurrentUser(): UserState {
return get(userStore);
}
/**
* Check if user is signed in
*/
export function isUserSignedIn(): boolean {
return get(userStore).signedIn;
} }

27
src/lib/stores/visualizationConfig.ts

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

14
src/lib/utils.ts

@ -54,7 +54,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
* @param relays Optional relay list for the address * @param relays Optional relay list for the address
* @returns A tag address string * @returns A tag address string
*/ */
export function createTagAddress(event: NostrEvent, relays: string[] = []): string { export function createTagAddress(
event: NostrEvent,
relays: string[] = [],
): string {
const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1]; const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1];
if (!dTag) { if (!dTag) {
throw new Error("Event does not have a d tag"); throw new Error("Event does not have a d tag");
@ -144,8 +147,7 @@ export function next(): number {
export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) { export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) {
function scrollTab() { function scrollTab() {
const element = const element = typeof el === "string"
typeof el === "string"
? document.querySelector(`[id^="wikitab-v0-${el}"]`) ? document.querySelector(`[id^="wikitab-v0-${el}"]`)
: el; : el;
if (!element) return; if (!element) return;
@ -166,8 +168,7 @@ export function scrollTabIntoView(el: string | HTMLElement, wait: boolean) {
} }
export function isElementInViewport(el: string | HTMLElement) { export function isElementInViewport(el: string | HTMLElement) {
const element = const element = typeof el === "string"
typeof el === "string"
? document.querySelector(`[id^="wikitab-v0-${el}"]`) ? document.querySelector(`[id^="wikitab-v0-${el}"]`)
: el; : el;
if (!element) return; if (!element) return;
@ -179,7 +180,8 @@ export function isElementInViewport(el: string | HTMLElement) {
rect.left >= 0 && rect.left >= 0 &&
rect.bottom <= rect.bottom <=
(globalThis.innerHeight || document.documentElement.clientHeight) && (globalThis.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (globalThis.innerWidth || document.documentElement.clientWidth) rect.right <=
(globalThis.innerWidth || document.documentElement.clientWidth)
); );
} }

187
src/lib/utils/asciidoc_metadata.ts

@ -23,7 +23,7 @@ export interface AsciiDocMetadata {
source?: string; source?: string;
publishedBy?: string; publishedBy?: string;
type?: string; type?: string;
autoUpdate?: 'yes' | 'ask' | 'no'; autoUpdate?: "yes" | "ask" | "no";
} }
export type SectionMetadata = AsciiDocMetadata; export type SectionMetadata = AsciiDocMetadata;
@ -41,29 +41,29 @@ export interface ParsedAsciiDoc {
// Shared attribute mapping based on Asciidoctor standard attributes // Shared attribute mapping based on Asciidoctor standard attributes
const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = { const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = {
// Standard Asciidoctor attributes // Standard Asciidoctor attributes
'author': 'authors', "author": "authors",
'description': 'summary', "description": "summary",
'keywords': 'tags', "keywords": "tags",
'revnumber': 'version', "revnumber": "version",
'revdate': 'publicationDate', "revdate": "publicationDate",
'revremark': 'edition', "revremark": "edition",
'title': 'title', "title": "title",
// Custom attributes for Alexandria // Custom attributes for Alexandria
'published_by': 'publishedBy', "published_by": "publishedBy",
'publisher': 'publisher', "publisher": "publisher",
'summary': 'summary', "summary": "summary",
'image': 'coverImage', "image": "coverImage",
'cover': 'coverImage', "cover": "coverImage",
'isbn': 'isbn', "isbn": "isbn",
'source': 'source', "source": "source",
'type': 'type', "type": "type",
'auto-update': 'autoUpdate', "auto-update": "autoUpdate",
'version': 'version', "version": "version",
'edition': 'edition', "edition": "edition",
'published_on': 'publicationDate', "published_on": "publicationDate",
'date': 'publicationDate', "date": "publicationDate",
'version-label': 'version', "version-label": "version",
}; };
/** /**
@ -78,15 +78,15 @@ function createProcessor() {
*/ */
function extractTagsFromAttributes(attributes: Record<string, any>): string[] { function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
const tags: string[] = []; const tags: string[] = [];
const attrTags = attributes['tags']; const attrTags = attributes["tags"];
const attrKeywords = attributes['keywords']; const attrKeywords = attributes["keywords"];
if (attrTags && typeof attrTags === 'string') { if (attrTags && typeof attrTags === "string") {
tags.push(...attrTags.split(',').map(tag => tag.trim())); tags.push(...attrTags.split(",").map((tag) => tag.trim()));
} }
if (attrKeywords && typeof attrKeywords === 'string') { if (attrKeywords && typeof attrKeywords === "string") {
tags.push(...attrKeywords.split(',').map(tag => tag.trim())); tags.push(...attrKeywords.split(",").map((tag) => tag.trim()));
} }
return [...new Set(tags)]; // Remove duplicates return [...new Set(tags)]; // Remove duplicates
@ -95,20 +95,24 @@ function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
/** /**
* Maps attributes to metadata with special handling for authors and tags * Maps attributes to metadata with special handling for authors and tags
*/ */
function mapAttributesToMetadata(attributes: Record<string, any>, metadata: AsciiDocMetadata, isDocument: boolean = false): void { function mapAttributesToMetadata(
attributes: Record<string, any>,
metadata: AsciiDocMetadata,
isDocument: boolean = false,
): void {
for (const [key, value] of Object.entries(attributes)) { for (const [key, value] of Object.entries(attributes)) {
const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()]; const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()];
if (metadataKey && value && typeof value === 'string') { if (metadataKey && value && typeof value === "string") {
if (metadataKey === 'authors' && isDocument) { if (metadataKey === "authors" && isDocument) {
// Skip author mapping for documents since it's handled manually // Skip author mapping for documents since it's handled manually
continue; continue;
} else if (metadataKey === 'authors' && !isDocument) { } else if (metadataKey === "authors" && !isDocument) {
// For sections, append author to existing authors array // For sections, append author to existing authors array
if (!metadata.authors) { if (!metadata.authors) {
metadata.authors = []; metadata.authors = [];
} }
metadata.authors.push(value); metadata.authors.push(value);
} else if (metadataKey === 'tags') { } else if (metadataKey === "tags") {
// Skip tags mapping since it's handled by extractTagsFromAttributes // Skip tags mapping since it's handled by extractTagsFromAttributes
continue; continue;
} else { } else {
@ -121,7 +125,10 @@ function mapAttributesToMetadata(attributes: Record<string, any>, metadata: Asci
/** /**
* Extracts authors from header line (document or section) * Extracts authors from header line (document or section)
*/ */
function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = false): string[] { function extractAuthorsFromHeader(
sourceContent: string,
isSection: boolean = false,
): string[] {
const authors: string[] = []; const authors: string[] = [];
const lines = sourceContent.split(/\r?\n/); const lines = sourceContent.split(/\r?\n/);
const headerPattern = isSection ? /^==\s+/ : /^=\s+/; const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
@ -135,20 +142,23 @@ function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = fa
const authorLine = lines[j]; const authorLine = lines[j];
// Stop if we hit a blank line or content that's not an author // Stop if we hit a blank line or content that's not an author
if (authorLine.trim() === '') { if (authorLine.trim() === "") {
break; break;
} }
if (authorLine.includes('<') && !authorLine.startsWith(':')) { if (authorLine.includes("<") && !authorLine.startsWith(":")) {
// This is an author line like "John Doe <john@example.com>" // This is an author line like "John Doe <john@example.com>"
const authorName = authorLine.split('<')[0].trim(); const authorName = authorLine.split("<")[0].trim();
if (authorName) { if (authorName) {
authors.push(authorName); authors.push(authorName);
} }
} else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' && authorLine.trim().split(/\s+/).length <= 2) { } else if (
isSection && authorLine.match(/^[A-Za-z\s]+$/) &&
authorLine.trim() !== "" && authorLine.trim().split(/\s+/).length <= 2
) {
// This is a simple author name without email (for sections) // This is a simple author name without email (for sections)
authors.push(authorLine.trim()); authors.push(authorLine.trim());
} else if (authorLine.startsWith(':')) { } else if (authorLine.startsWith(":")) {
// This is an attribute line, skip it - attributes are handled by mapAttributesToMetadata // This is an attribute line, skip it - attributes are handled by mapAttributesToMetadata
// Don't break here, continue to next line // Don't break here, continue to next line
} else { } else {
@ -168,7 +178,10 @@ function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = fa
/** /**
* Strips header and attribute lines from content * Strips header and attribute lines from content
*/ */
function stripHeaderAndAttributes(content: string, isSection: boolean = false): string { function stripHeaderAndAttributes(
content: string,
isSection: boolean = false,
): string {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let contentStart = 0; let contentStart = 0;
const headerPattern = isSection ? /^==\s+/ : /^=\s+/; const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
@ -176,8 +189,11 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false):
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip title line, author line, revision line, and attribute lines // Skip title line, author line, revision line, and attribute lines
if (!line.match(headerPattern) && !line.includes('<') && !line.match(/^.+,\s*.+:\s*.+$/) && if (
!line.match(/^:[^:]+:\s*.+$/) && line.trim() !== '') { !line.match(headerPattern) && !line.includes("<") &&
!line.match(/^.+,\s*.+:\s*.+$/) &&
!line.match(/^:[^:]+:\s*.+$/) && line.trim() !== ""
) {
contentStart = i; contentStart = i;
break; break;
} }
@ -185,20 +201,26 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false):
// Filter out all attribute lines and author lines from the content // Filter out all attribute lines and author lines from the content
const contentLines = lines.slice(contentStart); const contentLines = lines.slice(contentStart);
const filteredLines = contentLines.filter(line => { const filteredLines = contentLines.filter((line) => {
// Skip attribute lines // Skip attribute lines
if (line.match(/^:[^:]+:\s*.+$/)) { if (line.match(/^:[^:]+:\s*.+$/)) {
return false; return false;
} }
// Skip author lines (simple names without email) // Skip author lines (simple names without email)
if (isSection && line.match(/^[A-Za-z\s]+$/) && line.trim() !== '' && line.trim().split(/\s+/).length <= 2) { if (
isSection && line.match(/^[A-Za-z\s]+$/) && line.trim() !== "" &&
line.trim().split(/\s+/).length <= 2
) {
return false; return false;
} }
return true; return true;
}); });
// Remove extra blank lines and normalize newlines // Remove extra blank lines and normalize newlines
return filteredLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').replace(/\n\s*\n/g, '\n').trim(); return filteredLines.join("\n").replace(/\n\s*\n\s*\n/g, "\n\n").replace(
/\n\s*\n/g,
"\n",
).trim();
} }
/** /**
@ -219,10 +241,6 @@ function parseSectionAttributes(sectionContent: string): Record<string, any> {
return attributes; return attributes;
} }
/** /**
* Extracts metadata from AsciiDoc document using Asciidoctor * Extracts metadata from AsciiDoc document using Asciidoctor
*/ */
@ -231,7 +249,9 @@ export function extractDocumentMetadata(inputContent: string): {
content: string; content: string;
} { } {
const asciidoctor = createProcessor(); const asciidoctor = createProcessor();
const document = asciidoctor.load(inputContent, { standalone: false }) as Document; const document = asciidoctor.load(inputContent, {
standalone: false,
}) as Document;
const metadata: AsciiDocMetadata = {}; const metadata: AsciiDocMetadata = {};
const attributes = document.getAttributes(); const attributes = document.getAttributes();
@ -244,8 +264,11 @@ export function extractDocumentMetadata(inputContent: string): {
const authors = extractAuthorsFromHeader(document.getSource()); const authors = extractAuthorsFromHeader(document.getSource());
// Get authors from attributes (but avoid duplicates) // Get authors from attributes (but avoid duplicates)
const attrAuthor = attributes['author']; const attrAuthor = attributes["author"];
if (attrAuthor && typeof attrAuthor === 'string' && !authors.includes(attrAuthor)) { if (
attrAuthor && typeof attrAuthor === "string" &&
!authors.includes(attrAuthor)
) {
authors.push(attrAuthor); authors.push(attrAuthor);
} }
@ -295,15 +318,17 @@ export function extractSectionMetadata(inputSectionContent: string): {
title: string; title: string;
} { } {
const asciidoctor = createProcessor(); const asciidoctor = createProcessor();
const document = asciidoctor.load(`= Temp\n\n${inputSectionContent}`, { standalone: false }) as Document; const document = asciidoctor.load(`= Temp\n\n${inputSectionContent}`, {
standalone: false,
}) as Document;
const sections = document.getSections(); const sections = document.getSections();
if (sections.length === 0) { if (sections.length === 0) {
return { metadata: {}, content: inputSectionContent, title: '' }; return { metadata: {}, content: inputSectionContent, title: "" };
} }
const section = sections[0]; const section = sections[0];
const title = section.getTitle() || ''; const title = section.getTitle() || "";
const metadata: SectionMetadata = { title }; const metadata: SectionMetadata = { title };
// Parse attributes from the section content // Parse attributes from the section content
@ -350,7 +375,7 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
if (line.match(/^==\s+/)) { if (line.match(/^==\s+/)) {
// Save previous section if exists // Save previous section if exists
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
} }
@ -364,39 +389,43 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
// Save the last section // Save the last section
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); sectionsWithMetadata.push(extractSectionMetadata(sectionContent));
} }
return { return {
metadata: docMetadata, metadata: docMetadata,
content: document.getSource(), content: document.getSource(),
sections: sectionsWithMetadata sections: sectionsWithMetadata,
}; };
} }
/** /**
* Converts metadata to Nostr event tags * Converts metadata to Nostr event tags
*/ */
export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [string, string][] { export function metadataToTags(
metadata: AsciiDocMetadata | SectionMetadata,
): [string, string][] {
const tags: [string, string][] = []; const tags: [string, string][] = [];
if (metadata.title) tags.push(['title', metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
if (metadata.authors?.length) { if (metadata.authors?.length) {
metadata.authors.forEach(author => tags.push(['author', author])); metadata.authors.forEach((author) => tags.push(["author", author]));
} }
if (metadata.version) tags.push(['version', metadata.version]); if (metadata.version) tags.push(["version", metadata.version]);
if (metadata.edition) tags.push(['edition', metadata.edition]); if (metadata.edition) tags.push(["edition", metadata.edition]);
if (metadata.publicationDate) tags.push(['published_on', metadata.publicationDate]); if (metadata.publicationDate) {
if (metadata.publishedBy) tags.push(['published_by', metadata.publishedBy]); tags.push(["published_on", metadata.publicationDate]);
if (metadata.summary) tags.push(['summary', metadata.summary]); }
if (metadata.coverImage) tags.push(['image', metadata.coverImage]); if (metadata.publishedBy) tags.push(["published_by", metadata.publishedBy]);
if (metadata.isbn) tags.push(['i', metadata.isbn]); if (metadata.summary) tags.push(["summary", metadata.summary]);
if (metadata.source) tags.push(['source', metadata.source]); if (metadata.coverImage) tags.push(["image", metadata.coverImage]);
if (metadata.type) tags.push(['type', metadata.type]); if (metadata.isbn) tags.push(["i", metadata.isbn]);
if (metadata.autoUpdate) tags.push(['auto-update', metadata.autoUpdate]); if (metadata.source) tags.push(["source", metadata.source]);
if (metadata.type) tags.push(["type", metadata.type]);
if (metadata.autoUpdate) tags.push(["auto-update", metadata.autoUpdate]);
if (metadata.tags?.length) { if (metadata.tags?.length) {
metadata.tags.forEach(tag => tags.push(['t', tag])); metadata.tags.forEach((tag) => tags.push(["t", tag]));
} }
return tags; return tags;
@ -433,7 +462,7 @@ export function extractMetadataFromSectionsOnly(content: string): {
if (line.match(/^==\s+/)) { if (line.match(/^==\s+/)) {
// Save previous section if exists // Save previous section if exists
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sections.push(extractSectionMetadata(sectionContent)); sections.push(extractSectionMetadata(sectionContent));
} }
@ -447,7 +476,7 @@ export function extractMetadataFromSectionsOnly(content: string): {
// Save the last section // Save the last section
if (currentSection) { if (currentSection) {
const sectionContent = currentSectionContent.join('\n'); const sectionContent = currentSectionContent.join("\n");
sections.push(extractSectionMetadata(sectionContent)); sections.push(extractSectionMetadata(sectionContent));
} }
@ -474,9 +503,9 @@ export function extractSmartMetadata(content: string): {
if (hasDocumentHeader) { if (hasDocumentHeader) {
// Check if it's a minimal document header (just title, no other metadata) // Check if it's a minimal document header (just title, no other metadata)
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const titleLine = lines.find(line => line.match(/^=\s+/)); const titleLine = lines.find((line) => line.match(/^=\s+/));
const hasOtherMetadata = lines.some(line => const hasOtherMetadata = lines.some((line) =>
line.includes('<') || // author line line.includes("<") || // author line
line.match(/^.+,\s*.+:\s*.+$/) // revision line line.match(/^.+,\s*.+:\s*.+$/) // revision line
); );
@ -485,7 +514,7 @@ export function extractSmartMetadata(content: string): {
return extractDocumentMetadata(content); return extractDocumentMetadata(content);
} else { } else {
// Minimal document header (just title) - preserve the title line for 30040 events // Minimal document header (just title) - preserve the title line for 30040 events
const title = titleLine?.replace(/^=\s+/, '').trim(); const title = titleLine?.replace(/^=\s+/, "").trim();
const metadata: AsciiDocMetadata = {}; const metadata: AsciiDocMetadata = {};
if (title) { if (title) {
metadata.title = title; metadata.title = title;

33
src/lib/utils/displayLimits.ts

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

97
src/lib/utils/eventColors.ts

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

94
src/lib/utils/eventDeduplication.ts

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

53
src/lib/utils/event_input_utils.ts

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

55
src/lib/utils/event_kind_utils.ts

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

74
src/lib/utils/event_search.ts

@ -1,18 +1,16 @@
import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts"; import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts"; import type { Filter } from "./search_types.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts"; import { isValidNip05Address, wellKnownUrl } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
/** /**
* Search for a single event by ID or filter * Search for a single event by ID or filter
*/ */
export async function searchEvent(query: string): Promise<NDKEvent | null> { export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | null> {
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.warn("[Search] No NDK instance available"); console.warn("[Search] No NDK instance available");
return null; return null;
@ -34,19 +32,27 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
if (inboxRelays.length > 0 || outboxRelays.length > 0) { if (inboxRelays.length > 0 || outboxRelays.length > 0) {
console.log(`[Search] Found active relays - inbox: ${inboxRelays.length}, outbox: ${outboxRelays.length}`); console.log(
`[Search] Found active relays - inbox: ${inboxRelays.length}, outbox: ${outboxRelays.length}`,
);
break; break;
} }
console.log(`[Search] Waiting for relays to be available (attempt ${attempts + 1}/${maxAttempts})`); console.log(
await new Promise(resolve => setTimeout(resolve, 500)); `[Search] Waiting for relays to be available (attempt ${
attempts + 1
}/${maxAttempts})`,
);
await new Promise((resolve) => setTimeout(resolve, 500));
attempts++; attempts++;
} }
// AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let fetchEventWithFallback handle fallbacks // AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let fetchEventWithFallback handle fallbacks
// The fetchEventWithFallback function will use all available relays including fallback relays // The fetchEventWithFallback function will use all available relays including fallback relays
if (ndk.pool.relays.size === 0) { if (ndk.pool.relays.size === 0) {
console.warn("[Search] No relays in pool, but proceeding with search - fallback relays will be used"); console.warn(
"[Search] No relays in pool, but proceeding with search - fallback relays will be used",
);
} }
// Clean the query and normalize to lowercase // Clean the query and normalize to lowercase
@ -60,14 +66,14 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Try as event id // Try as event id
filterOrId = cleanedQuery; filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback( const eventResult = await fetchEventWithFallback(
get(ndkInstance), ndk,
filterOrId, filterOrId,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
// Always try as pubkey (profile event) as well // Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback( const profileEvent = await fetchEventWithFallback(
get(ndkInstance), ndk,
profileFilter, profileFilter,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -93,7 +99,7 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
console.log(`[Search] Decoded identifier:`, { console.log(`[Search] Decoded identifier:`, {
type: decoded.type, type: decoded.type,
data: decoded.data, data: decoded.data,
query: cleanedQuery query: cleanedQuery,
}); });
switch (decoded.type) { switch (decoded.type) {
@ -101,35 +107,55 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
console.log(`[Search] Processing nevent:`, { console.log(`[Search] Processing nevent:`, {
id: decoded.data.id, id: decoded.data.id,
kind: decoded.data.kind, kind: decoded.data.kind,
relays: decoded.data.relays relays: decoded.data.relays,
}); });
// Use the relays from the nevent if available // Use the relays from the nevent if available
if (decoded.data.relays && decoded.data.relays.length > 0) { if (decoded.data.relays && decoded.data.relays.length > 0) {
console.log(`[Search] Using relays from nevent:`, decoded.data.relays); console.log(
`[Search] Using relays from nevent:`,
decoded.data.relays,
);
// Try to fetch the event using the nevent's relays // Try to fetch the event using the nevent's relays
try { try {
// Create a temporary relay set for this search // Create a temporary relay set for this search
const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(decoded.data.relays, ndk); const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(
decoded.data.relays,
ndk,
);
if (neventRelaySet.relays.size > 0) { if (neventRelaySet.relays.size > 0) {
console.log(`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`); console.log(
`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`,
);
// Try to fetch the event using the nevent's relays // Try to fetch the event using the nevent's relays
const event = await ndk const event = await ndk
.fetchEvent({ ids: [decoded.data.id] }, undefined, neventRelaySet) .fetchEvent(
{ ids: [decoded.data.id] },
undefined,
neventRelaySet,
)
.withTimeout(TIMEOUTS.EVENT_FETCH); .withTimeout(TIMEOUTS.EVENT_FETCH);
if (event) { if (event) {
console.log(`[Search] Found event using nevent relays:`, event.id); console.log(
`[Search] Found event using nevent relays:`,
event.id,
);
return event; return event;
} else { } else {
console.log(`[Search] Event not found on nevent relays, trying default relays`); console.log(
`[Search] Event not found on nevent relays, trying default relays`,
);
} }
} }
} catch (error) { } catch (error) {
console.warn(`[Search] Error fetching from nevent relays:`, error); console.warn(
`[Search] Error fetching from nevent relays:`,
error,
);
} }
} }
@ -168,7 +194,7 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
try { try {
const event = await fetchEventWithFallback( const event = await fetchEventWithFallback(
get(ndkInstance), ndk,
filterOrId, filterOrId,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -190,6 +216,7 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
*/ */
export async function searchNip05( export async function searchNip05(
nip05Address: string, nip05Address: string,
ndk: NDK,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain // NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) { if (!isValidNip05Address(nip05Address)) {
@ -227,7 +254,7 @@ export async function searchNip05(
if (pubkey) { if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] }; const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback( const profileEvent = await fetchEventWithFallback(
get(ndkInstance), ndk,
profileFilter, profileFilter,
TIMEOUTS.EVENT_FETCH, TIMEOUTS.EVENT_FETCH,
); );
@ -258,6 +285,7 @@ export async function searchNip05(
*/ */
export async function findContainingIndexEvents( export async function findContainingIndexEvents(
contentEvent: NDKEvent, contentEvent: NDKEvent,
ndk: NDK,
): Promise<NDKEvent[]> { ): Promise<NDKEvent[]> {
// Support all content event kinds that can be contained in indexes // Support all content event kinds that can be contained in indexes
const contentEventKinds = [30041, 30818, 30040, 30023]; const contentEventKinds = [30041, 30818, 30040, 30023];
@ -266,8 +294,6 @@ export async function findContainingIndexEvents(
} }
try { try {
const ndk = get(ndkInstance);
// Search for 30040 events that reference this content event // Search for 30040 events that reference this content event
// We need to search for events that have an 'a' tag or 'e' tag referencing this event // We need to search for events that have an 'a' tag or 'e' tag referencing this event
const contentEventId = contentEvent.id; const contentEventId = contentEvent.id;

4
src/lib/utils/image_utils.ts

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

78
src/lib/utils/kind24_utils.ts

@ -1,12 +1,7 @@
import { get } from "svelte/store"; import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "../ndk";
import { userStore } from "../stores/userStore";
import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { createSignedEvent } from "./nostrEventService.ts"; import { createSignedEvent } from "./nostrEventService.ts";
import { anonymousRelays } from "../consts"; import { anonymousRelays } from "../consts.ts";
import { buildCompleteRelaySet } from "./relay_management"; import { buildCompleteRelaySet } from "./relay_management.ts";
// AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality // AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality
@ -18,23 +13,21 @@ import { buildCompleteRelaySet } from "./relay_management";
*/ */
export async function getKind24RelaySet( export async function getKind24RelaySet(
senderPubkey: string, senderPubkey: string,
recipientPubkey: string recipientPubkey: string,
ndk: NDK,
): Promise<string[]> { ): Promise<string[]> {
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK not available");
}
const senderPrefix = senderPubkey.slice(0, 8); const senderPrefix = senderPubkey.slice(0, 8);
const recipientPrefix = recipientPubkey.slice(0, 8); const recipientPrefix = recipientPubkey.slice(0, 8);
console.log(`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`); console.log(
`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`,
);
try { try {
// Fetch both users' complete relay sets using existing utilities // Fetch both users' complete relay sets using existing utilities
const [senderRelaySet, recipientRelaySet] = await Promise.all([ const [senderRelaySet, recipientRelaySet] = await Promise.all([
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })), buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })),
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey })) buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey })),
]); ]);
// Use sender's outbox relays and recipient's inbox relays // Use sender's outbox relays and recipient's inbox relays
@ -42,24 +35,33 @@ export async function getKind24RelaySet(
const recipientInboxRelays = recipientRelaySet.inboxRelays; const recipientInboxRelays = recipientRelaySet.inboxRelays;
// Prioritize common relays for better privacy // Prioritize common relays for better privacy
const commonRelays = senderOutboxRelays.filter(relay => const commonRelays = senderOutboxRelays.filter((relay: any) =>
recipientInboxRelays.includes(relay) recipientInboxRelays.includes(relay)
); );
const senderOnlyRelays = senderOutboxRelays.filter(relay => const senderOnlyRelays = senderOutboxRelays.filter((relay: any) =>
!recipientInboxRelays.includes(relay) !recipientInboxRelays.includes(relay)
); );
const recipientOnlyRelays = recipientInboxRelays.filter(relay => const recipientOnlyRelays = recipientInboxRelays.filter((relay: any) =>
!senderOutboxRelays.includes(relay) !senderOutboxRelays.includes(relay)
); );
// Prioritize: common relays first, then sender outbox, then recipient inbox // Prioritize: common relays first, then sender outbox, then recipient inbox
const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; const finalRelays = [
...commonRelays,
...senderOnlyRelays,
...recipientOnlyRelays,
];
console.log(`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`); console.log(
`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`,
);
return finalRelays; return finalRelays;
} catch (error) { } catch (error) {
console.error(`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`, error); console.error(
`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`,
error,
);
throw error; throw error;
} }
} }
@ -74,9 +76,11 @@ export async function getKind24RelaySet(
export async function createKind24Reply( export async function createKind24Reply(
content: string, content: string,
recipientPubkey: string, recipientPubkey: string,
originalEvent?: NDKEvent ndk: NDK,
): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> { originalEvent?: NDKEvent,
const ndk = get(ndkInstance); ): Promise<
{ success: boolean; eventId?: string; error?: string; relays?: string[] }
> {
if (!ndk?.activeUser) { if (!ndk?.activeUser) {
return { success: false, error: "Not logged in" }; return { success: false, error: "Not logged in" };
} }
@ -87,7 +91,11 @@ export async function createKind24Reply(
try { try {
// Get optimal relay set for this sender-recipient pair // Get optimal relay set for this sender-recipient pair
const targetRelays = await getKind24RelaySet(ndk.activeUser.pubkey, recipientPubkey); const targetRelays = await getKind24RelaySet(
ndk.activeUser.pubkey,
recipientPubkey,
ndk,
);
if (targetRelays.length === 0) { if (targetRelays.length === 0) {
return { success: false, error: "No relays available for publishing" }; return { success: false, error: "No relays available for publishing" };
@ -95,7 +103,7 @@ export async function createKind24Reply(
// Build tags for the kind 24 event // Build tags for the kind 24 event
const tags: string[][] = [ const tags: string[][] = [
["p", recipientPubkey, targetRelays[0]] // Use first relay as primary ["p", recipientPubkey, targetRelays[0]], // Use first relay as primary
]; ];
// Add q tag if replying to an original event // Add q tag if replying to an original event
@ -108,7 +116,7 @@ export async function createKind24Reply(
content, content,
ndk.activeUser.pubkey, ndk.activeUser.pubkey,
24, 24,
tags tags,
); );
// Create NDKEvent and publish // Create NDKEvent and publish
@ -117,19 +125,23 @@ export async function createKind24Reply(
const publishedToRelays = await event.publish(relaySet); const publishedToRelays = await event.publish(relaySet);
if (publishedToRelays.size > 0) { if (publishedToRelays.size > 0) {
console.log(`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`); console.log(
`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`,
);
return { success: true, eventId: event.id, relays: targetRelays }; return { success: true, eventId: event.id, relays: targetRelays };
} else { } else {
console.warn(`[createKind24Reply] Failed to publish to any relays`); console.warn(`[createKind24Reply] Failed to publish to any relays`);
return { success: false, error: "Failed to publish to any relays", relays: targetRelays }; return {
success: false,
error: "Failed to publish to any relays",
relays: targetRelays,
};
} }
} catch (error) { } catch (error) {
console.error("[createKind24Reply] Error creating kind 24 reply:", error); console.error("[createKind24Reply] Error creating kind 24 reply:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown error" error: error instanceof Error ? error.message : "Unknown error",
}; };
} }
} }

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

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

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

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

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

@ -10,8 +10,9 @@ hljs.configure({
// Escapes HTML characters for safe display // Escapes HTML characters for safe display
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const div = const div = typeof document !== "undefined"
typeof document !== "undefined" ? document.createElement("div") : null; ? document.createElement("div")
: null;
if (div) { if (div) {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
@ -100,8 +101,8 @@ function processTables(content: string): string {
}; };
// Check if second row is a delimiter row (only hyphens) // Check if second row is a delimiter row (only hyphens)
const hasHeader = const hasHeader = rows.length > 1 &&
rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows // Extract header and body rows
let headerCells: string[] = []; let headerCells: string[] = [];
@ -124,7 +125,8 @@ function processTables(content: string): string {
if (hasHeader) { if (hasHeader) {
html += "<thead>\n<tr>\n"; html += "<thead>\n<tr>\n";
headerCells.forEach((cell) => { headerCells.forEach((cell) => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`; html +=
`<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
}); });
html += "</tr>\n</thead>\n"; html += "</tr>\n</thead>\n";
} }
@ -135,7 +137,8 @@ function processTables(content: string): string {
const cells = processCells(row); const cells = processCells(row);
html += "<tr>\n"; html += "<tr>\n";
cells.forEach((cell) => { cells.forEach((cell) => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`; html +=
`<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
}); });
html += "</tr>\n"; html += "</tr>\n";
}); });
@ -197,7 +200,9 @@ function processFootnotes(content: string): string {
if (!referenceMap.has(id)) referenceMap.set(id, []); if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum); referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id }); referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`; return `<sup><a href="#fn-${id}" id="fnref-${id}-${
referenceMap.get(id)!.length
}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
}, },
); );
@ -216,12 +221,15 @@ function processFootnotes(content: string): string {
const backrefs = refs const backrefs = refs
.map( .map(
(num, i) => (num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`, `<a href=\"#fnref-${id}-${
i + 1
}\" class=\"text-primary-600 hover:underline footnote-backref\">${num}</a>`,
) )
.join(" "); .join(" ");
// If label is not a number, show it after all backrefs // If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : ""; const labelSuffix = isNaN(Number(label)) ? ` ${label}` : "";
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`; processedContent +=
`<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
} }
processedContent += "</ol>"; processedContent += "</ol>";
} }
@ -233,8 +241,6 @@ function processFootnotes(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
*/ */
@ -357,13 +363,17 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
language, language,
ignoreIllegals: true, ignoreIllegals: true,
}).value; }).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`; html =
`<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) { } catch (e: unknown) {
console.warn("Failed to highlight code block:", e); console.warn("Failed to highlight code block:", e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ""}">${code}</code></pre>`; html = `<pre class="code-block"><code class="hljs ${
language ? `language-${language}` : ""
}">${code}</code></pre>`;
} }
} else { } else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`; html =
`<pre class="code-block"><code class="hljs">${code}</code></pre>`;
} }
result = result.replace(id, html); result = result.replace(id, html);
@ -672,8 +682,6 @@ function isLaTeXContent(content: string): boolean {
return latexPatterns.some((pattern) => pattern.test(trimmed)); return latexPatterns.some((pattern) => pattern.test(trimmed));
} }
/** /**
* Parse markup text with advanced formatting * Parse markup text with advanced formatting
*/ */
@ -711,6 +719,8 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error("Error in parseAdvancedmarkup:", e); console.error("Error in parseAdvancedmarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`; return `<div class="text-red-500">Error processing markup: ${
(e as Error)?.message ?? "Unknown error"
}</div>`;
} }
} }

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

@ -1,6 +1,9 @@
import { processImageWithReveal, processNostrIdentifiersInText, processWikilinks, processAsciiDocAnchors } from "./markupServices"; import {
processAsciiDocAnchors,
processImageWithReveal,
processNostrIdentifiersInText,
processWikilinks,
} from "./markupServices.ts";
/** /**
* Processes nostr addresses in HTML content, but skips addresses that are * Processes nostr addresses in HTML content, but skips addresses that are
@ -41,8 +44,7 @@ async function processNostrAddresses(html: string): Promise<string> {
const processedMatch = await processNostrIdentifiersInText(fullMatch); const processedMatch = await processNostrIdentifiersInText(fullMatch);
// Replace the match in the HTML // Replace the match in the HTML
processedHtml = processedHtml = processedHtml.slice(0, matchIndex) +
processedHtml.slice(0, matchIndex) +
processedMatch + processedMatch +
processedHtml.slice(matchIndex + fullMatch.length); processedHtml.slice(matchIndex + fullMatch.length);
} }
@ -61,10 +63,10 @@ function processImageBlocks(html: string): string {
// Extract src and alt from img attributes // Extract src and alt from img attributes
const srcMatch = imgAttributes.match(/src="([^"]+)"/); const srcMatch = imgAttributes.match(/src="([^"]+)"/);
const altMatch = imgAttributes.match(/alt="([^"]*)"/); const altMatch = imgAttributes.match(/alt="([^"]*)"/);
const src = srcMatch ? srcMatch[1] : ''; const src = srcMatch ? srcMatch[1] : "";
const alt = altMatch ? altMatch[1] : ''; const alt = altMatch ? altMatch[1] : "";
const titleHtml = title ? `<div class="title">${title}</div>` : ''; const titleHtml = title ? `<div class="title">${title}</div>` : "";
return `<div class="imageblock"> return `<div class="imageblock">
<div class="content"> <div class="content">
@ -72,7 +74,7 @@ function processImageBlocks(html: string): string {
</div> </div>
${titleHtml} ${titleHtml}
</div>`; </div>`;
} },
); );
} }

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

@ -1,17 +1,16 @@
import * as emoji from "node-emoji";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { import {
processBasicTextFormatting,
processBlockquotes,
processEmojiShortcodes,
processHashtags,
processImageWithReveal, processImageWithReveal,
processMediaUrl, processMediaUrl,
processNostrIdentifiersInText, processNostrIdentifiersInText,
processEmojiShortcodes,
processWebSocketUrls, processWebSocketUrls,
processHashtags,
processBasicTextFormatting,
processBlockquotes,
processWikilinks, processWikilinks,
stripTrackingParams stripTrackingParams,
} from "./markupServices"; } from "./markupServices.ts";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
@ -21,8 +20,6 @@ const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax // AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g; const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g;
// 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
@ -82,12 +79,6 @@ function replaceAlexandriaNostrLinks(text: string): string {
return text; return text;
} }
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList( function parseList(
start: number, start: number,
@ -96,7 +87,9 @@ function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
): [string, number] { ): [string, number] {
let html = ""; let html = "";
let i = start; let i = start;
html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`; html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
@ -168,7 +161,9 @@ function processBasicFormatting(content: string): string {
processedText = processedText.replace( processedText = processedText.replace(
MARKUP_LINK, MARKUP_LINK,
(_match, text, url) => (_match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`, `<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
); );
// Process WebSocket URLs using shared services // Process WebSocket URLs using shared services
@ -220,12 +215,6 @@ function processBasicFormatting(content: string): string {
return processedText; return processedText;
} }
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return ""; if (!text) return "";
@ -249,7 +238,8 @@ export async function parseBasicmarkup(text: string): Promise<string> {
// AI-NOTE: 2025-01-24 - Added img tag to skip wrapping to prevent image rendering issues // AI-NOTE: 2025-01-24 - Added img tag to skip wrapping to prevent image rendering issues
// Skip wrapping if para already contains block-level elements, math blocks, or images // Skip wrapping if para already contains block-level elements, math blocks, or images
if ( if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i.test( /(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i
.test(
para, para,
) )
) { ) {
@ -268,6 +258,8 @@ 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>`;
} }
} }

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

@ -1,18 +1,17 @@
import * as emoji from "node-emoji";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { import {
processBasicTextFormatting,
processBlockquotes,
processEmojiShortcodes,
processHashtags,
processImageWithReveal, processImageWithReveal,
processMediaUrl, processMediaUrl,
processNostrIdentifiersInText, processNostrIdentifiersInText,
processEmojiShortcodes, processNostrIdentifiersWithEmbeddedEvents,
processWebSocketUrls, processWebSocketUrls,
processHashtags,
processBasicTextFormatting,
processBlockquotes,
processWikilinks, processWikilinks,
processNostrIdentifiersWithEmbeddedEvents, stripTrackingParams,
stripTrackingParams } from "./markupServices.ts";
} from "./markupServices";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
@ -89,7 +88,9 @@ function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
): [string, number] { ): [string, number] {
let html = ""; let html = "";
let i = start; let i = start;
html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`; html += `<${type} class="${
type === "ol" ? "list-decimal" : "list-disc"
} ml-6 mb-2">`;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
@ -161,7 +162,9 @@ function processBasicFormatting(content: string): string {
processedText = processedText.replace( processedText = processedText.replace(
MARKUP_LINK, MARKUP_LINK,
(_match, text, url) => (_match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`, `<a href="${
stripTrackingParams(url)
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
); );
// Process WebSocket URLs using shared services // Process WebSocket URLs using shared services
@ -218,7 +221,10 @@ function processBasicFormatting(content: string): string {
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding * AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding
* Up to 3 levels of nesting are supported, after which events are shown as links * Up to 3 levels of nesting are supported, after which events are shown as links
*/ */
export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0): Promise<string> { export async function parseEmbeddedMarkup(
text: string,
nestingLevel: number = 0,
): Promise<string> {
if (!text) return ""; if (!text) return "";
try { try {
@ -233,20 +239,18 @@ export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0
// 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 // Skip wrapping if content already contains block-level elements
const blockLevelEls =
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i;
processedText = processedText 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) => { .map((para) => {
// AI-NOTE: 2025-01-24 - Added img tag to skip wrapping to prevent image rendering issues
// Skip wrapping if para already contains block-level elements, math blocks, or images // Skip wrapping if para already contains block-level elements, math blocks, or images
if ( if (blockLevelEls.test(para)) {
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i.test(
para,
)
) {
return para; return para;
} }
return `<p class="my-1">${para}</p>`; return `<p class="my-1">${para}</p>`;
}) })
.join("\n"); .join("\n");
@ -255,7 +259,10 @@ export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0
processedText = await processNostrIdentifiersInText(processedText); processedText = await processNostrIdentifiersInText(processedText);
// Then process event identifiers with embedded events (only event-related identifiers) // Then process event identifiers with embedded events (only event-related identifiers)
processedText = processNostrIdentifiersWithEmbeddedEvents(processedText, nestingLevel); processedText = processNostrIdentifiersWithEmbeddedEvents(
processedText,
nestingLevel,
);
// Replace wikilinks // Replace wikilinks
processedText = processWikilinks(processedText); processedText = processWikilinks(processedText);
@ -263,6 +270,8 @@ export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error("Error in parseEmbeddedMarkup:", e); console.error("Error in parseEmbeddedMarkup:", 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>`;
} }
} }

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

@ -1,18 +1,25 @@
import { processNostrIdentifiers, NOSTR_PROFILE_REGEX } from "../nostrUtils.ts"; import {
createProfileLink,
getUserMetadata,
NOSTR_PROFILE_REGEX,
} from "../nostrUtils.ts";
import * as emoji from "node-emoji"; import * as emoji from "node-emoji";
// Media URL patterns // Media URL patterns
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<]*)?/; const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
/** /**
* Shared service for processing images with expand functionality * Shared service for processing images with expand functionality
*/ */
export function processImageWithReveal(src: string, alt: string = "Image"): string { export function processImageWithReveal(
src: string,
alt: string = "Image",
): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) { if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`; return `<img src="${src}" alt="${alt}">`;
} }
@ -47,16 +54,22 @@ export function processMediaUrl(url: string, alt?: string): string {
if (YOUTUBE_URL_REGEX.test(clean)) { if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean); const videoId = extractYouTubeVideoId(clean);
if (videoId) { if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`; return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${
alt || "YouTube video"
}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
} }
} }
if (VIDEO_URL_REGEX.test(clean)) { if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${alt || "Video"}</video>`; return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${
alt || "Video"
}</video>`;
} }
if (AUDIO_URL_REGEX.test(clean)) { if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${alt || "Audio"}</audio>`; return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${
alt || "Audio"
}</audio>`;
} }
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
@ -70,7 +83,9 @@ export function processMediaUrl(url: string, alt?: string): string {
/** /**
* Shared service for processing nostr identifiers * Shared service for processing nostr identifiers
*/ */
export async function processNostrIdentifiersInText(text: string): Promise<string> { export async function processNostrIdentifiersInText(
text: string,
): Promise<string> {
let processedText = text; let processedText = text;
// Find all profile-related nostr addresses (only npub and nprofile) // Find all profile-related nostr addresses (only npub and nprofile)
@ -83,7 +98,10 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin
const matchIndex = match.index ?? 0; const matchIndex = match.index ?? 0;
// Skip if part of a URL // Skip if part of a URL
const before = processedText.slice(Math.max(0, matchIndex - 12), matchIndex); const before = processedText.slice(
Math.max(0, matchIndex - 12),
matchIndex,
);
if (/https?:\/\/$|www\.$/i.test(before)) { if (/https?:\/\/$|www\.$/i.test(before)) {
continue; continue;
} }
@ -95,13 +113,13 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin
} }
// Get user metadata and create link // Get user metadata and create link
const { getUserMetadata, createProfileLink } = await import("../nostrUtils.ts");
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText); const link = createProfileLink(identifier, displayText);
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + link + processedText.slice(matchIndex + fullMatch.length); processedText = processedText.slice(0, matchIndex) + link +
processedText.slice(matchIndex + fullMatch.length);
} }
return processedText; return processedText;
@ -112,7 +130,10 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin
* Replaces nostr: links with embedded event placeholders * Replaces nostr: links with embedded event placeholders
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile) * Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile)
*/ */
export function processNostrIdentifiersWithEmbeddedEvents(text: string, nestingLevel: number = 0): string { export function processNostrIdentifiersWithEmbeddedEvents(
text: string,
nestingLevel: number = 0,
): string {
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g; const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text; let processedText = text;
@ -132,15 +153,20 @@ export function processNostrIdentifiersWithEmbeddedEvents(text: string, nestingL
if (nestingLevel >= MAX_NESTING_LEVEL) { if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link // At max nesting level, just show the link
replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`; replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`;
} else { } else {
// Create a placeholder for embedded event // Create a placeholder for embedded event
const componentId = `embedded-event-${Math.random().toString(36).substr(2, 9)}`; const componentId = `embedded-event-${
replacement = `<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`; Math.random().toString(36).substr(2, 9)
}`;
replacement =
`<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`;
} }
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement + processedText.slice(matchIndex + fullMatch.length); processedText = processedText.slice(0, matchIndex) + replacement +
processedText.slice(matchIndex + fullMatch.length);
} }
return processedText; return processedText;
@ -169,7 +195,10 @@ export function processWebSocketUrls(text: string): string {
*/ */
export function processHashtags(text: string): string { export function processHashtags(text: string): string {
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g; const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
return text.replace(hashtagRegex, '<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>'); return text.replace(
hashtagRegex,
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>',
);
} }
/** /**
@ -177,7 +206,10 @@ export function processHashtags(text: string): string {
*/ */
export function processBasicTextFormatting(text: string): string { export function processBasicTextFormatting(text: string): string {
// Bold: **text** or *text* // Bold: **text** or *text*
text = text.replace(/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, "<strong>$2</strong>"); text = text.replace(
/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g,
"<strong>$2</strong>",
);
// Italic: _text_ or __text__ // Italic: _text_ or __text__
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => { text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => {
@ -186,10 +218,13 @@ export function processBasicTextFormatting(text: string): string {
}); });
// Strikethrough: ~~text~~ or ~text~ // Strikethrough: ~~text~~ or ~text~
text = text.replace(/~~([^~\n]+)~~|~([^~\n]+)~/g, (_match, doubleText, singleText) => { text = text.replace(
/~~([^~\n]+)~~|~([^~\n]+)~/g,
(_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>`;
}); },
);
return text; return text;
} }
@ -203,7 +238,9 @@ export function processBlockquotes(text: string): string {
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">${lines.join("\n")}</blockquote>`; return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join("\n")
}</blockquote>`;
}); });
} }
@ -212,8 +249,16 @@ export function stripTrackingParams(url: string): string {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// Remove common tracking parameters // Remove common tracking parameters
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid']; const trackingParams = [
trackingParams.forEach(param => urlObj.searchParams.delete(param)); "utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
];
trackingParams.forEach((param) => urlObj.searchParams.delete(param));
return urlObj.toString(); return urlObj.toString();
} catch { } catch {
return url; return url;
@ -221,7 +266,9 @@ export function stripTrackingParams(url: string): string {
} }
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;
} }

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

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

86
src/lib/utils/network_detection.ts

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

41
src/lib/utils/nostrEventService.ts

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

122
src/lib/utils/nostrUtils.ts

@ -1,11 +1,15 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { ndkInstance } from "../ndk.ts";
import { npubCache } from "./npubCache.ts"; import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk"; import type { NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter, NostrProfile } from "./search_types.ts"; import type { Filter } from "./search_types.ts";
import { communityRelays, secondaryRelays, searchRelays, anonymousRelays } from "../consts.ts"; import {
anonymousRelays,
communityRelays,
searchRelays,
secondaryRelays,
} from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
@ -26,7 +30,16 @@ export const NOSTR_PROFILE_REGEX =
export const NOSTR_NOTE_REGEX = export const NOSTR_NOTE_REGEX =
/(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
// AI-NOTE: 2025-01-24 - NostrProfile interface moved to search_types.ts for consistency export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
/** /**
* HTML escape a string * HTML escape a string
@ -46,7 +59,7 @@ function escapeHtml(text: string): string {
* Escape regex special characters * Escape regex special characters
*/ */
function escapeRegExp(string: string): string { function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
/** /**
@ -54,12 +67,18 @@ function escapeRegExp(string: string): string {
*/ */
export async function getUserMetadata( export async function getUserMetadata(
identifier: string, identifier: string,
ndk: NDK,
force = false, force = false,
): Promise<NostrProfile> { ): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
console.log("getUserMetadata called with identifier:", identifier, "force:", force); console.log(
"getUserMetadata called with identifier:",
identifier,
"force:",
force,
);
if (!force && npubCache.has(cleanId)) { if (!force && npubCache.has(cleanId)) {
const cached = npubCache.get(cleanId)!; const cached = npubCache.get(cleanId)!;
@ -70,7 +89,6 @@ export async function getUserMetadata(
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
try { try {
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.warn("getUserMetadata: No NDK instance available"); console.warn("getUserMetadata: No NDK instance available");
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
@ -91,7 +109,10 @@ export async function getUserMetadata(
} else if (decoded.type === "nprofile") { } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey; pubkey = decoded.data.pubkey;
} else { } else {
console.warn("getUserMetadata: Unsupported identifier type:", decoded.type); console.warn(
"getUserMetadata: Unsupported identifier type:",
decoded.type,
);
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
@ -105,8 +126,7 @@ export async function getUserMetadata(
console.log("getUserMetadata: Profile event found:", profileEvent); console.log("getUserMetadata: Profile event found:", profileEvent);
const profile = const profile = profileEvent && profileEvent.content
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content) ? JSON.parse(profileEvent.content)
: null; : null;
@ -121,7 +141,6 @@ export async function getUserMetadata(
banner: profile?.banner, banner: profile?.banner,
website: profile?.website, website: profile?.website,
lud16: profile?.lud16, lud16: profile?.lud16,
created_at: profileEvent?.created_at, // AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
}; };
console.log("getUserMetadata: Final metadata:", metadata); console.log("getUserMetadata: Final metadata:", metadata);
@ -156,8 +175,8 @@ export function createProfileLink(
export async function createProfileLinkWithVerification( export async function createProfileLinkWithVerification(
identifier: string, identifier: string,
displayText: string | undefined, displayText: string | undefined,
ndk: NDK,
): Promise<string> { ): Promise<string> {
const ndk = get(ndkInstance) as NDK;
if (!ndk) { if (!ndk) {
return createProfileLink(identifier, displayText); return createProfileLink(identifier, displayText);
} }
@ -215,8 +234,7 @@ export async function createProfileLinkWithVerification(
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
const displayIdentifier = const displayIdentifier = profile?.displayName ??
profile?.displayName ??
profile?.display_name ?? profile?.display_name ??
profile?.name ?? profile?.name ??
escapedText; escapedText;
@ -253,6 +271,7 @@ function createNoteLink(identifier: string): string {
*/ */
export async function processNostrIdentifiers( export async function processNostrIdentifiers(
content: string, content: string,
ndk: NDK,
): Promise<string> { ): Promise<string> {
let processedContent = content; let processedContent = content;
@ -275,11 +294,14 @@ export async function processNostrIdentifiers(
if (!identifier.startsWith("nostr:")) { if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier; identifier = "nostr:" + identifier;
} }
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier, ndk);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText); const link = createProfileLink(identifier, displayText);
// Replace all occurrences of this exact match // Replace all occurrences of this exact match
processedContent = processedContent.replace(new RegExp(escapeRegExp(fullMatch), 'g'), link); processedContent = processedContent.replace(
new RegExp(escapeRegExp(fullMatch), "g"),
link,
);
} }
// Process notes (nevent, note, naddr) // Process notes (nevent, note, naddr)
@ -296,7 +318,10 @@ export async function processNostrIdentifiers(
} }
const link = createNoteLink(identifier); const link = createNoteLink(identifier);
// Replace all occurrences of this exact match // Replace all occurrences of this exact match
processedContent = processedContent.replace(new RegExp(escapeRegExp(fullMatch), 'g'), link); processedContent = processedContent.replace(
new RegExp(escapeRegExp(fullMatch), "g"),
link,
);
} }
return processedContent; return processedContent;
@ -401,7 +426,7 @@ export function withTimeout<T>(
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs), setTimeout(() => reject(new Error("Timeout")), timeoutMs)
), ),
]); ]);
} }
@ -412,7 +437,7 @@ export function withTimeout<T>(
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs), setTimeout(() => reject(new Error("Timeout")), timeoutMs)
), ),
]); ]);
} }
@ -449,12 +474,16 @@ export async function fetchEventWithFallback(
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
// Get all relays from NDK pool first (most comprehensive) // Get all relays from NDK pool first (most comprehensive)
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) => r.url); const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) =>
r.url
);
const inboxRelays = get(activeInboxRelays); const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays); const outboxRelays = get(activeOutboxRelays);
// Combine all available relays, prioritizing pool relays // Combine all available relays, prioritizing pool relays
let allRelays = [...new Set([...poolRelays, ...inboxRelays, ...outboxRelays])]; let allRelays = [
...new Set([...poolRelays, ...inboxRelays, ...outboxRelays]),
];
console.log("fetchEventWithFallback: Using pool relays:", poolRelays); console.log("fetchEventWithFallback: Using pool relays:", poolRelays);
console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays); console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays);
@ -463,7 +492,9 @@ export async function fetchEventWithFallback(
// Check if we have any relays available // Check if we have any relays available
if (allRelays.length === 0) { if (allRelays.length === 0) {
console.warn("fetchEventWithFallback: No relays available for event fetch, using fallback relays"); console.warn(
"fetchEventWithFallback: No relays available for event fetch, using fallback relays",
);
// Use fallback relays when no relays are available // Use fallback relays when no relays are available
allRelays = [...secondaryRelays, ...searchRelays, ...anonymousRelays]; allRelays = [...secondaryRelays, ...searchRelays, ...anonymousRelays];
console.log("fetchEventWithFallback: Using fallback relays:", allRelays); console.log("fetchEventWithFallback: Using fallback relays:", allRelays);
@ -474,13 +505,21 @@ export async function fetchEventWithFallback(
try { try {
if (relaySet.relays.size === 0) { if (relaySet.relays.size === 0) {
console.warn("fetchEventWithFallback: No relays in relay set for event fetch"); console.warn(
"fetchEventWithFallback: No relays in relay set for event fetch",
);
return null; return null;
} }
console.log("fetchEventWithFallback: Relay set size:", relaySet.relays.size); console.log(
"fetchEventWithFallback: Relay set size:",
relaySet.relays.size,
);
console.log("fetchEventWithFallback: Filter:", filterOrId); console.log("fetchEventWithFallback: Filter:", filterOrId);
console.log("fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r) => r.url)); console.log(
"fetchEventWithFallback: Relay URLs:",
Array.from(relaySet.relays).map((r) => r.url),
);
let found: NDKEvent | null = null; let found: NDKEvent | null = null;
@ -492,8 +531,9 @@ export async function fetchEventWithFallback(
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet) .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
} else { } else {
const filter = const filter = typeof filterOrId === "string"
typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId; ? { ids: [filterOrId] }
: filterOrId;
const results = await ndk const results = await ndk
.fetchEvents(filter, undefined, relaySet) .fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs); .withTimeout(timeoutMs);
@ -504,7 +544,9 @@ export async function fetchEventWithFallback(
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", "); const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(
", ",
);
console.warn( console.warn(
`fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, `fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
); );
@ -515,14 +557,19 @@ export async function fetchEventWithFallback(
// Always wrap as NDKEvent // Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === 'Timeout') { if (err instanceof Error && err.message === "Timeout") {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(", "); const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join(
", ",
);
console.warn( console.warn(
`fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, `fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`,
); );
} else { } else {
console.error("fetchEventWithFallback: Error in fetchEventWithFallback:", err); console.error(
"fetchEventWithFallback: Error in fetchEventWithFallback:",
err,
);
} }
return null; return null;
} }
@ -546,8 +593,10 @@ export function toNpub(pubkey: string | undefined): string | null {
// If it's an nprofile, decode and extract npub // If it's an nprofile, decode and extract npub
if (pubkey.startsWith("nprofile")) { if (pubkey.startsWith("nprofile")) {
const decoded = nip19.decode(pubkey); const decoded = nip19.decode(pubkey);
if (decoded.type === 'nprofile') { if (decoded.type === "nprofile") {
return decoded.data.pubkey ? nip19.npubEncode(decoded.data.pubkey) : null; return decoded.data.pubkey
? nip19.npubEncode(decoded.data.pubkey)
: null;
} }
} }
@ -565,7 +614,10 @@ export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk); return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
} }
export function createNDKEvent(ndk: NDK, rawEvent: NDKEvent | NostrEvent | undefined) { export function createNDKEvent(
ndk: NDK,
rawEvent: NDKEvent | NostrEvent | undefined,
) {
return new NDKEvent(ndk, rawEvent); return new NDKEvent(ndk, rawEvent);
} }

24
src/lib/utils/nostr_identifiers.ts

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

306
src/lib/utils/notification_utils.ts

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

20
src/lib/utils/npubCache.ts

@ -4,7 +4,7 @@ export type NpubMetadata = NostrProfile;
class NpubCache { class NpubCache {
private cache: Record<string, NpubMetadata> = {}; private cache: Record<string, NpubMetadata> = {};
private readonly storageKey = 'alexandria_npub_cache'; private readonly storageKey = "alexandria_npub_cache";
private readonly maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds private readonly maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
constructor() { constructor() {
@ -13,10 +13,13 @@ class NpubCache {
private loadFromStorage(): void { private loadFromStorage(): void {
try { try {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
const stored = localStorage.getItem(this.storageKey); const stored = localStorage.getItem(this.storageKey);
if (stored) { if (stored) {
const data = JSON.parse(stored) as Record<string, { profile: NpubMetadata; timestamp: number }>; const data = JSON.parse(stored) as Record<
string,
{ profile: NpubMetadata; timestamp: number }
>;
const now = Date.now(); const now = Date.now();
// Filter out expired entries // Filter out expired entries
@ -28,21 +31,24 @@ class NpubCache {
} }
} }
} catch (error) { } catch (error) {
console.warn('Failed to load npub cache from storage:', error); console.warn("Failed to load npub cache from storage:", error);
} }
} }
private saveToStorage(): void { private saveToStorage(): void {
try { try {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
const data: Record<string, { profile: NpubMetadata; timestamp: number }> = {}; const data: Record<
string,
{ profile: NpubMetadata; timestamp: number }
> = {};
for (const [key, profile] of Object.entries(this.cache)) { for (const [key, profile] of Object.entries(this.cache)) {
data[key] = { profile, timestamp: Date.now() }; data[key] = { profile, timestamp: Date.now() };
} }
localStorage.setItem(this.storageKey, JSON.stringify(data)); localStorage.setItem(this.storageKey, JSON.stringify(data));
} }
} catch (error) { } catch (error) {
console.warn('Failed to save npub cache to storage:', error); console.warn("Failed to save npub cache to storage:", error);
} }
} }

43
src/lib/utils/profileCache.ts

@ -1,6 +1,4 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { type NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub } from "./nostrUtils"; import { toNpub } from "./nostrUtils";
@ -19,13 +17,12 @@ const profileCache = new Map<string, ProfileData>();
* @param pubkey - The public key to fetch profile for * @param pubkey - The public key to fetch profile for
* @returns Profile data or null if not found * @returns Profile data or null if not found
*/ */
async function fetchProfile(pubkey: string): Promise<ProfileData | null> { async function fetchProfile(pubkey: string, ndk: NDK): Promise<ProfileData | null> {
try { try {
const ndk = get(ndkInstance);
const profileEvents = await ndk.fetchEvents({ const profileEvents = await ndk.fetchEvents({
kinds: [0], kinds: [0],
authors: [pubkey], authors: [pubkey],
limit: 1 limit: 1,
}); });
if (profileEvents.size === 0) { if (profileEvents.size === 0) {
@ -53,7 +50,7 @@ async function fetchProfile(pubkey: string): Promise<ProfileData | null> {
* @param pubkey - The public key to get display name for * @param pubkey - The public key to get display name for
* @returns Display name, name, or shortened npub (never hex ID) * @returns Display name, name, or shortened npub (never hex ID)
*/ */
export async function getDisplayName(pubkey: string): Promise<string> { export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> {
// Check cache first // Check cache first
if (profileCache.has(pubkey)) { if (profileCache.has(pubkey)) {
const profile = profileCache.get(pubkey)!; const profile = profileCache.get(pubkey)!;
@ -62,7 +59,7 @@ export async function getDisplayName(pubkey: string): Promise<string> {
} }
// Fetch profile // Fetch profile
const profile = await fetchProfile(pubkey); const profile = await fetchProfile(pubkey, ndk);
if (profile) { if (profile) {
profileCache.set(pubkey, profile); profileCache.set(pubkey, profile);
const npub = toNpub(pubkey); const npub = toNpub(pubkey);
@ -82,12 +79,13 @@ export async function getDisplayName(pubkey: string): Promise<string> {
*/ */
export async function batchFetchProfiles( export async function batchFetchProfiles(
pubkeys: string[], pubkeys: string[],
onProgress?: (fetched: number, total: number) => void ndk: NDK,
onProgress?: (fetched: number, total: number) => void,
): Promise<NDKEvent[]> { ): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = []; const allProfileEvents: NDKEvent[] = [];
// Filter out already cached pubkeys // Filter out already cached pubkeys
const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk)); const uncachedPubkeys = pubkeys.filter((pk) => !profileCache.has(pk));
if (uncachedPubkeys.length === 0) { if (uncachedPubkeys.length === 0) {
if (onProgress) onProgress(pubkeys.length, pubkeys.length); if (onProgress) onProgress(pubkeys.length, pubkeys.length);
@ -95,8 +93,6 @@ export async function batchFetchProfiles(
} }
try { try {
const ndk = get(ndkInstance);
// Report initial progress // Report initial progress
const cachedCount = pubkeys.length - uncachedPubkeys.length; const cachedCount = pubkeys.length - uncachedPubkeys.length;
if (onProgress) onProgress(cachedCount, pubkeys.length); if (onProgress) onProgress(cachedCount, pubkeys.length);
@ -106,11 +102,14 @@ export async function batchFetchProfiles(
let fetchedCount = cachedCount; let fetchedCount = cachedCount;
for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) { for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) {
const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length)); const chunk = uncachedPubkeys.slice(
i,
Math.min(i + CHUNK_SIZE, uncachedPubkeys.length),
);
const profileEvents = await ndk.fetchEvents({ const profileEvents = await ndk.fetchEvents({
kinds: [0], kinds: [0],
authors: chunk authors: chunk,
}); });
// Process each profile event // Process each profile event
@ -180,7 +179,7 @@ export function clearProfileCache(): void {
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> { export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
events.forEach(event => { events.forEach((event) => {
// Add author pubkey // Add author pubkey
if (event.pubkey) { if (event.pubkey) {
pubkeys.add(event.pubkey); pubkeys.add(event.pubkey);
@ -188,7 +187,7 @@ export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
// Add pubkeys from p tags // Add pubkeys from p tags
const pTags = event.getMatchingTags("p"); const pTags = event.getMatchingTags("p");
pTags.forEach(tag => { pTags.forEach((tag) => {
if (tag[1]) { if (tag[1]) {
pubkeys.add(tag[1]); pubkeys.add(tag[1]);
} }
@ -197,11 +196,11 @@ export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> {
// Extract pubkeys from content (nostr:npub1... format) // Extract pubkeys from content (nostr:npub1... format)
const npubPattern = /nostr:npub1[a-z0-9]{58}/g; const npubPattern = /nostr:npub1[a-z0-9]{58}/g;
const matches = event.content?.match(npubPattern) || []; const matches = event.content?.match(npubPattern) || [];
matches.forEach(match => { matches.forEach((match) => {
try { try {
const npub = match.replace('nostr:', ''); const npub = match.replace("nostr:", "");
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub);
if (decoded.type === 'npub') { if (decoded.type === "npub") {
pubkeys.add(decoded.data as string); pubkeys.add(decoded.data as string);
} }
} catch (e) { } catch (e) {
@ -226,11 +225,11 @@ export function replaceContentPubkeys(content: string): string {
let result = content; let result = content;
const matches = content.match(npubPattern) || []; const matches = content.match(npubPattern) || [];
matches.forEach(match => { matches.forEach((match) => {
try { try {
const npub = match.replace('nostr:', ''); const npub = match.replace("nostr:", "");
const decoded = nip19.decode(npub); const decoded = nip19.decode(npub);
if (decoded.type === 'npub') { if (decoded.type === "npub") {
const pubkey = decoded.data as string; const pubkey = decoded.data as string;
const displayName = getDisplayNameSync(pubkey); const displayName = getDisplayNameSync(pubkey);
result = result.replace(match, `@${displayName}`); result = result.replace(match, `@${displayName}`);

788
src/lib/utils/profile_search.ts

@ -1,428 +1,149 @@
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import { getNpubFromNip05, getUserMetadata } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts"; import { searchCache } from "./searchCache.ts";
import { searchRelays, communityRelays, secondaryRelays, localRelays } from "../consts.ts"; import { communityRelays, searchRelays, secondaryRelays } from "../consts.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import { import {
createProfileFromEvent,
fieldMatches, fieldMatches,
nip05Matches, nip05Matches,
normalizeSearchTerm, normalizeSearchTerm,
createProfileFromEvent,
} from "./search_utils.ts"; } from "./search_utils.ts";
import {
fetchCurrentUserLists,
getPubkeysFromUserLists,
isPubkeyInUserLists,
getListKindsForPubkey,
updateProfileCacheForPubkeys,
PEOPLE_LIST_KINDS
} from "./user_lists.ts";
import { nip19 } from "nostr-tools";
import { TIMEOUTS, SEARCH_LIMITS, CACHE_DURATIONS } from "./search_constants.ts";
// AI-NOTE: 2025-01-24 - User list cache with stale-while-revalidate for performance
// This prevents redundant relay queries by caching user lists for 5 minutes
// Fresh cache: Return immediately
// Stale cache: Return stale data immediately, update in background
// No cache: Wait for fresh data
/** /**
* User list cache interface * Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
interface UserListCache {
lists: any[];
pubkeys: Set<string>;
lastUpdated: number;
isUpdating: boolean;
}
/**
* Search strategy types
*/
type SearchStrategy = 'npub' | 'nip05' | 'userLists' | 'nip05Domains' | 'relaySearch';
/**
* Global user list cache instance
*/
let userListCache: UserListCache | null = null;
/**
* Get user lists with stale-while-revalidate caching
* Returns cached data immediately if available, updates in background if stale
*/ */
async function getUserListsWithCache(): Promise<{ lists: any[]; pubkeys: Set<string> }> { export async function searchProfiles(
const now = Date.now(); searchTerm: string,
ndk: NDK,
// If we have fresh cache, return it immediately ): Promise<ProfileSearchResult> {
if (userListCache && (now - userListCache.lastUpdated) < CACHE_DURATIONS.SEARCH_CACHE) { const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("profile_search: Using fresh user list cache");
return {
lists: userListCache.lists,
pubkeys: userListCache.pubkeys
};
}
// If we have stale cache and no update in progress, return stale data and update in background
if (userListCache && !userListCache.isUpdating) {
console.log("profile_search: Using stale user list cache, updating in background");
// Start background update
userListCache.isUpdating = true;
updateUserListCacheInBackground().catch(error => {
console.warn("profile_search: Background user list cache update failed:", error);
if (userListCache) {
userListCache.isUpdating = false;
}
});
return {
lists: userListCache.lists,
pubkeys: userListCache.pubkeys
};
}
// If no cache or update in progress, wait for fresh data console.log(
console.log("profile_search: Fetching fresh user lists"); "searchProfiles called with:",
return await updateUserListCache(); searchTerm,
} "normalized:",
normalizedSearchTerm,
);
/** // Check cache first
* Update user list cache in background const cachedResult = searchCache.get("profile", normalizedSearchTerm);
*/ if (cachedResult) {
async function updateUserListCacheInBackground(): Promise<void> { console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try { try {
const { lists, pubkeys } = await updateUserListCache(); const profileData = JSON.parse(event.content);
console.log("profile_search: Background user list cache update completed"); return createProfileFromEvent(event, profileData);
} catch (error) { } catch {
console.warn("profile_search: Background user list cache update failed:", error); return null;
} finally {
if (userListCache) {
userListCache.isUpdating = false;
}
}
}
/**
* Update user list cache with fresh data
*/
async function updateUserListCache(): Promise<{ lists: any[]; pubkeys: Set<string> }> {
const lists = await fetchCurrentUserLists([...PEOPLE_LIST_KINDS]);
const pubkeys = getPubkeysFromUserLists(lists);
userListCache = {
lists,
pubkeys,
lastUpdated: Date.now(),
isUpdating: false
};
console.log(`profile_search: Updated user list cache with ${lists.length} lists and ${pubkeys.size} pubkeys`);
// Update profile cache for all user list pubkeys to ensure follows are cached
if (pubkeys.size > 0) {
updateProfileCacheForPubkeys(Array.from(pubkeys)).catch(error => {
console.warn("profile_search: Failed to update profile cache:", error);
});
}
return { lists, pubkeys };
}
/**
* Clear user list cache (useful for logout or force refresh)
*/
export function clearUserListCache(): void {
userListCache = null;
console.log("profile_search: User list cache cleared");
}
/**
* Force refresh user list cache (useful when user follows/unfollows someone)
*/
export async function refreshUserListCache(): Promise<void> {
console.log("profile_search: Forcing user list cache refresh");
userListCache = null;
await updateUserListCache();
}
/**
* Get user list cache status for debugging
*/
export function getUserListCacheStatus(): {
hasCache: boolean;
isStale: boolean;
isUpdating: boolean;
ageMinutes: number | null;
listCount: number | null;
pubkeyCount: number | null;
} {
if (!userListCache) {
return {
hasCache: false,
isStale: false,
isUpdating: false,
ageMinutes: null,
listCount: null,
pubkeyCount: null
};
}
const now = Date.now();
const ageMs = now - userListCache.lastUpdated;
const ageMinutes = Math.round(ageMs / (60 * 1000));
const isStale = ageMs > CACHE_DURATIONS.SEARCH_CACHE;
return {
hasCache: true,
isStale,
isUpdating: userListCache.isUpdating,
ageMinutes,
listCount: userListCache.lists.length,
pubkeyCount: userListCache.pubkeys.size
};
} }
})
.filter(Boolean) as NostrProfile[];
/** console.log("Cached profiles found:", profiles.length);
* Wait for NDK to be properly initialized return { profiles, Status: {} };
*/
async function waitForNdk(): Promise<NDK> {
let ndk = get(ndkInstance);
if (!ndk) {
console.log("profile_search: Waiting for NDK initialization...");
let retryCount = 0;
const maxRetries = 10;
const retryDelay = 500; // milliseconds
while (retryCount < maxRetries && !ndk) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
ndk = get(ndkInstance);
retryCount++;
} }
if (!ndk) { if (!ndk) {
console.error("profile_search: NDK not initialized after waiting"); console.error("NDK not initialized");
throw new Error("NDK not initialized"); throw new Error("NDK not initialized");
} }
}
return ndk;
}
/**
* Check if search term is a valid npub/nprofile identifier
*/
function isNostrIdentifier(searchTerm: string): boolean {
return searchTerm.startsWith("npub") || searchTerm.startsWith("nprofile");
}
/** console.log("NDK initialized, starting search logic");
* Check if search term is a NIP-05 address
*/
function isNip05Address(searchTerm: string): boolean {
return searchTerm.includes("@");
}
/** let foundProfiles: NostrProfile[] = [];
* Determine search strategy based on search term
*/
function determineSearchStrategy(searchTerm: string): SearchStrategy {
if (isNostrIdentifier(searchTerm)) {
return 'npub';
}
if (isNip05Address(searchTerm)) {
return 'nip05';
}
return 'userLists'; // Default to user lists first, then other strategies
}
/**
* Search for profiles by npub/nprofile identifier
*/
async function searchByNostrIdentifier(searchTerm: string, ndk: NDK): Promise<NostrProfile[]> {
try { try {
const cleanId = searchTerm.replace(/^nostr:/, ""); // Check if it's a valid npub/nprofile first
const decoded = nip19.decode(cleanId); if (
normalizedSearchTerm.startsWith("npub") ||
if (!decoded) { normalizedSearchTerm.startsWith("nprofile")
return []; ) {
}
let pubkey: string;
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("Unsupported identifier type:", decoded.type);
return [];
}
// AI-NOTE: 2025-01-24 - For npub/nprofile searches, fetch the actual event to preserve timestamp
const events = await ndk.fetchEvents({
kinds: [0],
authors: [pubkey],
});
if (events.size > 0) {
// Get the most recent profile event
const event = Array.from(events).sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)[0];
if (event && event.content) {
try { try {
const profileData = JSON.parse(event.content); const metadata = await getUserMetadata(normalizedSearchTerm, ndk);
const profile = createProfileFromEvent(event, profileData); if (metadata) {
return [profile]; foundProfiles = [metadata];
} catch (error) {
console.error("Error parsing profile content for npub:", error);
}
}
} }
// Fallback to metadata
const metadata = await getUserMetadata(searchTerm);
const profileWithPubkey: NostrProfile = {
...metadata,
pubkey: pubkey,
};
return [profileWithPubkey];
} catch (error) { } catch (error) {
console.error("Error fetching metadata for npub:", error); console.error("Error fetching metadata for npub:", error);
return [];
} }
} } else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address - normalize it properly
/** const normalizedNip05 = normalizedSearchTerm.toLowerCase();
* Search for profiles by NIP-05 address
*/
async function searchByNip05Address(searchTerm: string): Promise<NostrProfile[]> {
try { try {
const normalizedNip05 = searchTerm.toLowerCase();
const npub = await getNpubFromNip05(normalizedNip05); const npub = await getNpubFromNip05(normalizedNip05);
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
return [profile]; foundProfiles = [profile];
} }
} catch (error) { } catch (e) {
console.error("[Search] NIP-05 lookup failed:", error); console.error("[Search] NIP-05 lookup failed:", e);
}
return [];
}
/**
* Fuzzy match function for user list searches
*/
function fuzzyMatch(text: string, searchTerm: string): boolean {
if (!text || !searchTerm) return false;
const normalizedText = text.toLowerCase();
const normalizedSearchTerm = searchTerm.toLowerCase();
// Direct substring match
if (normalizedText.includes(normalizedSearchTerm)) {
return true;
} }
} 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",
);
// AI-NOTE: 2025-01-24 - More strict word boundary matching for profile searches // If no NIP-05 results, try quick relay search
// Only match if the search term is a significant part of a word if (foundProfiles.length === 0) {
const words = normalizedText.split(/[\s\-_\.]+/); console.log("No NIP-05 results, trying quick relay search");
for (const word of words) { foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
// Only match if search term is at least 3 characters and represents a significant part of the word console.log(
if (normalizedSearchTerm.length >= 3) { "Quick relay search completed, found:",
if (word.includes(normalizedSearchTerm) || normalizedSearchTerm.includes(word)) { foundProfiles.length,
return true; "profiles",
} );
} }
} }
return false; // Cache the results
} if (foundProfiles.length > 0) {
const events = foundProfiles.map((profile) => {
/** const event = new NDKEvent(ndk);
* Search for profiles within user's lists with fuzzy matching event.content = JSON.stringify(profile);
*/ event.pubkey = profile.pubkey || "";
async function searchWithinUserLists( return event;
searchTerm: string,
userLists: any[],
ndk: NDK,
): Promise<NostrProfile[]> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
const foundProfiles: NostrProfile[] = [];
const processedPubkeys = new Set<string>();
// Get all pubkeys from user lists
const allPubkeys: string[] = [];
userLists.forEach(list => {
list.pubkeys.forEach((pubkey: string) => {
if (!processedPubkeys.has(pubkey)) {
allPubkeys.push(pubkey);
processedPubkeys.add(pubkey);
}
});
}); });
if (allPubkeys.length === 0) { const result = {
return foundProfiles; events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: normalizedSearchTerm,
};
searchCache.set("profile", normalizedSearchTerm, result);
} }
console.log(`searchWithinUserLists: Searching ${allPubkeys.length} pubkeys from user lists with fuzzy matching`); console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
// Fetch profiles for all pubkeys in batches
for (let i = 0; i < allPubkeys.length; i += SEARCH_LIMITS.BATCH_SIZE) {
const batch = allPubkeys.slice(i, i + SEARCH_LIMITS.BATCH_SIZE);
try {
const events = await ndk.fetchEvents({
kinds: [0],
authors: batch,
});
for (const event of events) {
try {
if (!event.content) continue;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term with exact field matching only
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesAbout) {
const profile = createProfileFromEvent(event, profileData);
foundProfiles.push(profile);
}
} catch {
// Invalid JSON, skip
}
}
} catch (error) { } catch (error) {
console.warn("searchWithinUserLists: Error fetching batch:", error); console.error("Error searching profiles:", error);
} return { profiles: [], Status: {} };
} }
console.log(`searchWithinUserLists: Found ${foundProfiles.length} matching profiles in user lists with fuzzy matching`);
return foundProfiles;
} }
/** /**
* Search for NIP-05 addresses across common domains * Search for NIP-05 addresses across common domains
*/ */
async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> { async function searchNip05Domains(
searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = []; const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups // Enhanced list of common domains for NIP-05 lookups
@ -460,25 +181,33 @@ async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> {
try { try {
const npub = await getNpubFromNip05(gitcitadelAddress); const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) { if (npub) {
console.log("NIP-05 search: SUCCESS! found npub for gitcitadel.com:", npub); console.log(
const metadata = await getUserMetadata(npub); "NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
console.log("NIP-05 search: created profile for gitcitadel.com:", profile); console.log(
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
foundProfiles.push(profile); foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else { } else {
console.log("NIP-05 search: no npub found for gitcitadel.com"); console.log("NIP-05 search: no npub found for gitcitadel.com");
} }
} catch (error) { } catch (e) {
console.log("NIP-05 search: error for gitcitadel.com:", error); console.log("NIP-05 search: error for gitcitadel.com:", e);
} }
// If gitcitadel.com didn't work, try other domains // If gitcitadel.com didn't work, try other domains
console.log("NIP-05 search: gitcitadel.com failed, trying other domains..."); console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(domain => domain !== "gitcitadel.com"); const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout // Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => { const searchPromises = otherDomains.map(async (domain) => {
@ -488,18 +217,23 @@ async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> {
const npub = await getNpubFromNip05(nip05Address); const npub = await getNpubFromNip05(nip05Address);
if (npub) { if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub); console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub, ndk);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
console.log("NIP-05 search: created profile for", nip05Address, ":", profile); console.log(
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
return profile; return profile;
} else { } else {
console.log("NIP-05 search: no npub found for", nip05Address); console.log("NIP-05 search: no npub found for", nip05Address);
} }
} catch (error) { } catch (e) {
console.log("NIP-05 search: error for", nip05Address, ":", error); console.log("NIP-05 search: error for", nip05Address, ":", e);
// Continue to next domain // Continue to next domain
} }
return null; return null;
@ -518,57 +252,39 @@ async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> {
return foundProfiles; return foundProfiles;
} }
/**
* Get all available relay URLs for comprehensive search
*/
function getAllRelayUrls(): string[] {
const userInboxRelays = get(activeInboxRelays);
const userOutboxRelays = get(activeOutboxRelays);
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive profile search coverage
// This includes all relays from consts.ts, user's personal relays, and local relays
const allRelayUrls = [
...searchRelays, // Dedicated profile search relays
...communityRelays, // Community relays
...secondaryRelays, // Secondary relays
...localRelays, // Local relays
...userInboxRelays, // User's personal inbox relays
...userOutboxRelays // User's personal outbox relays
];
// Deduplicate relay URLs
return [...new Set(allRelayUrls)];
}
/** /**
* Quick relay search with short timeout * Quick relay search with short timeout
*/ */
async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProfile[]> { async function quickRelaySearch(
searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm); console.log("quickRelaySearch called with:", searchTerm);
// Normalize the search term for relay search // Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm);
const uniqueRelayUrls = getAllRelayUrls(); // Use search relays (optimized for profiles) + user's inbox relays + community relays
console.log("Using ALL available relays for profile search:", uniqueRelayUrls); const userInboxRelays = get(activeInboxRelays);
console.log("Relay breakdown:", { const quickRelayUrls = [
searchRelays: searchRelays.length, ...searchRelays, // Dedicated profile search relays
communityRelays: communityRelays.length, ...userInboxRelays, // User's personal inbox relays
secondaryRelays: secondaryRelays.length, ...communityRelays, // Community relays
localRelays: localRelays.length, ...secondaryRelays, // Secondary relays as fallback
userInboxRelays: get(activeInboxRelays).length, ];
userOutboxRelays: get(activeOutboxRelays).length,
totalUnique: uniqueRelayUrls.length // Deduplicate relay URLs
}); const uniqueRelayUrls = [...new Set(quickRelayUrls)];
console.log("Using relays for profile search:", uniqueRelayUrls);
// Create relay sets for parallel search // Create relay sets for parallel search
const relaySets = uniqueRelayUrls const relaySets = uniqueRelayUrls
.map((url) => { .map((url) => {
try { try {
return NDKRelaySet.fromRelayUrls([url], ndk); return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (error) { } catch (e) {
console.warn(`Failed to create relay set for ${url}:`, error); console.warn(`Failed to create relay set for ${url}:`, e);
return null; return null;
} }
}) })
@ -582,7 +298,9 @@ async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProf
const foundInRelay: NostrProfile[] = []; const foundInRelay: NostrProfile[] = [];
let eventCount = 0; let eventCount = 0;
console.log(`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`); console.log(
`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`,
);
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ kinds: [0] }, { kinds: [0] },
@ -595,15 +313,22 @@ async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProf
try { try {
if (!event.content) return; if (!event.content) return;
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || ""; const displayName = profileData.displayName ||
profileData.display_name || "";
const display_name = profileData.display_name || ""; const display_name = profileData.display_name || "";
const name = profileData.name || ""; const name = profileData.name || "";
const nip05 = profileData.nip05 || ""; const nip05 = profileData.nip05 || "";
const about = profileData.about || ""; const about = profileData.about || "";
// Check if any field matches the search term using exact field matching only // Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm); const matchesDisplayName = fieldMatches(
const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm); displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm); const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm); const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm); const matchesAbout = fieldMatches(about, normalizedSearchTerm);
@ -639,7 +364,9 @@ async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProf
sub.on("eose", () => { sub.on("eose", () => {
console.log( console.log(
`Relay ${index + 1} (${uniqueRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, `Relay ${index + 1} (${
uniqueRelayUrls[index]
}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
); );
resolve(foundInRelay); resolve(foundInRelay);
}); });
@ -647,11 +374,13 @@ async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProf
// Short timeout for quick search // Short timeout for quick search
setTimeout(() => { setTimeout(() => {
console.log( console.log(
`Relay ${index + 1} (${uniqueRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, `Relay ${index + 1} (${
uniqueRelayUrls[index]
}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
); );
sub.stop(); sub.stop();
resolve(foundInRelay); resolve(foundInRelay);
}, TIMEOUTS.RELAY_TIMEOUT); }, 1500); // 1.5 second timeout per relay
}); });
}); });
@ -671,211 +400,8 @@ async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProf
} }
} }
console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`); console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles); return Object.values(allProfiles);
} }
/**
* Add user list information to profiles and prioritize them
*/
function prioritizeProfiles(profiles: NostrProfile[], userLists: any[]): NostrProfile[] {
return profiles.map(profile => {
if (profile.pubkey) {
const inLists = isPubkeyInUserLists(profile.pubkey, userLists);
const listKinds = getListKindsForPubkey(profile.pubkey, userLists);
return {
...profile,
isInUserLists: inLists,
listKinds: listKinds,
};
}
return profile;
}).sort((a, b) => {
const aInLists = a.isInUserLists || false;
const bInLists = b.isInUserLists || false;
if (aInLists && !bInLists) return -1;
if (!aInLists && bInLists) return 1;
// If both are in lists, prioritize by list kind (follows first)
if (aInLists && bInLists && a.listKinds && b.listKinds) {
const aHasFollows = a.listKinds.includes(3);
const bHasFollows = b.listKinds.includes(3);
if (aHasFollows && !bHasFollows) return -1;
if (!aHasFollows && bHasFollows) return 1;
}
return 0;
});
}
/**
* Cache search results
*/
function cacheSearchResults(profiles: NostrProfile[], searchTerm: string, ndk: NDK): void {
if (profiles.length > 0) {
const events = profiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || "";
// AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
if (profile.created_at) {
event.created_at = profile.created_at;
}
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: searchTerm,
};
searchCache.set("profile", searchTerm, result);
}
}
/**
* Get cached search results
*/
function getCachedResults(searchTerm: string): NostrProfile[] | null {
const cachedResult = searchCache.get("profile", searchTerm);
if (cachedResult) {
console.log("Found cached result for:", searchTerm);
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;
}
return null;
}
/**
* Execute search strategy based on search term type
*/
async function executeSearchStrategy(
strategy: SearchStrategy,
searchTerm: string,
ndk: NDK,
userLists: any[],
): Promise<NostrProfile[]> {
switch (strategy) {
case 'npub':
return await searchByNostrIdentifier(searchTerm, ndk);
case 'nip05':
return await searchByNip05Address(searchTerm);
case 'userLists':
const foundProfiles: NostrProfile[] = [];
// First, search within user's lists for exact matches
if (userLists.length > 0) {
console.log("Searching within user's lists first for:", searchTerm);
const listMatches = await searchWithinUserLists(searchTerm, userLists, ndk);
foundProfiles.push(...listMatches);
console.log("User list search completed, found:", listMatches.length, "profiles");
}
// If we found enough matches in user lists, return them
if (foundProfiles.length >= 5) {
console.log("Found sufficient matches in user lists, skipping other searches");
return foundProfiles;
}
// Try NIP-05 search (faster than relay search)
console.log("Starting NIP-05 search for:", searchTerm);
const nip05Profiles = await searchNip05Domains(searchTerm);
console.log("NIP-05 search completed, found:", nip05Profiles.length, "profiles");
foundProfiles.push(...nip05Profiles);
// If still not enough results, try quick relay search
if (foundProfiles.length < 10) {
console.log("Not enough results, trying quick relay search");
const relayProfiles = await quickRelaySearch(searchTerm, ndk);
console.log("Quick relay search completed, found:", relayProfiles.length, "profiles");
foundProfiles.push(...relayProfiles);
}
// AI-NOTE: 2025-01-24 - Limit results to prevent overwhelming the UI
// For profile searches, we want quality over quantity
if (foundProfiles.length > SEARCH_LIMITS.MAX_PROFILE_RESULTS) {
console.log(`Limiting results from ${foundProfiles.length} to ${SEARCH_LIMITS.MAX_PROFILE_RESULTS} most relevant profiles`);
return foundProfiles.slice(0, SEARCH_LIMITS.MAX_PROFILE_RESULTS);
}
return foundProfiles;
default:
return [];
}
}
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
* Prioritizes profiles from user's lists (follows, etc.)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("searchProfiles called with:", searchTerm, "normalized:", normalizedSearchTerm);
// Check cache first
const cachedProfiles = getCachedResults(normalizedSearchTerm);
if (cachedProfiles) {
return { profiles: cachedProfiles, Status: {} };
}
// Get user lists with stale-while-revalidate caching
let userLists: any[] = [];
let userPubkeys: Set<string> = new Set();
try {
const userListResult = await getUserListsWithCache();
userLists = userListResult.lists;
userPubkeys = userListResult.pubkeys;
console.log(`searchProfiles: Using user lists - ${userLists.length} lists with ${userPubkeys.size} unique pubkeys`);
} catch (error) {
console.warn("searchProfiles: Failed to get user lists:", error);
}
// Wait for NDK to be properly initialized
const ndk = await waitForNdk();
console.log("profile_search: NDK initialized, starting search logic");
try {
// Determine search strategy
const strategy = determineSearchStrategy(normalizedSearchTerm);
console.log("profile_search: Using search strategy:", strategy);
// Execute search strategy
const foundProfiles = await executeSearchStrategy(strategy, normalizedSearchTerm, ndk, userLists);
// Cache the results
cacheSearchResults(foundProfiles, normalizedSearchTerm, ndk);
// Add user list information to profiles and prioritize them
const prioritizedProfiles = prioritizeProfiles(foundProfiles, userLists);
console.log("Search completed, found profiles:", foundProfiles.length);
console.log("Prioritized profiles - follows first:", prioritizedProfiles.length);
return { profiles: prioritizedProfiles, Status: {} };
} catch (error) {
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
}
}

7
src/lib/utils/relayDiagnostics.ts

@ -42,9 +42,8 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
responseTime: Date.now() - startTime, responseTime: Date.now() - startTime,
}); });
} }
} };
}); });
} }
/** /**
@ -93,7 +92,9 @@ export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void {
console.log(`✅ Working relays (${working.length}):`); console.log(`✅ Working relays (${working.length}):`);
working.forEach((d) => { working.forEach((d) => {
console.log( console.log(
` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${d.responseTime ? ` (${d.responseTime}ms)` : ""}`, ` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${
d.responseTime ? ` (${d.responseTime}ms)` : ""
}`,
); );
}); });

64
src/lib/utils/relay_info_service.ts

@ -6,7 +6,7 @@
function simplifyUrl(url: string): string { function simplifyUrl(url: string): string {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : ''); return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : "");
} catch { } catch {
// If URL parsing fails, return the original string // If URL parsing fails, return the original string
return url; return url;
@ -42,18 +42,23 @@ export interface RelayInfoWithMetadata extends RelayInfo {
* @param url The relay URL to fetch info for * @param url The relay URL to fetch info for
* @returns Promise resolving to relay info or undefined if failed * @returns Promise resolving to relay info or undefined if failed
*/ */
export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata | undefined> { export async function fetchRelayInfo(
url: string,
): Promise<RelayInfoWithMetadata | undefined> {
try { try {
// Convert WebSocket URL to HTTP URL for NIP-11 // Convert WebSocket URL to HTTP URL for NIP-11
const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://'); const httpUrl = url.replace("ws://", "http://").replace(
"wss://",
"https://",
);
const response = await fetch(httpUrl, { const response = await fetch(httpUrl, {
headers: { headers: {
'Accept': 'application/nostr+json', "Accept": "application/nostr+json",
'User-Agent': 'Alexandria/1.0' "User-Agent": "Alexandria/1.0",
}, },
// Add timeout to prevent hanging // Add timeout to prevent hanging
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000),
}); });
if (!response.ok) { if (!response.ok) {
@ -62,7 +67,7 @@ export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata
url, url,
shortUrl: simplifyUrl(url), shortUrl: simplifyUrl(url),
hasNip11: false, hasNip11: false,
triedNip11: true triedNip11: true,
}; };
} }
@ -73,7 +78,7 @@ export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata
url, url,
shortUrl: simplifyUrl(url), shortUrl: simplifyUrl(url),
hasNip11: Object.keys(relayInfo).length > 0, hasNip11: Object.keys(relayInfo).length > 0,
triedNip11: true triedNip11: true,
}; };
} catch (error) { } catch (error) {
console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error); console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error);
@ -81,7 +86,7 @@ export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata
url, url,
shortUrl: simplifyUrl(url), shortUrl: simplifyUrl(url),
hasNip11: false, hasNip11: false,
triedNip11: true triedNip11: true,
}; };
} }
} }
@ -91,16 +96,18 @@ export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata
* @param urls Array of relay URLs to fetch info for * @param urls Array of relay URLs to fetch info for
* @returns Promise resolving to array of relay info objects * @returns Promise resolving to array of relay info objects
*/ */
export async function fetchRelayInfos(urls: string[]): Promise<RelayInfoWithMetadata[]> { export async function fetchRelayInfos(
urls: string[],
): Promise<RelayInfoWithMetadata[]> {
if (urls.length === 0) { if (urls.length === 0) {
return []; return [];
} }
const promises = urls.map(url => fetchRelayInfo(url)); const promises = urls.map((url) => fetchRelayInfo(url));
const results = await Promise.allSettled(promises); const results = await Promise.allSettled(promises);
return results return results
.map(result => result.status === 'fulfilled' ? result.value : undefined) .map((result) => result.status === "fulfilled" ? result.value : undefined)
.filter((info): info is RelayInfoWithMetadata => info !== undefined); .filter((info): info is RelayInfoWithMetadata => info !== undefined);
} }
@ -110,21 +117,29 @@ export async function fetchRelayInfos(urls: string[]): Promise<RelayInfoWithMeta
* @param relayInfo Optional relay info * @param relayInfo Optional relay info
* @returns String describing the relay type * @returns String describing the relay type
*/ */
export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string { export function getRelayTypeLabel(
relayUrl: string,
relayInfo?: RelayInfoWithMetadata,
): string {
// Check if it's a local relay // Check if it's a local relay
if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) { if (relayUrl.includes("localhost") || relayUrl.includes("127.0.0.1")) {
return 'Local'; return "Local";
} }
// Check if it's a community relay // Check if it's a community relay
if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') || if (
relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) { relayUrl.includes("nostr.band") || relayUrl.includes("noswhere.com") ||
return 'Community'; relayUrl.includes("damus.io") || relayUrl.includes("nostr.wine")
) {
return "Community";
} }
// Check if it's a user's relay (likely inbox/outbox) // Check if it's a user's relay (likely inbox/outbox)
if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) { if (
return 'User'; relayUrl.includes("relay.nsec.app") ||
relayUrl.includes("relay.snort.social")
) {
return "User";
} }
// Use relay name if available // Use relay name if available
@ -135,9 +150,9 @@ export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMet
// Fallback to domain // Fallback to domain
try { try {
const domain = new URL(relayUrl).hostname; const domain = new URL(relayUrl).hostname;
return domain.replace('www.', ''); return domain.replace("www.", "");
} catch { } catch {
return 'Unknown'; return "Unknown";
} }
} }
@ -147,7 +162,10 @@ export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMet
* @param relayUrl Relay URL as fallback * @param relayUrl Relay URL as fallback
* @returns Icon URL or undefined * @returns Icon URL or undefined
*/ */
export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined { export function getRelayIcon(
relayInfo?: RelayInfoWithMetadata,
relayUrl?: string,
): string | undefined {
if (relayInfo?.icon) { if (relayInfo?.icon) {
return relayInfo.icon; return relayInfo.icon;
} }

334
src/lib/utils/relay_management.ts

@ -1,5 +1,11 @@
import NDK, { NDKKind, NDKRelay, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKKind, NDKRelay, NDKUser } from "@nostr-dev-kit/ndk";
import { searchRelays, secondaryRelays, anonymousRelays, lowbandwidthRelays, localRelays } from "../consts.ts"; import {
anonymousRelays,
localRelays,
lowbandwidthRelays,
searchRelays,
secondaryRelays,
} from "../consts.ts";
import { getRelaySetForNetworkCondition } from "./network_detection.ts"; import { getRelaySetForNetworkCondition } from "./network_detection.ts";
import { networkCondition } from "../stores/networkStore.ts"; import { networkCondition } from "../stores/networkStore.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
@ -13,12 +19,12 @@ export function normalizeRelayUrl(url: string): string {
let normalized = url.toLowerCase().trim(); let normalized = url.toLowerCase().trim();
// Ensure protocol is present // Ensure protocol is present
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) { if (!normalized.startsWith("ws://") && !normalized.startsWith("wss://")) {
normalized = 'wss://' + normalized; normalized = "wss://" + normalized;
} }
// Remove trailing slash // Remove trailing slash
normalized = normalized.replace(/\/$/, ''); normalized = normalized.replace(/\/$/, "");
return normalized; return normalized;
} }
@ -58,7 +64,7 @@ export function testLocalRelayConnection(
actualUrl?: string; actualUrl?: string;
}> { }> {
// Only test connections on client-side // Only test connections on client-side
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return Promise.resolve({ return Promise.resolve({
connected: false, connected: false,
requiresAuth: false, requiresAuth: false,
@ -193,7 +199,7 @@ export function testRemoteRelayConnection(
actualUrl?: string; actualUrl?: string;
}> { }> {
// Only test connections on client-side // Only test connections on client-side
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return Promise.resolve({ return Promise.resolve({
connected: false, connected: false,
requiresAuth: false, requiresAuth: false,
@ -206,7 +212,9 @@ export function testRemoteRelayConnection(
// Ensure the URL is using wss:// protocol for remote relays // Ensure the URL is using wss:// protocol for remote relays
const secureUrl = relayUrl.replace(/^ws:\/\//, "wss://"); const secureUrl = relayUrl.replace(/^ws:\/\//, "wss://");
console.debug(`[relay_management.ts] Testing remote relay connection: ${secureUrl}`); console.debug(
`[relay_management.ts] Testing remote relay connection: ${secureUrl}`,
);
// Use the existing NDK instance instead of creating a new one // Use the existing NDK instance instead of creating a new one
const relay = new NDKRelay(secureUrl, undefined, ndk); const relay = new NDKRelay(secureUrl, undefined, ndk);
@ -216,7 +224,9 @@ export function testRemoteRelayConnection(
let actualUrl: string | undefined; let actualUrl: string | undefined;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.debug(`[relay_management.ts] Relay ${secureUrl} connection timeout`); console.debug(
`[relay_management.ts] Relay ${secureUrl} connection timeout`,
);
relay.disconnect(); relay.disconnect();
resolve({ resolve({
connected: false, connected: false,
@ -227,7 +237,9 @@ export function testRemoteRelayConnection(
}, 3000); }, 3000);
relay.on("connect", () => { relay.on("connect", () => {
console.debug(`[relay_management.ts] Relay ${secureUrl} connected successfully`); console.debug(
`[relay_management.ts] Relay ${secureUrl} connected successfully`,
);
connected = true; connected = true;
actualUrl = secureUrl; actualUrl = secureUrl;
clearTimeout(timeout); clearTimeout(timeout);
@ -248,7 +260,9 @@ export function testRemoteRelayConnection(
relay.on("disconnect", () => { relay.on("disconnect", () => {
if (!connected) { if (!connected) {
console.debug(`[relay_management.ts] Relay ${secureUrl} disconnected without connecting`); console.debug(
`[relay_management.ts] Relay ${secureUrl} disconnected without connecting`,
);
error = "Connection failed"; error = "Connection failed";
clearTimeout(timeout); clearTimeout(timeout);
resolve({ resolve({
@ -280,22 +294,23 @@ export function testRelayConnection(
actualUrl?: string; actualUrl?: string;
}> { }> {
// Determine if this is a local or remote relay // Determine if this is a local or remote relay
if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) { if (relayUrl.includes("localhost") || relayUrl.includes("127.0.0.1")) {
return testLocalRelayConnection(relayUrl, ndk); return testLocalRelayConnection(relayUrl, ndk);
} else { } else {
return testRemoteRelayConnection(relayUrl, ndk); return testRemoteRelayConnection(relayUrl, ndk);
} }
} }
/** /**
* Tests connection to local relays * Tests connection to local relays
* @param localRelayUrls Array of local relay URLs to test * @param localRelayUrls Array of local relay URLs to test
* @param ndk NDK instance * @param ndk NDK instance
* @returns Promise that resolves to array of working local relay URLs * @returns Promise that resolves to array of working local relay URLs
*/ */
async function testLocalRelays(localRelayUrls: string[], ndk: NDK): Promise<string[]> { async function testLocalRelays(
localRelayUrls: string[],
ndk: NDK,
): Promise<string[]> {
try { try {
const workingRelays: string[] = []; const workingRelays: string[] = [];
@ -310,17 +325,21 @@ async function testLocalRelays(localRelayUrls: string[], ndk: NDK): Promise<stri
const result = await testLocalRelayConnection(url, ndk); const result = await testLocalRelayConnection(url, ndk);
if (result.connected) { if (result.connected) {
workingRelays.push(url); workingRelays.push(url);
console.debug(`[relay_management.ts] Local relay connected: ${url}`); console.debug(
`[relay_management.ts] Local relay connected: ${url}`,
);
} }
// Don't log failures - local relays are optional // Don't log failures - local relays are optional
} catch { } catch {
// Silently ignore local relay failures - they're optional // Silently ignore local relay failures - they're optional
} }
}) }),
); );
if (workingRelays.length > 0) { if (workingRelays.length > 0) {
console.info(`[relay_management.ts] Found ${workingRelays.length} working local relays`); console.info(
`[relay_management.ts] Found ${workingRelays.length} working local relays`,
);
} }
return workingRelays; return workingRelays;
} catch { } catch {
@ -339,13 +358,13 @@ export async function discoverLocalRelays(ndk: NDK): Promise<string[]> {
try { try {
// If no local relays are configured, return empty array // If no local relays are configured, return empty array
if (localRelays.length === 0) { if (localRelays.length === 0) {
console.debug('[relay_management.ts] No local relays configured'); console.debug("[relay_management.ts] No local relays configured");
return []; return [];
} }
// Convert wss:// URLs from consts to ws:// for local testing // Convert wss:// URLs from consts to ws:// for local testing
const localRelayUrls = localRelays.map((url: string) => const localRelayUrls = localRelays.map((url: string) =>
url.replace(/^wss:\/\//, 'ws://') url.replace(/^wss:\/\//, "ws://")
); );
const workingRelays = await testLocalRelays(localRelayUrls, ndk); const workingRelays = await testLocalRelays(localRelayUrls, ndk);
@ -365,7 +384,10 @@ export async function discoverLocalRelays(ndk: NDK): Promise<string[]> {
* @param user User to fetch local relays for * @param user User to fetch local relays for
* @returns Promise that resolves to array of local relay URLs * @returns Promise that resolves to array of local relay URLs
*/ */
export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<string[]> { export async function getUserLocalRelays(
ndk: NDK,
user: NDKUser,
): Promise<string[]> {
try { try {
const localRelayEvent = await ndk.fetchEvent( const localRelayEvent = await ndk.fetchEvent(
{ {
@ -376,7 +398,7 @@ export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<strin
groupable: false, groupable: false,
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
} },
); );
if (!localRelayEvent) { if (!localRelayEvent) {
@ -385,14 +407,17 @@ export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<strin
const localRelays: string[] = []; const localRelays: string[] = [];
localRelayEvent.tags.forEach((tag) => { localRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) { if (tag[0] === "r" && tag[1]) {
localRelays.push(tag[1]); localRelays.push(tag[1]);
} }
}); });
return localRelays; return localRelays;
} catch (error) { } catch (error) {
console.info('[relay_management.ts] Error fetching user local relays:', error); console.info(
"[relay_management.ts] Error fetching user local relays:",
error,
);
return []; return [];
} }
} }
@ -403,7 +428,10 @@ export async function getUserLocalRelays(ndk: NDK, user: NDKUser): Promise<strin
* @param user User to fetch blocked relays for * @param user User to fetch blocked relays for
* @returns Promise that resolves to array of blocked relay URLs * @returns Promise that resolves to array of blocked relay URLs
*/ */
export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<string[]> { export async function getUserBlockedRelays(
ndk: NDK,
user: NDKUser,
): Promise<string[]> {
try { try {
const blockedRelayEvent = await ndk.fetchEvent( const blockedRelayEvent = await ndk.fetchEvent(
{ {
@ -414,7 +442,7 @@ export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<str
groupable: false, groupable: false,
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
} },
); );
if (!blockedRelayEvent) { if (!blockedRelayEvent) {
@ -423,14 +451,17 @@ export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<str
const blockedRelays: string[] = []; const blockedRelays: string[] = [];
blockedRelayEvent.tags.forEach((tag) => { blockedRelayEvent.tags.forEach((tag) => {
if (tag[0] === 'r' && tag[1]) { if (tag[0] === "r" && tag[1]) {
blockedRelays.push(tag[1]); blockedRelays.push(tag[1]);
} }
}); });
return blockedRelays; return blockedRelays;
} catch (error) { } catch (error) {
console.info('[relay_management.ts] Error fetching user blocked relays:', error); console.info(
"[relay_management.ts] Error fetching user blocked relays:",
error,
);
return []; return [];
} }
} }
@ -441,9 +472,15 @@ export async function getUserBlockedRelays(ndk: NDK, user: NDKUser): Promise<str
* @param user User to fetch outbox relays for * @param user User to fetch outbox relays for
* @returns Promise that resolves to array of outbox relay URLs * @returns Promise that resolves to array of outbox relay URLs
*/ */
export async function getUserOutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> { export async function getUserOutboxRelays(
ndk: NDK,
user: NDKUser,
): Promise<string[]> {
try { try {
console.debug('[relay_management.ts] Fetching outbox relays for user:', user.pubkey); console.debug(
"[relay_management.ts] Fetching outbox relays for user:",
user.pubkey,
);
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
kinds: [10002], kinds: [10002],
@ -453,36 +490,47 @@ export async function getUserOutboxRelays(ndk: NDK, user: NDKUser): Promise<stri
groupable: false, groupable: false,
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
} },
); );
if (!relayList) { if (!relayList) {
console.debug('[relay_management.ts] No relay list found for user'); console.debug("[relay_management.ts] No relay list found for user");
return []; return [];
} }
console.debug('[relay_management.ts] Found relay list event:', relayList.id); console.debug(
console.debug('[relay_management.ts] Relay list tags:', relayList.tags); "[relay_management.ts] Found relay list event:",
relayList.id,
);
console.debug("[relay_management.ts] Relay list tags:", relayList.tags);
const outboxRelays: string[] = []; const outboxRelays: string[] = [];
relayList.tags.forEach((tag) => { relayList.tags.forEach((tag) => {
console.debug('[relay_management.ts] Processing tag:', tag); console.debug("[relay_management.ts] Processing tag:", tag);
if (tag[0] === 'w' && tag[1]) { if (tag[0] === "w" && tag[1]) {
outboxRelays.push(tag[1]); outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added outbox relay:', tag[1]); console.debug("[relay_management.ts] Added outbox relay:", tag[1]);
} else if (tag[0] === 'r' && tag[1]) { } else if (tag[0] === "r" && tag[1]) {
// Some relay lists use 'r' for both inbox and outbox // Some relay lists use 'r' for both inbox and outbox
outboxRelays.push(tag[1]); outboxRelays.push(tag[1]);
console.debug('[relay_management.ts] Added relay (r tag):', tag[1]); console.debug("[relay_management.ts] Added relay (r tag):", tag[1]);
} else { } else {
console.debug('[relay_management.ts] Skipping tag:', tag[0], 'value:', tag[1]); console.debug(
"[relay_management.ts] Skipping tag:",
tag[0],
"value:",
tag[1],
);
} }
}); });
console.debug('[relay_management.ts] Final outbox relays:', outboxRelays); console.debug("[relay_management.ts] Final outbox relays:", outboxRelays);
return outboxRelays; return outboxRelays;
} catch (error) { } catch (error) {
console.info('[relay_management.ts] Error fetching user outbox relays:', error); console.info(
"[relay_management.ts] Error fetching user outbox relays:",
error,
);
return []; return [];
} }
} }
@ -494,45 +542,65 @@ export async function getUserOutboxRelays(ndk: NDK, user: NDKUser): Promise<stri
export async function getExtensionRelays(): Promise<string[]> { export async function getExtensionRelays(): Promise<string[]> {
try { try {
// Check if we're in a browser environment with extension support // Check if we're in a browser environment with extension support
if (typeof window === 'undefined' || !globalThis.nostr) { if (typeof window === "undefined" || !globalThis.nostr) {
console.debug('[relay_management.ts] No globalThis.nostr available'); console.debug("[relay_management.ts] No globalThis.nostr available");
return []; return [];
} }
console.debug('[relay_management.ts] Extension available, checking for getRelays()'); console.debug(
"[relay_management.ts] Extension available, checking for getRelays()",
);
const extensionRelays: string[] = []; const extensionRelays: string[] = [];
// Try to get relays from the extension's API // Try to get relays from the extension's API
// Different extensions may expose their relay config differently // Different extensions may expose their relay config differently
if (globalThis.nostr.getRelays) { if (globalThis.nostr.getRelays) {
console.debug('[relay_management.ts] getRelays() method found, calling it...'); console.debug(
"[relay_management.ts] getRelays() method found, calling it...",
);
try { try {
const relays = await globalThis.nostr.getRelays(); const relays = await globalThis.nostr.getRelays();
console.debug('[relay_management.ts] getRelays() returned:', relays); console.debug("[relay_management.ts] getRelays() returned:", relays);
if (relays && typeof relays === 'object') { if (relays && typeof relays === "object") {
// Convert relay object to array of URLs // Convert relay object to array of URLs
const relayUrls = Object.keys(relays); const relayUrls = Object.keys(relays);
extensionRelays.push(...relayUrls); extensionRelays.push(...relayUrls);
console.debug('[relay_management.ts] Got relays from extension:', relayUrls); console.debug(
"[relay_management.ts] Got relays from extension:",
relayUrls,
);
} }
} catch (error) { } catch (error) {
console.debug('[relay_management.ts] Extension getRelays() failed:', error); console.debug(
"[relay_management.ts] Extension getRelays() failed:",
error,
);
} }
} else { } else {
console.debug('[relay_management.ts] getRelays() method not found on globalThis.nostr'); console.debug(
"[relay_management.ts] getRelays() method not found on globalThis.nostr",
);
} }
// If getRelays() didn't work, try alternative methods // If getRelays() didn't work, try alternative methods
if (extensionRelays.length === 0) { if (extensionRelays.length === 0) {
// Some extensions might expose relays through other methods // Some extensions might expose relays through other methods
// This is a fallback for extensions that don't expose getRelays() // This is a fallback for extensions that don't expose getRelays()
console.debug('[relay_management.ts] Extension does not expose relay configuration'); console.debug(
"[relay_management.ts] Extension does not expose relay configuration",
);
} }
console.debug('[relay_management.ts] Final extension relays:', extensionRelays); console.debug(
"[relay_management.ts] Final extension relays:",
extensionRelays,
);
return extensionRelays; return extensionRelays;
} catch (error) { } catch (error) {
console.debug('[relay_management.ts] Error getting extension relays:', error); console.debug(
"[relay_management.ts] Error getting extension relays:",
error,
);
return []; return [];
} }
} }
@ -547,36 +615,59 @@ async function testRelaySet(relayUrls: string[], ndk: NDK): Promise<string[]> {
const workingRelays: string[] = []; const workingRelays: string[] = [];
const maxConcurrent = 2; // Reduce to 2 relays at a time to avoid overwhelming them const maxConcurrent = 2; // Reduce to 2 relays at a time to avoid overwhelming them
console.debug(`[relay_management.ts] Testing ${relayUrls.length} relays in batches of ${maxConcurrent}`); console.debug(
`[relay_management.ts] Testing ${relayUrls.length} relays in batches of ${maxConcurrent}`,
);
console.debug(`[relay_management.ts] Relay URLs to test:`, relayUrls); console.debug(`[relay_management.ts] Relay URLs to test:`, relayUrls);
for (let i = 0; i < relayUrls.length; i += maxConcurrent) { for (let i = 0; i < relayUrls.length; i += maxConcurrent) {
const batch = relayUrls.slice(i, i + maxConcurrent); const batch = relayUrls.slice(i, i + maxConcurrent);
console.debug(`[relay_management.ts] Testing batch ${Math.floor(i/maxConcurrent) + 1}:`, batch); console.debug(
`[relay_management.ts] Testing batch ${
Math.floor(i / maxConcurrent) + 1
}:`,
batch,
);
const batchPromises = batch.map(async (url) => { const batchPromises = batch.map(async (url) => {
try { try {
console.debug(`[relay_management.ts] Testing relay: ${url}`); console.debug(`[relay_management.ts] Testing relay: ${url}`);
const result = await testRelayConnection(url, ndk); const result = await testRelayConnection(url, ndk);
console.debug(`[relay_management.ts] Relay ${url} test result:`, result); console.debug(
`[relay_management.ts] Relay ${url} test result:`,
result,
);
return result.connected ? url : null; return result.connected ? url : null;
} catch (error) { } catch (error) {
console.debug(`[relay_management.ts] Failed to test relay ${url}:`, error); console.debug(
`[relay_management.ts] Failed to test relay ${url}:`,
error,
);
return null; return null;
} }
}); });
const batchResults = await Promise.allSettled(batchPromises); const batchResults = await Promise.allSettled(batchPromises);
const batchWorkingRelays = batchResults const batchWorkingRelays = batchResults
.filter((result): result is PromiseFulfilledResult<string | null> => result.status === 'fulfilled') .filter((result): result is PromiseFulfilledResult<string | null> =>
.map(result => result.value) result.status === "fulfilled"
)
.map((result) => result.value)
.filter((url): url is string => url !== null); .filter((url): url is string => url !== null);
console.debug(`[relay_management.ts] Batch ${Math.floor(i/maxConcurrent) + 1} working relays:`, batchWorkingRelays); console.debug(
`[relay_management.ts] Batch ${
Math.floor(i / maxConcurrent) + 1
} working relays:`,
batchWorkingRelays,
);
workingRelays.push(...batchWorkingRelays); workingRelays.push(...batchWorkingRelays);
} }
console.debug(`[relay_management.ts] Total working relays after testing:`, workingRelays); console.debug(
`[relay_management.ts] Total working relays after testing:`,
workingRelays,
);
return workingRelays; return workingRelays;
} }
@ -588,13 +679,19 @@ async function testRelaySet(relayUrls: string[], ndk: NDK): Promise<string[]> {
*/ */
export async function buildCompleteRelaySet( export async function buildCompleteRelaySet(
ndk: NDK, ndk: NDK,
user: NDKUser | null user: NDKUser | null,
): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> { ): Promise<{ inboxRelays: string[]; outboxRelays: string[] }> {
console.debug('[relay_management.ts] buildCompleteRelaySet: Starting with user:', user?.pubkey || 'null'); console.debug(
"[relay_management.ts] buildCompleteRelaySet: Starting with user:",
user?.pubkey || "null",
);
// Discover local relays first // Discover local relays first
const discoveredLocalRelays = await discoverLocalRelays(ndk); const discoveredLocalRelays = await discoverLocalRelays(ndk);
console.debug('[relay_management.ts] buildCompleteRelaySet: Discovered local relays:', discoveredLocalRelays); console.debug(
"[relay_management.ts] buildCompleteRelaySet: Discovered local relays:",
discoveredLocalRelays,
);
// Get user-specific relays if available // Get user-specific relays if available
let userOutboxRelays: string[] = []; let userOutboxRelays: string[] = [];
@ -603,42 +700,75 @@ export async function buildCompleteRelaySet(
let extensionRelays: string[] = []; let extensionRelays: string[] = [];
if (user) { if (user) {
console.debug('[relay_management.ts] buildCompleteRelaySet: Fetching user-specific relays for:', user.pubkey); console.debug(
"[relay_management.ts] buildCompleteRelaySet: Fetching user-specific relays for:",
user.pubkey,
);
try { try {
userOutboxRelays = await getUserOutboxRelays(ndk, user); userOutboxRelays = await getUserOutboxRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User outbox relays:', userOutboxRelays); console.debug(
"[relay_management.ts] buildCompleteRelaySet: User outbox relays:",
userOutboxRelays,
);
} catch (error) { } catch (error) {
console.debug('[relay_management.ts] Error fetching user outbox relays:', error); console.debug(
"[relay_management.ts] Error fetching user outbox relays:",
error,
);
} }
try { try {
userLocalRelays = await getUserLocalRelays(ndk, user); userLocalRelays = await getUserLocalRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User local relays:', userLocalRelays); console.debug(
"[relay_management.ts] buildCompleteRelaySet: User local relays:",
userLocalRelays,
);
} catch (error) { } catch (error) {
console.debug('[relay_management.ts] Error fetching user local relays:', error); console.debug(
"[relay_management.ts] Error fetching user local relays:",
error,
);
} }
try { try {
blockedRelays = await getUserBlockedRelays(ndk, user); blockedRelays = await getUserBlockedRelays(ndk, user);
console.debug('[relay_management.ts] buildCompleteRelaySet: User blocked relays:', blockedRelays); console.debug(
"[relay_management.ts] buildCompleteRelaySet: User blocked relays:",
blockedRelays,
);
} catch { } catch {
// Silently ignore blocked relay fetch errors // Silently ignore blocked relay fetch errors
} }
try { try {
extensionRelays = await getExtensionRelays(); extensionRelays = await getExtensionRelays();
console.debug('[relay_management.ts] Extension relays gathered:', extensionRelays); console.debug(
"[relay_management.ts] Extension relays gathered:",
extensionRelays,
);
} catch (error) { } catch (error) {
console.debug('[relay_management.ts] Error fetching extension relays:', error); console.debug(
"[relay_management.ts] Error fetching extension relays:",
error,
);
} }
} else { } else {
console.debug('[relay_management.ts] buildCompleteRelaySet: No user provided, skipping user-specific relays'); console.debug(
"[relay_management.ts] buildCompleteRelaySet: No user provided, skipping user-specific relays",
);
} }
// Build initial relay sets and deduplicate // Build initial relay sets and deduplicate
const finalInboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userLocalRelays]); const finalInboxRelays = deduplicateRelayUrls([
const finalOutboxRelays = deduplicateRelayUrls([...discoveredLocalRelays, ...userOutboxRelays, ...extensionRelays]); ...discoveredLocalRelays,
...userLocalRelays,
]);
const finalOutboxRelays = deduplicateRelayUrls([
...discoveredLocalRelays,
...userOutboxRelays,
...extensionRelays,
]);
// Test relays and filter out non-working ones // Test relays and filter out non-working ones
let testedInboxRelays: string[] = []; let testedInboxRelays: string[] = [];
@ -654,15 +784,21 @@ export async function buildCompleteRelaySet(
// If no relays passed testing, use remote relays without testing // If no relays passed testing, use remote relays without testing
if (testedInboxRelays.length === 0 && testedOutboxRelays.length === 0) { if (testedInboxRelays.length === 0 && testedOutboxRelays.length === 0) {
const remoteRelays = deduplicateRelayUrls([...secondaryRelays, ...searchRelays]); const remoteRelays = deduplicateRelayUrls([
...secondaryRelays,
...searchRelays,
]);
return { return {
inboxRelays: remoteRelays, inboxRelays: remoteRelays,
outboxRelays: remoteRelays outboxRelays: remoteRelays,
}; };
} }
// Always include some remote relays as fallback, even when local relays are working // Always include some remote relays as fallback, even when local relays are working
const fallbackRelays = deduplicateRelayUrls([...anonymousRelays, ...secondaryRelays]); const fallbackRelays = deduplicateRelayUrls([
...anonymousRelays,
...secondaryRelays,
]);
// Use tested relays and add fallback relays // Use tested relays and add fallback relays
const inboxRelays = testedInboxRelays.length > 0 const inboxRelays = testedInboxRelays.length > 0
@ -678,27 +814,51 @@ export async function buildCompleteRelaySet(
currentNetworkCondition, currentNetworkCondition,
discoveredLocalRelays, discoveredLocalRelays,
lowbandwidthRelays, lowbandwidthRelays,
{ inboxRelays, outboxRelays } { inboxRelays, outboxRelays },
); );
// Filter out blocked relays and deduplicate final sets // Filter out blocked relays and deduplicate final sets
const finalRelaySet = { const finalRelaySet = {
inboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.inboxRelays.filter((r: string) => !blockedRelays.includes(r))), inboxRelays: deduplicateRelayUrls(
outboxRelays: deduplicateRelayUrls(networkOptimizedRelaySet.outboxRelays.filter((r: string) => !blockedRelays.includes(r))) networkOptimizedRelaySet.inboxRelays.filter((r: string) =>
!blockedRelays.includes(r)
),
),
outboxRelays: deduplicateRelayUrls(
networkOptimizedRelaySet.outboxRelays.filter((r: string) =>
!blockedRelays.includes(r)
),
),
}; };
// Ensure we always have at least some relays // Ensure we always have at least some relays
if (finalRelaySet.inboxRelays.length === 0 && finalRelaySet.outboxRelays.length === 0) { if (
console.warn('[relay_management.ts] No relays available, using anonymous relays as final fallback'); finalRelaySet.inboxRelays.length === 0 &&
finalRelaySet.outboxRelays.length === 0
) {
console.warn(
"[relay_management.ts] No relays available, using anonymous relays as final fallback",
);
return { return {
inboxRelays: deduplicateRelayUrls(anonymousRelays), inboxRelays: deduplicateRelayUrls(anonymousRelays),
outboxRelays: deduplicateRelayUrls(anonymousRelays) outboxRelays: deduplicateRelayUrls(anonymousRelays),
}; };
} }
console.debug('[relay_management.ts] buildCompleteRelaySet: Final relay sets - inbox:', finalRelaySet.inboxRelays.length, 'outbox:', finalRelaySet.outboxRelays.length); console.debug(
console.debug('[relay_management.ts] buildCompleteRelaySet: Final inbox relays:', finalRelaySet.inboxRelays); "[relay_management.ts] buildCompleteRelaySet: Final relay sets - inbox:",
console.debug('[relay_management.ts] buildCompleteRelaySet: Final outbox relays:', finalRelaySet.outboxRelays); finalRelaySet.inboxRelays.length,
"outbox:",
finalRelaySet.outboxRelays.length,
);
console.debug(
"[relay_management.ts] buildCompleteRelaySet: Final inbox relays:",
finalRelaySet.inboxRelays,
);
console.debug(
"[relay_management.ts] buildCompleteRelaySet: Final outbox relays:",
finalRelaySet.outboxRelays,
);
return finalRelaySet; return finalRelaySet;
} }

8
src/lib/utils/search_result_formatter.ts

@ -6,13 +6,15 @@ export class SearchResultFormatter {
/** /**
* Formats a result message based on search count and type * Formats a result message based on search count and type
*/ */
formatResultMessage(searchResultCount: number | null, searchResultType: string | null): string { formatResultMessage(
searchResultCount: number | null,
searchResultType: string | null,
): string {
if (searchResultCount === 0) { if (searchResultCount === 0) {
return "Search completed. No results found."; return "Search completed. No results found.";
} }
const typeLabel = const typeLabel = searchResultType === "n"
searchResultType === "n"
? "profile" ? "profile"
: searchResultType === "nip05" : searchResultType === "nip05"
? "NIP-05 address" ? "NIP-05 address"

14
src/lib/utils/search_utility.ts

@ -13,13 +13,13 @@ export { searchBySubscription } from "./subscription_search";
export { searchEvent, searchNip05 } from "./event_search"; export { searchEvent, searchNip05 } from "./event_search";
export { checkCommunity } from "./community_checker"; export { checkCommunity } from "./community_checker";
export { export {
wellKnownUrl,
lnurlpWellKnownUrl,
isValidNip05Address,
normalizeSearchTerm,
fieldMatches,
nip05Matches,
COMMON_DOMAINS, COMMON_DOMAINS,
isEmojiReaction,
createProfileFromEvent, createProfileFromEvent,
fieldMatches,
isEmojiReaction,
isValidNip05Address,
lnurlpWellKnownUrl,
nip05Matches,
normalizeSearchTerm,
wellKnownUrl,
} from "./search_utils"; } from "./search_utils";

744
src/lib/utils/subscription_search.ts

File diff suppressed because it is too large Load Diff

72
src/lib/utils/tag_event_fetch.ts

@ -1,7 +1,5 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { type NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "../ndk"; import { batchFetchProfiles, extractPubkeysFromEvents } from "./profileCache.ts";
import { get } from "svelte/store";
import { extractPubkeysFromEvents, batchFetchProfiles } from "./profileCache";
// Constants for publication event kinds // Constants for publication event kinds
const INDEX_EVENT_KIND = 30040; const INDEX_EVENT_KIND = 30040;
@ -33,34 +31,36 @@ export async function fetchTaggedEventsFromRelays(
tags: string[], tags: string[],
existingEventIds: Set<string>, existingEventIds: Set<string>,
baseEvents: NDKEvent[], baseEvents: NDKEvent[],
debug?: (...args: any[]) => void ndk: NDK,
debug?: (...args: any[]) => void,
): Promise<TagExpansionResult> { ): Promise<TagExpansionResult> {
const log = debug || console.debug; const log = debug || console.debug;
log("Fetching from relays for tags:", tags); log("Fetching from relays for tags:", tags);
// Fetch publications that have any of the specified tags // Fetch publications that have any of the specified tags
const ndk = get(ndkInstance);
const taggedPublications = await ndk.fetchEvents({ const taggedPublications = await ndk.fetchEvents({
kinds: [INDEX_EVENT_KIND], kinds: [INDEX_EVENT_KIND],
"#t": tags, // Match any of these tags "#t": tags, // Match any of these tags
limit: 30 // Reasonable default limit limit: 30, // Reasonable default limit
}); });
log("Found tagged publications from relays:", taggedPublications.size); log("Found tagged publications from relays:", taggedPublications.size);
// Filter to avoid duplicates // Filter to avoid duplicates
const newPublications = Array.from(taggedPublications).filter( const newPublications = Array.from(taggedPublications).filter(
(event: NDKEvent) => !existingEventIds.has(event.id) (event: NDKEvent) => !existingEventIds.has(event.id),
); );
// Extract content event d-tags from new publications // Extract content event d-tags from new publications
const contentEventDTags = new Set<string>(); const contentEventDTags = new Set<string>();
const existingContentDTags = new Set( const existingContentDTags = new Set(
baseEvents baseEvents
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) .filter((e) =>
.map(e => e.tagValue("d")) e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)
.filter(d => d !== undefined) )
.map((e) => e.tagValue("d"))
.filter((d) => d !== undefined),
); );
newPublications.forEach((event: NDKEvent) => { newPublications.forEach((event: NDKEvent) => {
@ -68,9 +68,9 @@ export async function fetchTaggedEventsFromRelays(
aTags.forEach((tag: string[]) => { aTags.forEach((tag: string[]) => {
// Parse the 'a' tag identifier: kind:pubkey:d-tag // Parse the 'a' tag identifier: kind:pubkey:d-tag
if (tag[1]) { if (tag[1]) {
const parts = tag[1].split(':'); const parts = tag[1].split(":");
if (parts.length >= 3) { if (parts.length >= 3) {
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons const dTag = parts.slice(2).join(":"); // Handle d-tags with colons
if (!existingContentDTags.has(dTag)) { if (!existingContentDTags.has(dTag)) {
contentEventDTags.add(dTag); contentEventDTags.add(dTag);
} }
@ -91,7 +91,7 @@ export async function fetchTaggedEventsFromRelays(
return { return {
publications: newPublications, publications: newPublications,
contentEvents: newContentEvents contentEvents: newContentEvents,
}; };
} }
@ -115,20 +115,20 @@ export function findTaggedEventsInFetched(
tags: string[], tags: string[],
existingEventIds: Set<string>, existingEventIds: Set<string>,
baseEvents: NDKEvent[], baseEvents: NDKEvent[],
debug?: (...args: any[]) => void debug?: (...args: any[]) => void,
): TagExpansionResult { ): TagExpansionResult {
const log = debug || console.debug; const log = debug || console.debug;
log("Searching through already fetched events for tags:", tags); log("Searching through already fetched events for tags:", tags);
// Find publications in allEvents that have the specified tags // Find publications in allEvents that have the specified tags
const taggedPublications = allEvents.filter(event => { const taggedPublications = allEvents.filter((event) => {
if (event.kind !== INDEX_EVENT_KIND) return false; if (event.kind !== INDEX_EVENT_KIND) return false;
if (existingEventIds.has(event.id)) return false; // Skip base events if (existingEventIds.has(event.id)) return false; // Skip base events
// Check if event has any of the specified tags // Check if event has any of the specified tags
const eventTags = event.getMatchingTags("t").map(tag => tag[1]); const eventTags = event.getMatchingTags("t").map((tag) => tag[1]);
return tags.some(tag => eventTags.includes(tag)); return tags.some((tag) => eventTags.includes(tag));
}); });
const newPublications = taggedPublications; const newPublications = taggedPublications;
@ -137,9 +137,11 @@ export function findTaggedEventsInFetched(
// For content events, also search in allEvents // For content events, also search in allEvents
const existingContentDTags = new Set( const existingContentDTags = new Set(
baseEvents baseEvents
.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) .filter((e) =>
.map(e => e.tagValue("d")) e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)
.filter(d => d !== undefined) )
.map((e) => e.tagValue("d"))
.filter((d) => d !== undefined),
); );
const contentEventDTags = new Set<string>(); const contentEventDTags = new Set<string>();
@ -148,9 +150,9 @@ export function findTaggedEventsInFetched(
aTags.forEach((tag: string[]) => { aTags.forEach((tag: string[]) => {
// Parse the 'a' tag identifier: kind:pubkey:d-tag // Parse the 'a' tag identifier: kind:pubkey:d-tag
if (tag[1]) { if (tag[1]) {
const parts = tag[1].split(':'); const parts = tag[1].split(":");
if (parts.length >= 3) { if (parts.length >= 3) {
const dTag = parts.slice(2).join(':'); // Handle d-tags with colons const dTag = parts.slice(2).join(":"); // Handle d-tags with colons
if (!existingContentDTags.has(dTag)) { if (!existingContentDTags.has(dTag)) {
contentEventDTags.add(dTag); contentEventDTags.add(dTag);
} }
@ -160,7 +162,7 @@ export function findTaggedEventsInFetched(
}); });
// Find content events in allEvents // Find content events in allEvents
const newContentEvents = allEvents.filter(event => { const newContentEvents = allEvents.filter((event) => {
if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false; if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false;
const dTag = event.tagValue("d"); const dTag = event.tagValue("d");
return dTag !== undefined && contentEventDTags.has(dTag); return dTag !== undefined && contentEventDTags.has(dTag);
@ -168,7 +170,7 @@ export function findTaggedEventsInFetched(
return { return {
publications: newPublications, publications: newPublications,
contentEvents: newContentEvents contentEvents: newContentEvents,
}; };
} }
@ -184,20 +186,30 @@ export function findTaggedEventsInFetched(
export async function fetchProfilesForNewEvents( export async function fetchProfilesForNewEvents(
newPublications: NDKEvent[], newPublications: NDKEvent[],
newContentEvents: NDKEvent[], newContentEvents: NDKEvent[],
onProgressUpdate: (progress: { current: number; total: number } | null) => void, ndk: NDK,
debug?: (...args: any[]) => void onProgressUpdate: (
progress: { current: number; total: number } | null,
) => void,
debug?: (...args: any[]) => void,
): Promise<void> { ): Promise<void> {
const log = debug || console.debug; const log = debug || console.debug;
// Extract pubkeys from new events // Extract pubkeys from new events
const newPubkeys = extractPubkeysFromEvents([...newPublications, ...newContentEvents]); const newPubkeys = extractPubkeysFromEvents([
...newPublications,
...newContentEvents,
]);
if (newPubkeys.size > 0) { if (newPubkeys.size > 0) {
log("Fetching profiles for", newPubkeys.size, "new pubkeys from tag expansion"); log(
"Fetching profiles for",
newPubkeys.size,
"new pubkeys from tag expansion",
);
onProgressUpdate({ current: 0, total: newPubkeys.size }); onProgressUpdate({ current: 0, total: newPubkeys.size });
await batchFetchProfiles(Array.from(newPubkeys), (fetched, total) => { await batchFetchProfiles(Array.from(newPubkeys), ndk, (fetched, total) => {
onProgressUpdate({ current: fetched, total }); onProgressUpdate({ current: fetched, total });
}); });

49
src/lib/utils/websocket_utils.ts

@ -29,13 +29,15 @@ type RejectCallback = (reason?: any) => void;
type EventHandler = (ev: Event) => void; type EventHandler = (ev: Event) => void;
type MessageEventHandler = (ev: MessageEvent) => void; type MessageEventHandler = (ev: MessageEvent) => void;
type EventHandlerReject = (reject: RejectCallback) => EventHandler; type EventHandlerReject = (reject: RejectCallback) => EventHandler;
type EventHandlerResolve<T> = (resolve: ResolveCallback<T>) => (reject: RejectCallback) => MessageEventHandler; type EventHandlerResolve<T> = (
resolve: ResolveCallback<T>,
) => (reject: RejectCallback) => MessageEventHandler;
function handleMessage( function handleMessage(
ev: MessageEvent, ev: MessageEvent,
subId: string, subId: string,
resolve: (event: NostrEvent) => void, resolve: (event: NostrEvent) => void,
reject: (reason: any) => void reject: (reason: any) => void,
) { ) {
const data = JSON.parse(ev.data); const data = JSON.parse(ev.data);
@ -64,12 +66,14 @@ function handleMessage(
function handleError( function handleError(
ev: Event, ev: Event,
reject: (reason: any) => void reject: (reason: any) => void,
) { ) {
reject(ev); reject(ev);
} }
export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent | null> { export async function fetchNostrEvent(
filter: NostrFilter,
): Promise<NostrEvent | null> {
// AI-NOTE: Updated to use active relay stores instead of hardcoded relay URL // AI-NOTE: Updated to use active relay stores instead of hardcoded relay URL
// This ensures the function uses the user's configured relays and can find events // This ensures the function uses the user's configured relays and can find events
// across multiple relays rather than being limited to a single hardcoded relay. // across multiple relays rather than being limited to a single hardcoded relay.
@ -99,7 +103,10 @@ export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent |
const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays]; const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays];
const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates
console.debug(`[fetchNostrEvent] Trying ${uniqueRelays.length} relays for event discovery:`, uniqueRelays); console.debug(
`[fetchNostrEvent] Trying ${uniqueRelays.length} relays for event discovery:`,
uniqueRelays,
);
// Try all available relays in parallel and return the first result // Try all available relays in parallel and return the first result
const relayPromises = uniqueRelays.map(async (relay) => { const relayPromises = uniqueRelays.map(async (relay) => {
@ -110,15 +117,14 @@ export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent |
// AI-NOTE: Currying is used here to abstract the internal handler logic away from the WebSocket // AI-NOTE: Currying is used here to abstract the internal handler logic away from the WebSocket
// handling logic. The message and error handlers themselves can be refactored without affecting // handling logic. The message and error handlers themselves can be refactored without affecting
// the WebSocket handling logic. // the WebSocket handling logic.
const curriedMessageHandler: (subId: string) => (resolve: ResolveCallback<NostrEvent>) => (reject: RejectCallback) => MessageEventHandler = const curriedMessageHandler: (
(subId) => subId: string,
(resolve) => ) => (
(reject) => resolve: ResolveCallback<NostrEvent>,
(ev: MessageEvent) => ) => (reject: RejectCallback) => MessageEventHandler =
(subId) => (resolve) => (reject) => (ev: MessageEvent) =>
handleMessage(ev, subId, resolve, reject); handleMessage(ev, subId, resolve, reject);
const curriedErrorHandler: EventHandlerReject = const curriedErrorHandler: EventHandlerReject = (reject) => (ev: Event) =>
(reject) =>
(ev: Event) =>
handleError(ev, reject); handleError(ev, reject);
// AI-NOTE: These variables store references to partially-applied handlers so that the `finally` // AI-NOTE: These variables store references to partially-applied handlers so that the `finally`
@ -158,7 +164,7 @@ export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent |
// Find the first successful result // Find the first successful result
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === "fulfilled" && result.value) {
return result.value; return result.value;
} }
} }
@ -191,7 +197,10 @@ export async function fetchEventByDTag(dTag: string): Promise<NostrEvent> {
try { try {
const event = await fetchNostrEvent({ "#d": [dTag], limit: 1 }); const event = await fetchNostrEvent({ "#d": [dTag], limit: 1 });
if (!event) { if (!event) {
error(404, `Event not found for d-tag: ${dTag}. href="/events?d=${dTag}"`); error(
404,
`Event not found for d-tag: ${dTag}. href="/events?d=${dTag}"`,
);
} }
return event; return event;
} catch (err) { } catch (err) {
@ -215,7 +224,10 @@ export async function fetchEventByNaddr(naddr: string): Promise<NostrEvent> {
}; };
const event = await fetchNostrEvent(filter); const event = await fetchNostrEvent(filter);
if (!event) { if (!event) {
error(404, `Event not found for naddr: ${naddr}. href="/events?id=${naddr}"`); error(
404,
`Event not found for naddr: ${naddr}. href="/events?id=${naddr}"`,
);
} }
return event; return event;
} catch (err) { } catch (err) {
@ -234,7 +246,10 @@ export async function fetchEventByNevent(nevent: string): Promise<NostrEvent> {
const decoded = neventDecode(nevent); const decoded = neventDecode(nevent);
const event = await fetchNostrEvent({ ids: [decoded.id], limit: 1 }); const event = await fetchNostrEvent({ ids: [decoded.id], limit: 1 });
if (!event) { if (!event) {
error(404, `Event not found for nevent: ${nevent}. href="/events?id=${nevent}"`); error(
404,
`Event not found for nevent: ${nevent}. href="/events?id=${nevent}"`,
);
} }
return event; return event;
} catch (err) { } catch (err) {

29
src/routes/+layout.svelte

@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import Navigation from "$lib/components/Navigation.svelte"; import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte"; import { onMount, setContext } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Alert } from "flowbite-svelte"; import { cleanupNdk } from "$lib/ndk";
import { HammerSolid } from "flowbite-svelte-icons"; import type { LayoutProps } from "./$types";
import { logCurrentRelayConfiguration, activeInboxRelays, activeOutboxRelays, cleanupNdk } from "$lib/ndk";
// Define children prop for Svelte 5 // Define children prop for Svelte 5
let { children } = $props(); let { data, children }: LayoutProps = $props();
setContext("ndk", data.ndk);
// Get standard metadata for OpenGraph tags // Get standard metadata for OpenGraph tags
let title = "Library of Alexandria"; let title = "Library of Alexandria";
@ -20,24 +21,6 @@
let summary = let summary =
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
// AI-NOTE: Refactored to avoid blocking $effect with logging operations
// Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
}
});
onMount(() => { onMount(() => {
const rect = document.body.getBoundingClientRect(); const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`; // document.body.style.height = `${rect.height}px`;

139
src/routes/+layout.ts

@ -1,141 +1,8 @@
import { getPersistedLogin, initNdk, ndkInstance } from "../lib/ndk.ts";
import {
loginWithExtension,
loginWithAmber,
loginWithNpub,
} from "../lib/stores/userStore.ts";
import { loginMethodStorageKey } from "../lib/stores/userStore.ts";
import Pharos, { pharosInstance } from "../lib/parser.ts";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import { get } from "svelte/store"; import { initNdk } from "$lib/ndk";
import { browser } from "$app/environment";
// AI-NOTE: SSR enabled for better SEO and OpenGraph support
export const ssr = true;
/**
* Attempts to restore the user's authentication session from localStorage.
* Handles extension, Amber (NIP-46), and npub login methods.
* Only runs on client-side.
*/
function restoreAuthSession() {
// Only run on client-side
if (!browser) return;
try {
const pubkey = getPersistedLogin();
const loginMethod = localStorage.getItem(loginMethodStorageKey);
const logoutFlag = localStorage.getItem("alexandria/logout/flag");
console.log("Layout load - persisted pubkey:", 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 }) => {
const ndk = get(ndkInstance);
try {
// deno-lint-ignore no-explicit-any
const amberSigner = (NDKNip46Signer as any).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 {
// 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) {
console.warn(
`Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
);
}
}
export const load: LayoutLoad = () => { export const load: LayoutLoad = () => {
// Initialize NDK with new relay management system
const ndk = initNdk();
ndkInstance.set(ndk);
// Only restore auth session on client-side
if (browser) {
restoreAuthSession();
}
const parser = new Pharos(ndk);
pharosInstance.set(parser);
return { return {
ndk, ndk: initNdk(),
parser,
};
}; };
}

7
src/routes/contact/+page.svelte

@ -9,9 +9,9 @@
Input, Input,
Modal, Modal,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { ndkInstance, ndkSignedIn, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { communityRelays, anonymousRelays } from "$lib/consts"; import { anonymousRelays } 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
@ -21,6 +21,8 @@
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";
const ndk = getNdkContext();
// Function to close the success message // Function to close the success message
function closeSuccessMessage() { function closeSuccessMessage() {
submissionSuccess = false; submissionSuccess = false;
@ -193,7 +195,6 @@
try { try {
// Get NDK instance // Get NDK instance
const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
throw new Error("NDK instance not available"); throw new Error("NDK instance not available");
} }

232
src/routes/events/+page.svelte

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -9,19 +8,17 @@
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 CommentViewer from "$lib/components/CommentViewer.svelte"; import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import EventInput from "$lib/components/EventInput.svelte"; import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
import { getEventType } from "$lib/utils/mime"; import { getEventType } from "$lib/utils/mime";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility"; import { checkCommunity } from "$lib/utils/search_utility";
import { parseRepostContent, parseContent } from "$lib/utils/notification_utils"; import EmbeddedEvent from "$lib/components/embedded_events/EmbeddedEvent.svelte";
import { fetchCurrentUserLists, isPubkeyInUserLists } from "$lib/utils/user_lists";
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -45,7 +42,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let user = $state($userStore);
let userRelayPreference = $state(false); let userRelayPreference = $state(false);
let showSidePanel = $state(false); let showSidePanel = $state(false);
let searchInProgress = $state(false); let searchInProgress = $state(false);
@ -53,59 +49,15 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
let communityStatus = $state<Record<string, boolean>>({}); let communityStatus = $state<Record<string, boolean>>({});
let searchResultsCollapsed = $state(false); let searchResultsCollapsed = $state(false);
userStore.subscribe((val) => (user = val));
// Debug: Check if user is logged in
$effect(() => {
console.log("[Events Page] User state:", user);
console.log("[Events Page] User signed in:", user?.signedIn);
console.log("[Events Page] User pubkey:", user?.pubkey);
});
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true; showSidePanel = true;
// AI-NOTE: 2025-01-24 - Preserve search results to allow navigation through them // AI-NOTE: 2025-01-24 - Preserve search results to allow navigation through them
// Don't clear search results when showing a single event - this allows users to browse through results // Don't clear search results when showing a single event - this allows users to browse through results
// searchResults = [];
// secondOrderResults = [];
// tTagResults = [];
// originalEventIds = new Set();
// originalAddresses = new Set();
// searchType = null;
// searchTerm = null;
// searchInProgress = false;
// secondOrderSearchMessage = null;
// AI-NOTE: 2025-01-24 - Properly parse profile data for kind 0 events
if (newEvent.kind === 0) { if (newEvent.kind === 0) {
try { try {
const parsedProfile = parseProfileContent(newEvent); profile = JSON.parse(newEvent.content);
if (parsedProfile) { } catch {
profile = parsedProfile;
console.log("[Events Page] Parsed profile data:", parsedProfile);
// If the event doesn't have user list information, fetch it
if (typeof parsedProfile.isInUserLists !== 'boolean') {
fetchCurrentUserLists()
.then((userLists) => {
const isInLists = isPubkeyInUserLists(newEvent.pubkey, userLists);
// Update the profile with user list information
profile = { ...parsedProfile, isInUserLists: isInLists } as any;
// Also update the event's profileData
(newEvent as any).profileData = { ...parsedProfile, isInUserLists: isInLists };
})
.catch(() => {
profile = { ...parsedProfile, isInUserLists: false } as any;
(newEvent as any).profileData = { ...parsedProfile, isInUserLists: false };
});
}
} else {
console.warn("[Events Page] Failed to parse profile content for event:", newEvent.id);
profile = null;
}
} catch (error) {
console.error("[Events Page] Error parsing profile content:", error);
profile = null; profile = null;
} }
} else { } else {
@ -115,20 +67,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author // AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author
if (newEvent.pubkey) { if (newEvent.pubkey) {
cacheProfileForPubkey(newEvent.pubkey); cacheProfileForPubkey(newEvent.pubkey);
// Update profile data with user list information
updateProfileDataWithUserLists([newEvent]);
// Also check community status for the individual event
if (!communityStatus[newEvent.pubkey]) {
checkCommunity(newEvent.pubkey)
.then((status) => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: status };
})
.catch(() => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: false };
});
}
} }
} }
@ -207,7 +145,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
addresses: Set<string> = new Set(), addresses: Set<string> = new Set(),
searchTypeParam?: string, searchTypeParam?: string,
searchTermParam?: string, searchTermParam?: string,
loading: boolean = false, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic
) { ) {
searchResults = results; searchResults = results;
secondOrderResults = secondOrder; secondOrderResults = secondOrder;
@ -257,10 +194,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// AI-NOTE: 2025-01-24 - Cache profiles for all search results // AI-NOTE: 2025-01-24 - Cache profiles for all search results
cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]); cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]);
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
} }
// AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events // AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events
@ -278,31 +211,9 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey)); const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey));
await Promise.allSettled(cachePromises); await Promise.allSettled(cachePromises);
// AI-NOTE: 2025-01-24 - Update profile data with user list information for cached events
await updateProfileDataWithUserLists(events);
console.log(`[Events Page] Profile caching complete`); console.log(`[Events Page] Profile caching complete`);
} }
// AI-NOTE: 2025-01-24 - Function to update profile data with user list information
async function updateProfileDataWithUserLists(events: NDKEvent[]) {
try {
const userLists = await fetchCurrentUserLists();
for (const event of events) {
if (event.kind === 0 && event.pubkey) {
const existingProfileData = (event as any).profileData || parseProfileContent(event);
if (existingProfileData) {
const isInLists = isPubkeyInUserLists(event.pubkey, userLists);
(event as any).profileData = { ...existingProfileData, isInUserLists: isInLists };
}
}
}
} catch (error) {
console.warn("[Events Page] Failed to update profile data with user lists:", error);
}
}
function handleClear() { function handleClear() {
searchType = null; searchType = null;
searchTerm = null; searchTerm = null;
@ -380,8 +291,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
website?: string; website?: string;
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
isInUserLists?: boolean;
listKinds?: number[];
} | null { } | null {
if (event.kind !== 0 || !event.content) { if (event.kind !== 0 || !event.content) {
return null; return null;
@ -402,10 +311,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
return neventEncode(event, $activeInboxRelays); return neventEncode(event, $activeInboxRelays);
} }
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function isAddressableEvent(event: NDKEvent): boolean { function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable"; return getEventType(event.kind || 0) === "addressable";
} }
@ -437,16 +342,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
return addr.slice(0, head) + "…" + addr.slice(-tail); return addr.slice(0, head) + "…" + addr.slice(-tail);
} }
function formatEventDate(event: NDKEvent): string {
if (event.created_at) {
return new Date(event.created_at * 1000).toLocaleDateString();
}
if ((event as any).timestamp) {
return new Date((event as any).timestamp * 1000).toLocaleDateString();
}
return "Unknown date";
}
function onLoadingChange(val: boolean) { function onLoadingChange(val: boolean) {
loading = val; loading = val;
searchInProgress = searchInProgress =
@ -476,30 +371,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
} }
} }
// AI-NOTE: 2025-01-24 - Ensure proper reactivity by creating a new object
communityStatus = { ...communityStatus, ...newCommunityStatus }; communityStatus = { ...communityStatus, ...newCommunityStatus };
console.log("Community status updated:", communityStatus);
}
// AI-NOTE: Refactored to avoid blocking $effect with logging operations
// Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Events Page - Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
} }
});
</script> </script>
@ -574,7 +447,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4">
{#each searchResults as result, index} {#each searchResults as result, index}
{@const profileData = (result as any).profileData || parseProfileContent(result)} {@const profileData = parseProfileContent(result)}
<button <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" 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)} onclick={() => handleEventFound(result)}
@ -588,22 +461,6 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if profileData?.isInUserLists}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{/if}
{#if result.pubkey && communityStatus[result.pubkey]} {#if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
@ -619,8 +476,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
/> />
</svg> </svg>
</div> </div>
{/if} {:else}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div> <div class="flex-shrink-0 w-4 h-4"></div>
{/if} {/if}
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
@ -632,7 +488,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{formatEventDate(result)} {result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
{#if result.kind === 0 && profileData} {#if result.kind === 0 && profileData}
@ -714,11 +574,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
{#await ((result.kind === 6 || result.kind === 16) ? parseRepostContent(result.content) : parseContent(result.content)) then parsedContent} <EmbeddedEvent nostrIdentifier={result.id} nestingLevel={0} />
{@html parsedContent.slice(0, 200)}{parsedContent.length > 200 ? "..." : ""}
{:catch}
{result.content.slice(0, 200)}{result.content.length > 200 ? "..." : ""}
{/await}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -761,22 +617,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if profileData?.isInUserLists} {#if result.pubkey && communityStatus[result.pubkey]}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -803,7 +644,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{formatEventDate(result)} {result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1"> <div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
@ -892,11 +737,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
{#await ((result.kind === 6 || result.kind === 16) ? parseRepostContent(result.content) : parseContent(result.content)) then parsedContent} <EmbeddedEvent nostrIdentifier={result.id} nestingLevel={0} />
{@html parsedContent.slice(0, 200)}{parsedContent.length > 200 ? "..." : ""}
{:catch}
{result.content.slice(0, 200)}{result.content.length > 200 ? "..." : ""}
{/await}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -933,22 +774,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if profileData?.isInUserLists} {#if result.pubkey && communityStatus[result.pubkey]}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -975,7 +801,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{formatEventDate(result)} {result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
{#if result.kind === 0 && profileData} {#if result.kind === 0 && profileData}
@ -1057,11 +887,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<div <div
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
> >
{#await ((result.kind === 6 || result.kind === 16) ? parseRepostContent(result.content) : parseContent(result.content)) then parsedContent} <EmbeddedEvent nostrIdentifier={result.id} nestingLevel={0} />
{@html parsedContent.slice(0, 200)}{parsedContent.length > 200 ? "..." : ""}
{:catch}
{result.content.slice(0, 200)}{result.content.length > 200 ? "..." : ""}
{/await}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -1116,7 +942,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
{/if} {/if}
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} {searchValue} communityStatusMap={communityStatus} /> <EventDetails {event} {profile} />
</div> </div>
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<RelayActions {event} /> <RelayActions {event} />
@ -1126,7 +952,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<CommentViewer {event} /> <CommentViewer {event} />
</div> </div>
{#if user?.signedIn} {#if isLoggedIn && userPubkey}
<div class="mt-8 min-w-0 overflow-hidden"> <div class="mt-8 min-w-0 overflow-hidden">
<Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading> <Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} /> <CommentBox {event} {userRelayPreference} />

6
src/routes/my-notes/+page.svelte

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getTitleTagForEvent } from "$lib/utils/event_input_utils"; import { getTitleTagForEvent } from "$lib/utils/event_input_utils";
import asciidoctor from "asciidoctor"; import asciidoctor from "asciidoctor";
import { postProcessAsciidoctorHtml } from "$lib/utils/markup/asciidoctorPostProcessor"; import { postProcessAsciidoctorHtml } from "$lib/utils/markup/asciidoctorPostProcessor";
import { getNdkContext } from "$lib/ndk";
const ndk = getNdkContext();
let events: NDKEvent[] = $state([]); let events: NDKEvent[] = $state([]);
let loading = $state(true); let loading = $state(true);
@ -42,7 +43,6 @@
loading = false; loading = false;
return; return;
} }
const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
error = "NDK not initialized."; error = "NDK not initialized.";
loading = false; loading = false;

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

@ -4,7 +4,6 @@
Textarea, Textarea,
Toolbar, Toolbar,
ToolbarButton, ToolbarButton,
Tooltip,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { import {
CodeOutline, CodeOutline,
@ -13,8 +12,11 @@
} from "flowbite-svelte-icons"; } 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 { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getNdkContext } from "$lib/ndk";
const ndk = getNdkContext();
let someIndexValue = 0; let someIndexValue = 0;
// TODO: Prompt user to sign in before editing. // TODO: Prompt user to sign in before editing.
@ -26,7 +28,7 @@
const showPreview = () => { const showPreview = () => {
try { try {
$pharosInstance ??= new Pharos($ndkInstance); $pharosInstance ??= new Pharos(ndk);
$pharosInstance.reset(); $pharosInstance.reset();
$pharosInstance.parse(editorText); $pharosInstance.parse(editorText);
} catch (e) { } catch (e) {
@ -53,7 +55,7 @@
return; return;
} }
$pharosInstance.generate($ndkInstance.activeUser?.pubkey!); $pharosInstance.generate(ndk.activeUser?.pubkey!);
goto("/new/compose"); goto("/new/compose");
}; };
</script> </script>

5
src/routes/proxy+layout.ts

@ -1,5 +0,0 @@
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async () => {
return {};
};

2
src/routes/publication/+page.server.ts

@ -17,7 +17,7 @@ const IDENTIFIER_PREFIXES = {
NEVENT: "nevent", NEVENT: "nevent",
} as const; } as const;
export const load: PageServerLoad = ({ url }) => { export const load: PageServerLoad = ({ url }: { url: URL }) => {
const id = url.searchParams.get("id"); const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d"); const dTag = url.searchParams.get("d");

24
src/routes/publication/[type]/[identifier]/+layout.server.ts

@ -1,33 +1,11 @@
import { error } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
// AI-NOTE: Server-side event fetching for SEO metadata
async function fetchEventServerSide(type: string, identifier: string): Promise<NostrEvent | null> {
// For now, return null to indicate server-side fetch not implemented
// This will fall back to client-side fetching
return null;
}
export const load: LayoutServerLoad = async ({ params, url }) => { export const load: LayoutServerLoad = ({ url }: { url: URL }) => {
const { type, identifier } = params;
// Try to fetch event server-side for metadata
const indexEvent = await fetchEventServerSide(type, identifier);
// Extract metadata for meta tags (use fallbacks if no event found)
const title = indexEvent?.tags.find((tag) => tag[0] === "title")?.[1] || "Alexandria Publication";
const summary = indexEvent?.tags.find((tag) => tag[0] === "summary")?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
const image = indexEvent?.tags.find((tag) => tag[0] === "image")?.[1] || "/screenshots/old_books.jpg";
const currentUrl = `${url.origin}${url.pathname}`; const currentUrl = `${url.origin}${url.pathname}`;
return { return {
indexEvent, // Will be null, triggering client-side fetch
metadata: { metadata: {
title,
summary,
image,
currentUrl, currentUrl,
}, },
}; };

53
src/routes/publication/[type]/[identifier]/+page.ts

@ -1,30 +1,36 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts"; import {
fetchEventByDTag,
fetchEventById,
fetchEventByNaddr,
fetchEventByNevent,
} from "../../../../lib/utils/websocket_utils.ts";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts"; import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
export const load: PageLoad = async ({ params, parent }: { params: { type: string; identifier: string }; parent: any }) => { export const load: PageLoad = async (
{ params }: {
params: { type: string; identifier: string };
},
) => {
const { type, identifier } = params; const { type, identifier } = params;
// Get layout data (no server-side data since SSR is disabled)
const layoutData = await parent();
// AI-NOTE: Always fetch client-side since server-side fetch returns null for now // AI-NOTE: Always fetch client-side since server-side fetch returns null for now
let indexEvent: NostrEvent | null = null; let indexEvent: NostrEvent | null = null;
try { try {
// Handle different identifier types // Handle different identifier types
switch (type) { switch (type) {
case 'id': case "id":
indexEvent = await fetchEventById(identifier); indexEvent = await fetchEventById(identifier);
break; break;
case 'd': case "d":
indexEvent = await fetchEventByDTag(identifier); indexEvent = await fetchEventByDTag(identifier);
break; break;
case 'naddr': case "naddr":
indexEvent = await fetchEventByNaddr(identifier); indexEvent = await fetchEventByNaddr(identifier);
break; break;
case 'nevent': case "nevent":
indexEvent = await fetchEventByNevent(identifier); indexEvent = await fetchEventByNevent(identifier);
break; break;
default: default:
@ -39,41 +45,34 @@ export const load: PageLoad = async ({ params, parent }: { params: { type: strin
// This prevents 404 errors when relay stores haven't been populated yet // This prevents 404 errors when relay stores haven't been populated yet
// Create appropriate search link based on type // Create appropriate search link based on type
let searchParam = ''; let searchParam = "";
switch (type) { switch (type) {
case 'id': case "id":
searchParam = `id=${identifier}`; searchParam = `id=${identifier}`;
break; break;
case 'd': case "d":
searchParam = `d=${identifier}`; searchParam = `d=${identifier}`;
break; break;
case 'naddr': case "naddr":
case 'nevent': case "nevent":
searchParam = `id=${identifier}`; searchParam = `id=${identifier}`;
break; break;
default: default:
searchParam = `q=${identifier}`; searchParam = `q=${identifier}`;
} }
error(404, `Event not found for ${type}: ${identifier}. href="/events?${searchParam}"`); error(
404,
`Event not found for ${type}: ${identifier}. href="/events?${searchParam}"`,
);
} }
const publicationType = indexEvent.tags.find((tag) => tag[0] === "type")?.[1] ?? ""; const publicationType =
indexEvent.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
// AI-NOTE: Use proper NDK instance from layout or create one with relays
let ndk = layoutData?.ndk;
if (!ndk) {
// Import NDK dynamically to avoid SSR issues
const NDK = (await import("@nostr-dev-kit/ndk")).default;
// Import initNdk to get properly configured NDK with relays
const { initNdk } = await import("$lib/ndk");
ndk = initNdk();
}
const result = { const result = {
publicationType, publicationType,
indexEvent, indexEvent,
ndk, // Use minimal NDK instance
}; };
return result; return result;

23
src/routes/visualize/+page.svelte

@ -8,7 +8,6 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte"; import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils"; import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state"; import { networkFetchLimit } from "$lib/state";
@ -17,7 +16,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache"; import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache";
import { userStore } from "$lib/stores/userStore"; import { activePubkey, getNdkContext } from "$lib/ndk";
// Import utility functions for tag-based event fetching // Import utility functions for tag-based event fetching
// These functions handle the complex logic of finding publications by tags // These functions handle the complex logic of finding publications by tags
// and extracting their associated content events // and extracting their associated content events
@ -29,6 +28,8 @@
import { deduplicateAndCombineEvents } from "$lib/utils/eventDeduplication"; import { deduplicateAndCombineEvents } from "$lib/utils/eventDeduplication";
import type { EventCounts } from "$lib/types"; import type { EventCounts } from "$lib/types";
const ndk = getNdkContext();
// Configuration // Configuration
const DEBUG = true; // Set to true to enable debug logging const DEBUG = true; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040; const INDEX_EVENT_KIND = 30040;
@ -122,7 +123,7 @@
} }
// Get the current user's pubkey // Get the current user's pubkey
const currentUserPubkey = get(userStore).pubkey; const currentUserPubkey = get(activePubkey);
if (!currentUserPubkey) { if (!currentUserPubkey) {
console.warn("No logged-in user, cannot fetch user's follow list"); console.warn("No logged-in user, cannot fetch user's follow list");
return []; return [];
@ -130,7 +131,7 @@
// If limit is 1, only fetch the current user's follow list // If limit is 1, only fetch the current user's follow list
if (config.limit === 1) { if (config.limit === 1) {
const userFollowList = await $ndkInstance.fetchEvents({ const userFollowList = await ndk.fetchEvents({
kinds: [3], kinds: [3],
authors: [currentUserPubkey], authors: [currentUserPubkey],
limit: 1 limit: 1
@ -148,7 +149,7 @@
debug(`Fetched user's follow list`); debug(`Fetched user's follow list`);
} else { } else {
// If limit > 1, fetch the user's follow list plus additional ones from people they follow // If limit > 1, fetch the user's follow list plus additional ones from people they follow
const userFollowList = await $ndkInstance.fetchEvents({ const userFollowList = await ndk.fetchEvents({
kinds: [3], kinds: [3],
authors: [currentUserPubkey], authors: [currentUserPubkey],
limit: 1 limit: 1
@ -180,7 +181,7 @@
debug(`Fetching ${pubkeysToFetch.length} additional follow lists (total limit: ${config.limit})`); debug(`Fetching ${pubkeysToFetch.length} additional follow lists (total limit: ${config.limit})`);
const additionalFollowLists = await $ndkInstance.fetchEvents({ const additionalFollowLists = await ndk.fetchEvents({
kinds: [3], kinds: [3],
authors: pubkeysToFetch authors: pubkeysToFetch
}); });
@ -215,7 +216,7 @@
debug(`Fetching level ${level} follow lists for ${currentLevelPubkeys.length} pubkeys`); debug(`Fetching level ${level} follow lists for ${currentLevelPubkeys.length} pubkeys`);
// Fetch follow lists for this level // Fetch follow lists for this level
const levelFollowLists = await $ndkInstance.fetchEvents({ const levelFollowLists = await ndk.fetchEvents({
kinds: [3], kinds: [3],
authors: currentLevelPubkeys authors: currentLevelPubkeys
}); });
@ -362,7 +363,7 @@
const followEvents = await fetchFollowLists(config); const followEvents = await fetchFollowLists(config);
allFetchedEvents.push(...followEvents); allFetchedEvents.push(...followEvents);
} else { } else {
const fetchedEvents = await $ndkInstance.fetchEvents( const fetchedEvents = await ndk.fetchEvents(
{ {
kinds: [config.kind], kinds: [config.kind],
limit: config.limit limit: config.limit
@ -394,7 +395,7 @@
if (data.eventId) { if (data.eventId) {
// Fetch specific publication // Fetch specific publication
debug(`Fetching specific publication: ${data.eventId}`); debug(`Fetching specific publication: ${data.eventId}`);
const event = await $ndkInstance.fetchEvent(data.eventId); const event = await ndk.fetchEvent(data.eventId);
if (!event) { if (!event) {
throw new Error(`Publication not found: ${data.eventId}`); throw new Error(`Publication not found: ${data.eventId}`);
@ -414,7 +415,7 @@
const indexConfig = publicationConfigs.find(ec => ec.kind === INDEX_EVENT_KIND); const indexConfig = publicationConfigs.find(ec => ec.kind === INDEX_EVENT_KIND);
const indexLimit = indexConfig?.limit || 20; const indexLimit = indexConfig?.limit || 20;
const indexEvents = await $ndkInstance.fetchEvents( const indexEvents = await ndk.fetchEvents(
{ {
kinds: [INDEX_EVENT_KIND], kinds: [INDEX_EVENT_KIND],
limit: indexLimit limit: indexLimit
@ -455,7 +456,7 @@
const contentEventPromises = Array.from(referencesByAuthor.entries()).map( const contentEventPromises = Array.from(referencesByAuthor.entries()).map(
async ([author, refs]) => { async ([author, refs]) => {
const dTags = [...new Set(refs.map(r => r.dTag))]; // Dedupe d-tags const dTags = [...new Set(refs.map(r => r.dTag))]; // Dedupe d-tags
return $ndkInstance.fetchEvents({ return ndk.fetchEvents({
kinds: enabledContentKinds, // Only fetch enabled kinds kinds: enabledContentKinds, // Only fetch enabled kinds
authors: [author], authors: [author],
"#d": dTags, "#d": dTags,

6
src/routes/visualize/+page.ts

@ -1,9 +1,9 @@
import type { PageLoad } from './$types'; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url }) => {
const eventId = url.searchParams.get('event'); const eventId = url.searchParams.get("event");
return { return {
eventId eventId,
}; };
}; };

5
src/styles/events.css

@ -1,5 +0,0 @@
@layer components {
canvas.qr-code {
@apply block mx-auto my-4;
}
}

8
src/styles/notifications.css

@ -151,7 +151,13 @@
/* Transition utilities */ /* Transition utilities */
.transition-colors { .transition-colors {
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, text-decoration-color 0.15s ease-in-out, fill 0.15s ease-in-out, stroke 0.15s ease-in-out; transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
text-decoration-color 0.15s ease-in-out,
fill 0.15s ease-in-out,
stroke 0.15s ease-in-out;
} }
.transition-all { .transition-all {

18
src/styles/publications.css

@ -100,7 +100,8 @@
/* blockquote; prose and poetry quotes */ /* blockquote; prose and poetry quotes */
.publication-leather .quoteblock, .publication-leather .quoteblock,
.publication-leather .verseblock { .publication-leather .verseblock {
@apply p-4 my-4 border-s-4 rounded border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-700; @apply p-4 my-4 border-s-4 rounded border-primary-300 bg-primary-50
dark:border-primary-500 dark:bg-primary-700;
} }
.publication-leather .verseblock pre.content { .publication-leather .verseblock pre.content {
@ -154,7 +155,8 @@
} }
.publication-leather .admonitionblock.tip { .publication-leather .admonitionblock.tip {
@apply rounded overflow-hidden border border-success-100 dark:border-success-800; @apply rounded overflow-hidden border border-success-100
dark:border-success-800;
} }
.publication-leather .admonitionblock.tip .icon, .publication-leather .admonitionblock.tip .icon,
@ -172,7 +174,8 @@
} }
.publication-leather .admonitionblock.important { .publication-leather .admonitionblock.important {
@apply rounded overflow-hidden border border-primary-200 dark:border-primary-700; @apply rounded overflow-hidden border border-primary-200
dark:border-primary-700;
} }
.publication-leather .admonitionblock.important .icon, .publication-leather .admonitionblock.important .icon,
@ -181,7 +184,8 @@
} }
.publication-leather .admonitionblock.caution { .publication-leather .admonitionblock.caution {
@apply rounded overflow-hidden border border-warning-200 dark:border-warning-700; @apply rounded overflow-hidden border border-warning-200
dark:border-warning-700;
} }
.publication-leather .admonitionblock.caution .icon, .publication-leather .admonitionblock.caution .icon,
@ -190,7 +194,8 @@
} }
.publication-leather .admonitionblock.warning { .publication-leather .admonitionblock.warning {
@apply rounded overflow-hidden border border-danger-200 dark:border-danger-800; @apply rounded overflow-hidden border border-danger-200
dark:border-danger-800;
} }
.publication-leather .admonitionblock.warning .icon, .publication-leather .admonitionblock.warning .icon,
@ -254,7 +259,8 @@
@screen lg { @screen lg {
@media (hover: hover) { @media (hover: hover) {
.blog .discreet .card-leather:not(:hover) { .blog .discreet .card-leather:not(:hover) {
@apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition duration-500 ease-in-out; @apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition
duration-500 ease-in-out;
} }
.blog .discreet .group { .blog .discreet .group {
@apply bg-transparent; @apply bg-transparent;

6
src/styles/scrollbar.css

@ -1,7 +1,8 @@
@layer components { @layer components {
/* Global scrollbar styles */ /* Global scrollbar styles */
* { * {
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */ scrollbar-color: rgba(87, 66, 41, 0.8)
transparent; /* Transparent track, default scrollbar thumb */
} }
/* Webkit Browsers (Chrome, Safari, Edge) */ /* Webkit Browsers (Chrome, Safari, Edge) */
@ -14,7 +15,8 @@
} }
*::-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 */
} }
} }

28
src/styles/visualize.css

@ -30,7 +30,8 @@
} }
.legend-letter { .legend-letter {
@apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; @apply absolute inset-0 flex items-center justify-center text-black text-xs
font-bold;
} }
.legend-text { .legend-text {
@ -39,7 +40,8 @@
/* Network visualization styles - specific to visualization */ /* Network visualization styles - specific to visualization */
.network-container { .network-container {
@apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px] max-h-[900px]; @apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px]
max-h-[900px];
} }
.network-svg-container { .network-svg-container {
@ -48,11 +50,15 @@
.network-svg { .network-svg {
@apply w-full sm:h-[100%] border; @apply w-full sm:h-[100%] border;
@apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 rounded; @apply border border-primary-200 has-[:hover]:border-primary-700
dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500
rounded;
} }
.network-error { .network-error {
@apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200
rounded-lg mb-4;
} }
.network-error-title { .network-error-title {
@ -78,8 +84,9 @@
/* Tooltip styles - specific to visualization tooltips */ /* Tooltip styles - specific to visualization tooltips */
.tooltip-close-btn { .tooltip-close-btn {
@apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700
rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200; dark:hover:bg-gray-600 rounded-full p-1 text-gray-500 hover:text-gray-700
dark:text-gray-400 dark:hover:text-gray-200;
} }
.tooltip-content { .tooltip-content {
@ -91,7 +98,8 @@
} }
.tooltip-title-link { .tooltip-title-link {
@apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; @apply text-gray-800 hover:text-blue-600 dark:text-gray-200
dark:hover:text-blue-400;
} }
.tooltip-metadata { .tooltip-metadata {
@ -99,11 +107,13 @@
} }
.tooltip-summary { .tooltip-summary {
@apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto
max-h-40;
} }
.tooltip-content-preview { .tooltip-content-preview {
@apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto
max-h-40;
} }
.tooltip-help-text { .tooltip-help-text {

85
test_data/LaTeXtestfile.md

@ -1,12 +1,24 @@
# This is a testfile for writing mathematic formulas in NostrMarkup # This is a testfile for writing mathematic formulas in NostrMarkup
This document covers the rendering of formulas in TeX/LaTeX and AsciiMath notation, or some combination of those within the same page. It is meant to be rendered by clients utilizing MathJax. This document covers the rendering of formulas in TeX/LaTeX and AsciiMath
notation, or some combination of those within the same page. It is meant to be
If you want the entire document to be rendered as mathematics, place the entire thing in a backtick-codeblock, but know that this makes the document slower to load, it is harder to format the prose, and the result is less legible. It also doesn't increase portability, as it's easy to export markup as LaTeX files, or as PDFs, with the formulas rendered. rendered by clients utilizing MathJax.
The general idea, is that anything placed within `single backticks` is inline code, and inline-code will all be scanned for typical mathematics statements and rendered with best-effort. (For more precise rendering, use Asciidoc.) We will not render text that is not marked as inline code, as mathematical formulas, as that is prose. If you want the entire document to be rendered as mathematics, place the entire
thing in a backtick-codeblock, but know that this makes the document slower to
If you want the TeX to be blended into the surrounding text, wrap the text within single `$`. Otherwise, use double `$$` symbols, for display math, and it will appear on its own line. load, it is harder to format the prose, and the result is less legible. It also
doesn't increase portability, as it's easy to export markup as LaTeX files, or
as PDFs, with the formulas rendered.
The general idea, is that anything placed within `single backticks` is inline
code, and inline-code will all be scanned for typical mathematics statements and
rendered with best-effort. (For more precise rendering, use Asciidoc.) We will
not render text that is not marked as inline code, as mathematical formulas, as
that is prose.
If you want the TeX to be blended into the surrounding text, wrap the text
within single `$`. Otherwise, use double `$$` symbols, for display math, and it
will appear on its own line.
## TeX Examples ## TeX Examples
@ -16,36 +28,25 @@ Same equation, in the display mode: `$$\sqrt{x}$$`
Something more complex, inline: `$\mathbb{N} = \{ a \in \mathbb{Z} : a > 0 \}$` Something more complex, inline: `$\mathbb{N} = \{ a \in \mathbb{Z} : a > 0 \}$`
Something complex, in display mode: `$$P \left( A=2 \, \middle| \, \dfrac{A^2}{B}>4 \right)$$` Something complex, in display mode:
`$$P \left( A=2 \, \middle| \, \dfrac{A^2}{B}>4 \right)$$`
Another example of `$$\prod_{i=1}^{n} x_i - 1$$` inline formulas. Another example of `$$\prod_{i=1}^{n} x_i - 1$$` inline formulas.
Function example: Function example: `$$ f(x)= \begin{cases} 1/d_{ij} & \quad \text{when
`$$ $d_{ij} \leq 160$}\\ 0 & \quad \text{otherwise} \end{cases}
f(x)=
\begin{cases}
1/d_{ij} & \quad \text{when $d_{ij} \leq 160$}\\
0 & \quad \text{otherwise}
\end{cases}
$$ $$ `
`
And a matrix: And a matrix: ` $$
`
$$
M = M = \begin{bmatrix} \frac{5}{6} & \frac{1}{6} & 0 \\[0.3em] \frac{5}{6} & 0 &
\begin{bmatrix} \frac{1}{6} \\[0.3em] 0 & \frac{5}{6} & \frac{1}{6} \end{bmatrix}
\frac{5}{6} & \frac{1}{6} & 0 \\[0.3em]
\frac{5}{6} & 0 & \frac{1}{6} \\[0.3em]
0 & \frac{5}{6} & \frac{1}{6}
\end{bmatrix}
$$ $$ `
`
LaTeX ypesetting won't be rendered. Use NostrMarkup delimeter tables for this sort of thing. LaTeX ypesetting won't be rendered. Use NostrMarkup delimeter tables for this
sort of thing.
`\\begin{tabular}{|c|c|c|l|r|} `\\begin{tabular}{|c|c|c|l|r|}
\\hline \\hline
@ -69,13 +70,17 @@ We also recognize common LaTeX statements:
Greek letters are a snap: `$\Psi$`, `$\psi$`, `$\Phi$`, `$\phi$`. Greek letters are a snap: `$\Psi$`, `$\psi$`, `$\Phi$`, `$\phi$`.
Equations within text are easy--- A well known Maxwell thermodynamic relation is `$\left.{\partial T \over \partial P}\right|_{s} = \left.{\partial v \over \partial s}\right|_{P}$`. Equations within text are easy--- A well known Maxwell thermodynamic relation is
`$\left.{\partial T \over \partial P}\right|_{s} = \left.{\partial v \over \partial s}\right|_{P}$`.
You can also set aside equations like so: `\begin{eqnarray} du &=& T\ ds -P\ dv, \qquad \mbox{first law.}\label{fl}\\ ds &\ge& {\delta q \over T}.\qquad \qquad \mbox{second law.} \label{sl} \end {eqnarray}` You can also set aside equations like so:
`\begin{eqnarray} du &=& T\ ds -P\ dv, \qquad \mbox{first law.}\label{fl}\\ ds &\ge& {\delta q \over T}.\qquad \qquad \mbox{second law.} \label{sl} \end {eqnarray}`
## And some good ole Asciimath ## And some good ole Asciimath
Asciimath doesn't use `$` or `$$` delimiters, but we are using it to make mathy stuff easier to find. If you want it inline, include it inline. If you want it on a separate line, put a hard-return before and after. Asciimath doesn't use `$` or `$$` delimiters, but we are using it to make mathy
stuff easier to find. If you want it inline, include it inline. If you want it
on a separate line, put a hard-return before and after.
Inline text example here `$E=mc^2$` and another `$1/(x+1)$`; very simple. Inline text example here `$E=mc^2$` and another `$1/(x+1)$`; very simple.
@ -109,19 +114,23 @@ Using the quadratic formula, the roots of `$x^2-6x+4=0$` are
Advanced alignment and matrices looks like this: Advanced alignment and matrices looks like this:
A `$3xx3$` matrix, `$$((1,2,3),(4,5,6),(7,8,9))$$` and a `$2xx1$` matrix, or vector, `$$((1),(0))$$`. A `$3xx3$` matrix, `$$((1,2,3),(4,5,6),(7,8,9))$$` and a `$2xx1$` matrix, or
vector, `$$((1),(0))$$`.
The outer brackets determine the delimiters e.g. `$|(a,b),(c,d)|=ad-bc$`. The outer brackets determine the delimiters e.g. `$|(a,b),(c,d)|=ad-bc$`.
A general `$m xx n$` matrix `$$((a_(11), cdots , a_(1n)),(vdots, ddots, vdots),(a_(m1), cdots , a_(mn)))$$` A general `$m xx n$` matrix
`$$((a_(11), cdots , a_(1n)),(vdots, ddots, vdots),(a_(m1), cdots , a_(mn)))$$`
## Mixed Examples ## Mixed Examples
Here are some examples mixing LaTeX and AsciiMath: Here are some examples mixing LaTeX and AsciiMath:
- LaTeX inline: `$\frac{1}{2}$` vs AsciiMath inline: `$1/2$` - LaTeX inline: `$\frac{1}{2}$` vs AsciiMath inline: `$1/2$`
- LaTeX display: `$$\sum_{i=1}^n x_i$$` vs AsciiMath display: `$$sum_(i=1)^n x_i$$` - LaTeX display: `$$\sum_{i=1}^n x_i$$` vs AsciiMath display:
- LaTeX matrix: `$$\begin{pmatrix} a & b \\ c & d \end{pmatrix}$$` vs AsciiMath matrix: `$$((a,b),(c,d))$$` `$$sum_(i=1)^n x_i$$`
- LaTeX matrix: `$$\begin{pmatrix} a & b \\ c & d \end{pmatrix}$$` vs AsciiMath
matrix: `$$((a,b),(c,d))$$`
## Edge Cases ## Edge Cases
@ -134,9 +143,9 @@ Here are some examples mixing LaTeX and AsciiMath:
- CSS with dollar signs: `color: $primary-color` - CSS with dollar signs: `color: $primary-color`
This document should demonstrate that: This document should demonstrate that:
1. LaTeX is processed within inline code blocks with proper delimiters 1. LaTeX is processed within inline code blocks with proper delimiters
2. AsciiMath is processed within inline code blocks with proper delimiters 2. AsciiMath is processed within inline code blocks with proper delimiters
3. Regular code blocks remain unchanged 3. Regular code blocks remain unchanged
4. Mixed content is handled correctly 4. Mixed content is handled correctly
5. Edge cases are handled gracefully 5. Edge cases are handled gracefully $$
$$

24
tests/e2e/my_notes_layout.pw.spec.ts

@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'; import { expect, type Page, test } from "@playwright/test";
// Utility to check for horizontal scroll bar // Utility to check for horizontal scroll bar
async function hasHorizontalScroll(page: Page, selector: string) { async function hasHorizontalScroll(page: Page, selector: string) {
@ -9,16 +9,16 @@ async function hasHorizontalScroll(page: Page, selector: string) {
}, selector); }, selector);
} }
test.describe('My Notes Layout', () => { test.describe("My Notes Layout", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/my-notes'); await page.goto("/my-notes");
await page.waitForSelector('h1:text("My Notes")'); await page.waitForSelector('h1:text("My Notes")');
}); });
test('no horizontal scroll bar for all tag type and tag filter combinations', async ({ page }) => { test("no horizontal scroll bar for all tag type and tag filter combinations", async ({ page }) => {
// Helper to check scroll for current state // Helper to check scroll for current state
async function assertNoScroll() { async function assertNoScroll() {
const hasScroll = await hasHorizontalScroll(page, 'main, body, html'); const hasScroll = await hasHorizontalScroll(page, "main, body, html");
expect(hasScroll).toBeFalsy(); expect(hasScroll).toBeFalsy();
} }
@ -26,9 +26,11 @@ test.describe('My Notes Layout', () => {
await assertNoScroll(); await assertNoScroll();
// Get all tag type buttons // Get all tag type buttons
const tagTypeButtons = await page.locator('aside button').all(); const tagTypeButtons = await page.locator("aside button").all();
// Only consider tag type buttons (first N) // Only consider tag type buttons (first N)
const tagTypeCount = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-6 > button').count(); const tagTypeCount = await page.locator(
"aside > div.flex.flex-wrap.gap-2.mb-6 > button",
).count();
// For each single tag type // For each single tag type
for (let i = 0; i < tagTypeCount; i++) { for (let i = 0; i < tagTypeCount; i++) {
// Click tag type button // Click tag type button
@ -36,7 +38,9 @@ test.describe('My Notes Layout', () => {
await page.waitForTimeout(100); // Wait for UI update await page.waitForTimeout(100); // Wait for UI update
await assertNoScroll(); await assertNoScroll();
// Get tag filter buttons (after tag type buttons) // Get tag filter buttons (after tag type buttons)
const tagFilterButtons = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-4 > button').all(); const tagFilterButtons = await page.locator(
"aside > div.flex.flex-wrap.gap-2.mb-4 > button",
).all();
// Try all single tag filter selections // Try all single tag filter selections
for (let j = 0; j < tagFilterButtons.length; j++) { for (let j = 0; j < tagFilterButtons.length; j++) {
await tagFilterButtons[j].click(); await tagFilterButtons[j].click();
@ -72,7 +76,9 @@ test.describe('My Notes Layout', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await assertNoScroll(); await assertNoScroll();
// Get tag filter buttons for this combination // Get tag filter buttons for this combination
const tagFilterButtons = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-4 > button').all(); const tagFilterButtons = await page.locator(
"aside > div.flex.flex-wrap.gap-2.mb-4 > button",
).all();
// Try all single tag filter selections // Try all single tag filter selections
for (let k = 0; k < tagFilterButtons.length; k++) { for (let k = 0; k < tagFilterButtons.length; k++) {
await tagFilterButtons[k].click(); await tagFilterButtons[k].click();

195
tests/unit/ZettelEditor.test.ts

@ -1,37 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AsciiDocMetadata } from "../../src/lib/utils/asciidoc_metadata"; import type { AsciiDocMetadata } from "../../src/lib/utils/asciidoc_metadata";
// Mock all Svelte components and dependencies // Mock all Svelte components and dependencies
vi.mock("flowbite-svelte", () => ({ vi.mock("flowbite-svelte", () => ({
Textarea: vi.fn().mockImplementation((props) => { Textarea: vi.fn().mockImplementation((props) => {
return { return {
$$render: () => `<textarea data-testid="textarea" class="${props.class || ''}" rows="${props.rows || 12}" ${props.disabled ? 'disabled' : ''} placeholder="${props.placeholder || ''}"></textarea>`, $$render: () =>
$$bind: { value: props.bind, oninput: props.oninput } `<textarea data-testid="textarea" class="${props.class || ""}" rows="${
props.rows || 12
}" ${props.disabled ? "disabled" : ""} placeholder="${
props.placeholder || ""
}"></textarea>`,
$$bind: { value: props.bind, oninput: props.oninput },
}; };
}), }),
Button: vi.fn().mockImplementation((props) => { Button: vi.fn().mockImplementation((props) => {
return { return {
$$render: () => `<button data-testid="preview-button" class="${props.class || ''}" ${props.disabled ? 'disabled' : ''} onclick="${props.onclick || ''}">${props.children || ''}</button>`, $$render: () =>
$$bind: { onclick: props.onclick } `<button data-testid="preview-button" class="${props.class || ""}" ${
props.disabled ? "disabled" : ""
} onclick="${props.onclick || ""}">${props.children || ""}</button>`,
$$bind: { onclick: props.onclick },
}; };
}) }),
})); }));
vi.mock("flowbite-svelte-icons", () => ({ vi.mock("flowbite-svelte-icons", () => ({
EyeOutline: vi.fn().mockImplementation(() => ({ EyeOutline: vi.fn().mockImplementation(() => ({
$$render: () => `<svg data-testid="eye-icon"></svg>` $$render: () => `<svg data-testid="eye-icon"></svg>`,
})) })),
})); }));
vi.mock("asciidoctor", () => ({ vi.mock("asciidoctor", () => ({
default: vi.fn(() => ({ default: vi.fn(() => ({
convert: vi.fn((content, options) => { convert: vi.fn((content, options) => {
// Mock AsciiDoctor conversion - return simple HTML // Mock AsciiDoctor conversion - return simple HTML
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>') return content.replace(/^==\s+(.+)$/gm, "<h2>$1</h2>")
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, '<em>$1</em>'); .replace(/\*(.+?)\*/g, "<em>$1</em>");
}) }),
})) })),
})); }));
// Mock sessionStorage // Mock sessionStorage
@ -41,21 +49,21 @@ const mockSessionStorage = {
removeItem: vi.fn(), removeItem: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
}; };
Object.defineProperty(global, 'sessionStorage', { Object.defineProperty(global, "sessionStorage", {
value: mockSessionStorage, value: mockSessionStorage,
writable: true writable: true,
}); });
// Mock window object for DOM manipulation // Mock window object for DOM manipulation
Object.defineProperty(global, 'window', { Object.defineProperty(global, "window", {
value: { value: {
sessionStorage: mockSessionStorage, sessionStorage: mockSessionStorage,
document: { document: {
querySelector: vi.fn(), querySelector: vi.fn(),
createElement: vi.fn(), createElement: vi.fn(),
}
}, },
writable: true },
writable: true,
}); });
// Mock DOM methods // Mock DOM methods
@ -64,14 +72,14 @@ const mockCreateElement = vi.fn();
const mockAddEventListener = vi.fn(); const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn(); const mockRemoveEventListener = vi.fn();
Object.defineProperty(global, 'document', { Object.defineProperty(global, "document", {
value: { value: {
querySelector: mockQuerySelector, querySelector: mockQuerySelector,
createElement: mockCreateElement, createElement: mockCreateElement,
addEventListener: mockAddEventListener, addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener, removeEventListener: mockRemoveEventListener,
}, },
writable: true writable: true,
}); });
describe("ZettelEditor Component Logic", () => { describe("ZettelEditor Component Logic", () => {
@ -90,7 +98,8 @@ describe("ZettelEditor Component Logic", () => {
describe("Publication Format Detection Logic", () => { describe("Publication Format Detection Logic", () => {
it("should detect document header format", () => { it("should detect document header format", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; const contentWithDocumentHeader =
"= Document Title\n\n== Section 1\nContent";
// Test the regex pattern used in the component // Test the regex pattern used in the component
const hasDocumentHeader = contentWithDocumentHeader.match(/^=\s+/m); const hasDocumentHeader = contentWithDocumentHeader.match(/^=\s+/m);
@ -104,7 +113,7 @@ describe("ZettelEditor Component Logic", () => {
const lines = contentWithIndexCard.split(/\r?\n/); const lines = contentWithIndexCard.split(/\r?\n/);
let hasIndexCard = false; let hasIndexCard = false;
for (const line of lines) { for (const line of lines) {
if (line.trim().toLowerCase() === 'index card') { if (line.trim().toLowerCase() === "index card") {
hasIndexCard = true; hasIndexCard = true;
break; break;
} }
@ -113,7 +122,8 @@ describe("ZettelEditor Component Logic", () => {
}); });
it("should not detect publication format for normal section content", () => { it("should not detect publication format for normal section content", () => {
const normalContent = "== Section 1\nContent\n\n== Section 2\nMore content"; const normalContent =
"== Section 1\nContent\n\n== Section 2\nMore content";
// Test the logic used in the component // Test the logic used in the component
const lines = normalContent.split(/\r?\n/); const lines = normalContent.split(/\r?\n/);
@ -123,7 +133,7 @@ describe("ZettelEditor Component Logic", () => {
hasPublicationHeader = true; hasPublicationHeader = true;
break; break;
} }
if (line.trim().toLowerCase() === 'index card') { if (line.trim().toLowerCase() === "index card") {
hasPublicationHeader = true; hasPublicationHeader = true;
break; break;
} }
@ -141,7 +151,9 @@ describe("ZettelEditor Component Logic", () => {
expect(hasDocumentHeader).toBeFalsy(); // This content doesn't have a document header expect(hasDocumentHeader).toBeFalsy(); // This content doesn't have a document header
// Test section splitting logic // Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); const sectionStrings = content.split(/(?=^==\s+)/gm).filter((
section: string,
) => section.trim());
expect(sectionStrings).toHaveLength(1); expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1"); expect(sectionStrings[0]).toContain("== Section 1");
}); });
@ -154,7 +166,9 @@ describe("ZettelEditor Component Logic", () => {
expect(hasDocumentHeader).toBeFalsy(); expect(hasDocumentHeader).toBeFalsy();
// Test section splitting logic // Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); const sectionStrings = content.split(/(?=^==\s+)/gm).filter((
section: string,
) => section.trim());
expect(sectionStrings).toHaveLength(1); expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1"); expect(sectionStrings[0]).toContain("== Section 1");
}); });
@ -168,12 +182,16 @@ describe("ZettelEditor Component Logic", () => {
describe("Content Conversion Logic", () => { describe("Content Conversion Logic", () => {
it("should convert document title to section title", () => { it("should convert document title to section title", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; const contentWithDocumentHeader =
"= Document Title\n\n== Section 1\nContent";
// Test the conversion logic // Test the conversion logic
let convertedContent = contentWithDocumentHeader.replace(/^=\s+(.+)$/gm, '== $1'); let convertedContent = contentWithDocumentHeader.replace(
convertedContent = convertedContent.replace(/^index card$/gim, ''); /^=\s+(.+)$/gm,
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); "== $1",
);
convertedContent = convertedContent.replace(/^index card$/gim, "");
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, "\n\n");
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent"); expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
}); });
@ -182,20 +200,27 @@ describe("ZettelEditor Component Logic", () => {
const contentWithIndexCard = "index card\n\n== Section 1\nContent"; const contentWithIndexCard = "index card\n\n== Section 1\nContent";
// Test the conversion logic // Test the conversion logic
let convertedContent = contentWithIndexCard.replace(/^=\s+(.+)$/gm, '== $1'); let convertedContent = contentWithIndexCard.replace(
convertedContent = convertedContent.replace(/^index card$/gim, ''); /^=\s+(.+)$/gm,
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); "== $1",
);
convertedContent = convertedContent.replace(/^index card$/gim, "");
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, "\n\n");
expect(finalContent).toBe("\n\n== Section 1\nContent"); expect(finalContent).toBe("\n\n== Section 1\nContent");
}); });
it("should clean up double newlines", () => { it("should clean up double newlines", () => {
const contentWithExtraNewlines = "= Document Title\n\n\n== Section 1\nContent"; const contentWithExtraNewlines =
"= Document Title\n\n\n== Section 1\nContent";
// Test the conversion logic // Test the conversion logic
let convertedContent = contentWithExtraNewlines.replace(/^=\s+(.+)$/gm, '== $1'); let convertedContent = contentWithExtraNewlines.replace(
convertedContent = convertedContent.replace(/^index card$/gim, ''); /^=\s+(.+)$/gm,
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); "== $1",
);
convertedContent = convertedContent.replace(/^index card$/gim, "");
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, "\n\n");
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent"); expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
}); });
@ -203,14 +228,24 @@ describe("ZettelEditor Component Logic", () => {
describe("SessionStorage Integration", () => { describe("SessionStorage Integration", () => {
it("should store content in sessionStorage when switching to publication editor", () => { it("should store content in sessionStorage when switching to publication editor", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; const contentWithDocumentHeader =
"= Document Title\n\n== Section 1\nContent";
// Test the sessionStorage logic // Test the sessionStorage logic
mockSessionStorage.setItem('zettelEditorContent', contentWithDocumentHeader); mockSessionStorage.setItem(
mockSessionStorage.setItem('zettelEditorSource', 'publication-format'); "zettelEditorContent",
contentWithDocumentHeader,
);
mockSessionStorage.setItem("zettelEditorSource", "publication-format");
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorContent', contentWithDocumentHeader); expect(mockSessionStorage.setItem).toHaveBeenCalledWith(
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorSource', 'publication-format'); "zettelEditorContent",
contentWithDocumentHeader,
);
expect(mockSessionStorage.setItem).toHaveBeenCalledWith(
"zettelEditorSource",
"publication-format",
);
}); });
}); });
@ -227,7 +262,7 @@ describe("ZettelEditor Component Logic", () => {
it("should calculate correct event count for multiple sections", () => { it("should calculate correct event count for multiple sections", () => {
const sections = [ const sections = [
{ title: "Section 1", content: "Content 1", tags: [] }, { title: "Section 1", content: "Content 1", tags: [] },
{ title: "Section 2", content: "Content 2", tags: [] } { title: "Section 2", content: "Content 2", tags: [] },
]; ];
const eventCount = sections.length; const eventCount = sections.length;
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`; const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`;
@ -240,9 +275,15 @@ describe("ZettelEditor Component Logic", () => {
describe("Tag Processing Logic", () => { describe("Tag Processing Logic", () => {
it("should process tags correctly", () => { it("should process tags correctly", () => {
// Mock the metadataToTags function // Mock the metadataToTags function
const mockMetadataToTags = vi.fn().mockReturnValue([["author", "Test Author"]]); const mockMetadataToTags = vi.fn().mockReturnValue([[
"author",
const mockMetadata = { title: "Section 1", author: "Test Author" } as AsciiDocMetadata; "Test Author",
]]);
const mockMetadata = {
title: "Section 1",
author: "Test Author",
} as AsciiDocMetadata;
const tags = mockMetadataToTags(mockMetadata); const tags = mockMetadataToTags(mockMetadata);
expect(tags).toEqual([["author", "Test Author"]]); expect(tags).toEqual([["author", "Test Author"]]);
@ -264,9 +305,9 @@ describe("ZettelEditor Component Logic", () => {
it("should process AsciiDoc content correctly", () => { it("should process AsciiDoc content correctly", () => {
// Mock the asciidoctor conversion // Mock the asciidoctor conversion
const mockConvert = vi.fn((content, options) => { const mockConvert = vi.fn((content, options) => {
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>') return content.replace(/^==\s+(.+)$/gm, "<h2>$1</h2>")
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, '<em>$1</em>'); .replace(/\*(.+?)\*/g, "<em>$1</em>");
}); });
const content = "== Test Section\n\nThis is **bold** and *italic* text."; const content = "== Test Section\n\nThis is **bold** and *italic* text.";
@ -279,9 +320,9 @@ describe("ZettelEditor Component Logic", () => {
}, },
}); });
expect(processedContent).toContain('<h2>Test Section</h2>'); expect(processedContent).toContain("<h2>Test Section</h2>");
expect(processedContent).toContain('<strong>bold</strong>'); expect(processedContent).toContain("<strong>bold</strong>");
expect(processedContent).toContain('<em>italic</em>'); expect(processedContent).toContain("<em>italic</em>");
}); });
}); });
@ -322,11 +363,11 @@ describe("ZettelEditor Component Logic", () => {
onPreviewToggle: vi.fn(), onPreviewToggle: vi.fn(),
}; };
expect(expectedProps).toHaveProperty('content'); expect(expectedProps).toHaveProperty("content");
expect(expectedProps).toHaveProperty('placeholder'); expect(expectedProps).toHaveProperty("placeholder");
expect(expectedProps).toHaveProperty('showPreview'); expect(expectedProps).toHaveProperty("showPreview");
expect(expectedProps).toHaveProperty('onContentChange'); expect(expectedProps).toHaveProperty("onContentChange");
expect(expectedProps).toHaveProperty('onPreviewToggle'); expect(expectedProps).toHaveProperty("onPreviewToggle");
}); });
}); });
@ -334,7 +375,7 @@ describe("ZettelEditor Component Logic", () => {
it("should integrate with ZettelParser utilities", () => { it("should integrate with ZettelParser utilities", () => {
// Mock the parseAsciiDocSections function // Mock the parseAsciiDocSections function
const mockParseAsciiDocSections = vi.fn().mockReturnValue([ const mockParseAsciiDocSections = vi.fn().mockReturnValue([
{ title: "Section 1", content: "Content 1", tags: [] } { title: "Section 1", content: "Content 1", tags: [] },
]); ]);
const content = "== Section 1\nContent 1"; const content = "== Section 1\nContent 1";
@ -348,13 +389,13 @@ describe("ZettelEditor Component Logic", () => {
// Mock the utility functions // Mock the utility functions
const mockExtractDocumentMetadata = vi.fn().mockReturnValue({ const mockExtractDocumentMetadata = vi.fn().mockReturnValue({
metadata: { title: "Document Title" } as AsciiDocMetadata, metadata: { title: "Document Title" } as AsciiDocMetadata,
content: "Document content" content: "Document content",
}); });
const mockExtractSectionMetadata = vi.fn().mockReturnValue({ const mockExtractSectionMetadata = vi.fn().mockReturnValue({
metadata: { title: "Section Title" } as AsciiDocMetadata, metadata: { title: "Section Title" } as AsciiDocMetadata,
content: "Section content", content: "Section content",
title: "Section Title" title: "Section Title",
}); });
const documentContent = "= Document Title\nDocument content"; const documentContent = "= Document Title\nDocument content";
@ -370,12 +411,17 @@ describe("ZettelEditor Component Logic", () => {
describe("Content Validation", () => { describe("Content Validation", () => {
it("should validate content structure", () => { it("should validate content structure", () => {
const validContent = "== Section 1\nContent here\n\n== Section 2\nMore content"; const validContent =
"== Section 1\nContent here\n\n== Section 2\nMore content";
const invalidContent = "Just some text without sections"; const invalidContent = "Just some text without sections";
// Test section detection // Test section detection
const validSections = validContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); const validSections = validContent.split(/(?=^==\s+)/gm).filter((
const invalidSections = invalidContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); section: string,
) => section.trim());
const invalidSections = invalidContent.split(/(?=^==\s+)/gm).filter((
section: string,
) => section.trim());
expect(validSections.length).toBeGreaterThan(0); expect(validSections.length).toBeGreaterThan(0);
// The invalid content will have one section (the entire content) since it doesn't start with == // The invalid content will have one section (the entire content) since it doesn't start with ==
@ -383,14 +429,17 @@ describe("ZettelEditor Component Logic", () => {
}); });
it("should handle mixed content types", () => { it("should handle mixed content types", () => {
const mixedContent = "= Document Title\n\n== Section 1\nContent\n\n== Section 2\nMore content"; const mixedContent =
"= Document Title\n\n== Section 1\nContent\n\n== Section 2\nMore content";
// Test document header detection // Test document header detection
const hasDocumentHeader = mixedContent.match(/^=\s+/m); const hasDocumentHeader = mixedContent.match(/^=\s+/m);
expect(hasDocumentHeader).toBeTruthy(); expect(hasDocumentHeader).toBeTruthy();
// Test section extraction // Test section extraction
const sections = mixedContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); const sections = mixedContent.split(/(?=^==\s+)/gm).filter((
section: string,
) => section.trim());
expect(sections.length).toBeGreaterThan(0); expect(sections.length).toBeGreaterThan(0);
}); });
}); });
@ -401,9 +450,9 @@ describe("ZettelEditor Component Logic", () => {
// Test various string manipulations // Test various string manipulations
const convertedContent = originalContent const convertedContent = originalContent
.replace(/^=\s+(.+)$/gm, '== $1') .replace(/^=\s+(.+)$/gm, "== $1")
.replace(/^index card$/gim, '') .replace(/^index card$/gim, "")
.replace(/\n\s*\n\s*\n/g, '\n\n'); .replace(/\n\s*\n\s*\n/g, "\n\n");
expect(convertedContent).toBe("== Title\n\n== Section\nContent"); expect(convertedContent).toBe("== Title\n\n== Section\nContent");
}); });
@ -415,14 +464,14 @@ describe("ZettelEditor Component Logic", () => {
"= Title\nindex card\n== Section\nContent", // Both "= Title\nindex card\n== Section\nContent", // Both
]; ];
edgeCases.forEach(content => { edgeCases.forEach((content) => {
const converted = content const converted = content
.replace(/^=\s+(.+)$/gm, '== $1') .replace(/^=\s+(.+)$/gm, "== $1")
.replace(/^index card$/gim, '') .replace(/^index card$/gim, "")
.replace(/\n\s*\n\s*\n/g, '\n\n'); .replace(/\n\s*\n\s*\n/g, "\n\n");
expect(converted).toBeDefined(); expect(converted).toBeDefined();
expect(typeof converted).toBe('string'); expect(typeof converted).toBe("string");
}); });
}); });
}); });

336
tests/unit/eventInput30040.test.ts

@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { build30040EventSet, validate30040EventSet } from "../../src/lib/utils/event_input_utils"; import {
import { extractDocumentMetadata, parseAsciiDocWithMetadata } from "../../src/lib/utils/asciidoc_metadata"; build30040EventSet,
validate30040EventSet,
} from "../../src/lib/utils/event_input_utils";
// Mock NDK and other dependencies // Mock NDK and other dependencies
vi.mock("@nostr-dev-kit/ndk", () => ({ vi.mock("@nostr-dev-kit/ndk", () => ({
@ -16,6 +18,7 @@ vi.mock("@nostr-dev-kit/ndk", () => ({
})), })),
})); }));
// TODO: Replace with getNdkContext mock.
vi.mock("../../src/lib/ndk", () => ({ vi.mock("../../src/lib/ndk", () => ({
ndkInstance: { ndkInstance: {
subscribe: vi.fn(), subscribe: vi.fn(),
@ -60,16 +63,29 @@ This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]]; const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe(""); expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-with-preamble"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Test Document with Preamble"]); "d",
"test-document-with-preamble",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Test Document with Preamble",
]);
expect(indexEvent.tags).toContainEqual(["author", "John Doe"]); expect(indexEvent.tags).toContainEqual(["author", "John Doe"]);
expect(indexEvent.tags).toContainEqual(["version", "1.0"]); expect(indexEvent.tags).toContainEqual(["version", "1.0"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document with preamble"]); expect(indexEvent.tags).toContainEqual([
"summary",
"This is a test document with preamble",
]);
expect(indexEvent.tags).toContainEqual(["t", "test"]); expect(indexEvent.tags).toContainEqual(["t", "test"]);
expect(indexEvent.tags).toContainEqual(["t", "preamble"]); expect(indexEvent.tags).toContainEqual(["t", "preamble"]);
expect(indexEvent.tags).toContainEqual(["t", "asciidoc"]); expect(indexEvent.tags).toContainEqual(["t", "asciidoc"]);
@ -80,22 +96,47 @@ This is the content of the second section.`;
// First section // First section
expect(sectionEvents[0].kind).toBe(30041); expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section."); expect(sectionEvents[0].content).toBe(
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-with-preamble-first-section"]); "This is the content of the first section.",
);
expect(sectionEvents[0].tags).toContainEqual([
"d",
"test-document-with-preamble-first-section",
]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]); expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); expect(sectionEvents[0].tags).toContainEqual([
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]); "author",
"Section Author",
]);
expect(sectionEvents[0].tags).toContainEqual([
"summary",
"This is the first section",
]);
// Second section // Second section
expect(sectionEvents[1].kind).toBe(30041); expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section."); expect(sectionEvents[1].content).toBe(
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-with-preamble-second-section"]); "This is the content of the second section.",
);
expect(sectionEvents[1].tags).toContainEqual([
"d",
"test-document-with-preamble-second-section",
]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]); expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]); expect(sectionEvents[1].tags).toContainEqual([
"summary",
"This is the second section",
]);
// Test a-tags in index event // Test a-tags in index event
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-first-section"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-second-section"]); "a",
"30041:test-pubkey:test-document-with-preamble-first-section",
]);
expect(indexEvent.tags).toContainEqual([
"a",
"30041:test-pubkey:test-document-with-preamble-second-section",
]);
}); });
}); });
@ -118,32 +159,64 @@ This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]]; const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe(""); expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-without-preamble"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Test Document without Preamble"]); "d",
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document without preamble"]); "test-document-without-preamble",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Test Document without Preamble",
]);
expect(indexEvent.tags).toContainEqual([
"summary",
"This is a test document without preamble",
]);
// Test section events // Test section events
expect(sectionEvents).toHaveLength(2); expect(sectionEvents).toHaveLength(2);
// First section // First section
expect(sectionEvents[0].kind).toBe(30041); expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section."); expect(sectionEvents[0].content).toBe(
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-without-preamble-first-section"]); "This is the content of the first section.",
);
expect(sectionEvents[0].tags).toContainEqual([
"d",
"test-document-without-preamble-first-section",
]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]); expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); expect(sectionEvents[0].tags).toContainEqual([
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]); "author",
"Section Author",
]);
expect(sectionEvents[0].tags).toContainEqual([
"summary",
"This is the first section",
]);
// Second section // Second section
expect(sectionEvents[1].kind).toBe(30041); expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section."); expect(sectionEvents[1].content).toBe(
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-without-preamble-second-section"]); "This is the content of the second section.",
);
expect(sectionEvents[1].tags).toContainEqual([
"d",
"test-document-without-preamble-second-section",
]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]); expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]); expect(sectionEvents[1].tags).toContainEqual([
"summary",
"This is the second section",
]);
}); });
}); });
@ -163,24 +236,43 @@ This is the preamble content.
const tags: [string, string][] = [["type", "skeleton"]]; const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe(""); expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-with-preamble"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document with Preamble"]); "d",
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document with preamble"]); "skeleton-document-with-preamble",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Skeleton Document with Preamble",
]);
expect(indexEvent.tags).toContainEqual([
"summary",
"This is a skeleton document with preamble",
]);
// Test section events // Test section events
expect(sectionEvents).toHaveLength(3); expect(sectionEvents).toHaveLength(3);
// All sections should have empty content // All sections should have empty content
sectionEvents.forEach((section, index) => { sectionEvents.forEach((section: any, index: number) => {
expect(section.kind).toBe(30041); expect(section.kind).toBe(30041);
expect(section.content).toBe(""); expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-with-preamble-empty-section-${index + 1}`]); expect(section.tags).toContainEqual([
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]); "d",
`skeleton-document-with-preamble-empty-section-${index + 1}`,
]);
expect(section.tags).toContainEqual([
"title",
`Empty Section ${index + 1}`,
]);
}); });
}); });
}); });
@ -199,24 +291,43 @@ This is the preamble content.
const tags: [string, string][] = [["type", "skeleton"]]; const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe(""); expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-without-preamble"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document without Preamble"]); "d",
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document without preamble"]); "skeleton-document-without-preamble",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Skeleton Document without Preamble",
]);
expect(indexEvent.tags).toContainEqual([
"summary",
"This is a skeleton document without preamble",
]);
// Test section events // Test section events
expect(sectionEvents).toHaveLength(3); expect(sectionEvents).toHaveLength(3);
// All sections should have empty content // All sections should have empty content
sectionEvents.forEach((section, index) => { sectionEvents.forEach((section: any, index: number) => {
expect(section.kind).toBe(30041); expect(section.kind).toBe(30041);
expect(section.content).toBe(""); expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-without-preamble-empty-section-${index + 1}`]); expect(section.tags).toContainEqual([
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]); "d",
`skeleton-document-without-preamble-empty-section-${index + 1}`,
]);
expect(section.tags).toContainEqual([
"title",
`Empty Section ${index + 1}`,
]);
}); });
}); });
}); });
@ -228,7 +339,11 @@ index card`;
const tags: [string, string][] = [["type", "index-card"]]; const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
@ -249,14 +364,27 @@ index card`;
const tags: [string, string][] = [["type", "index-card"]]; const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event // Test index event
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe(""); expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-index-card-with-metadata"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card with Metadata"]); "d",
expect(indexEvent.tags).toContainEqual(["summary", "This is an index card with metadata"]); "test-index-card-with-metadata",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Test Index Card with Metadata",
]);
expect(indexEvent.tags).toContainEqual([
"summary",
"This is an index card with metadata",
]);
expect(indexEvent.tags).toContainEqual(["t", "index"]); expect(indexEvent.tags).toContainEqual(["t", "index"]);
expect(indexEvent.tags).toContainEqual(["t", "card"]); expect(indexEvent.tags).toContainEqual(["t", "card"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]); expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
@ -303,23 +431,45 @@ This is the section content.`;
const tags: [string, string][] = [["type", "complex"]]; const tags: [string, string][] = [["type", "complex"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
// Test index event metadata // Test index event metadata
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "complex-metadata-document"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Complex Metadata Document"]); "d",
"complex-metadata-document",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Complex Metadata Document",
]);
expect(indexEvent.tags).toContainEqual(["author", "Jane Smith"]); // Should use header line author expect(indexEvent.tags).toContainEqual(["author", "Jane Smith"]); // Should use header line author
expect(indexEvent.tags).toContainEqual(["author", "Override Author"]); // Additional author from attribute expect(indexEvent.tags).toContainEqual(["author", "Override Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["author", "Third Author"]); // Additional author from attribute expect(indexEvent.tags).toContainEqual(["author", "Third Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["version", "2.0"]); // Should use revision line version expect(indexEvent.tags).toContainEqual(["version", "2.0"]); // Should use revision line version
expect(indexEvent.tags).toContainEqual(["summary", "This is a complex document with all metadata types Alternative description field"]); expect(indexEvent.tags).toContainEqual([
"summary",
"This is a complex document with all metadata types Alternative description field",
]);
expect(indexEvent.tags).toContainEqual(["published_on", "2024-03-01"]); expect(indexEvent.tags).toContainEqual(["published_on", "2024-03-01"]);
expect(indexEvent.tags).toContainEqual(["published_by", "Alexandria Complex"]); expect(indexEvent.tags).toContainEqual([
"published_by",
"Alexandria Complex",
]);
expect(indexEvent.tags).toContainEqual(["type", "book"]); expect(indexEvent.tags).toContainEqual(["type", "book"]);
expect(indexEvent.tags).toContainEqual(["image", "https://example.com/cover.jpg"]); expect(indexEvent.tags).toContainEqual([
"image",
"https://example.com/cover.jpg",
]);
expect(indexEvent.tags).toContainEqual(["i", "978-0-123456-78-9"]); expect(indexEvent.tags).toContainEqual(["i", "978-0-123456-78-9"]);
expect(indexEvent.tags).toContainEqual(["source", "https://github.com/alexandria/complex"]); expect(indexEvent.tags).toContainEqual([
"source",
"https://github.com/alexandria/complex",
]);
expect(indexEvent.tags).toContainEqual(["auto-update", "yes"]); expect(indexEvent.tags).toContainEqual(["auto-update", "yes"]);
expect(indexEvent.tags).toContainEqual(["t", "complex"]); expect(indexEvent.tags).toContainEqual(["t", "complex"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]); expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
@ -332,13 +482,31 @@ This is the section content.`;
expect(sectionEvents).toHaveLength(1); expect(sectionEvents).toHaveLength(1);
expect(sectionEvents[0].kind).toBe(30041); expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the section content."); expect(sectionEvents[0].content).toBe("This is the section content.");
expect(sectionEvents[0].tags).toContainEqual(["d", "complex-metadata-document-section-with-complex-metadata"]); expect(sectionEvents[0].tags).toContainEqual([
expect(sectionEvents[0].tags).toContainEqual(["title", "Section with Complex Metadata"]); "d",
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); "complex-metadata-document-section-with-complex-metadata",
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Co-Author"]); ]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This section has complex metadata Alternative description for section"]); expect(sectionEvents[0].tags).toContainEqual([
"title",
"Section with Complex Metadata",
]);
expect(sectionEvents[0].tags).toContainEqual([
"author",
"Section Author",
]);
expect(sectionEvents[0].tags).toContainEqual([
"author",
"Section Co-Author",
]);
expect(sectionEvents[0].tags).toContainEqual([
"summary",
"This section has complex metadata Alternative description for section",
]);
expect(sectionEvents[0].tags).toContainEqual(["type", "chapter"]); expect(sectionEvents[0].tags).toContainEqual(["type", "chapter"]);
expect(sectionEvents[0].tags).toContainEqual(["image", "https://example.com/section-image.jpg"]); expect(sectionEvents[0].tags).toContainEqual([
"image",
"https://example.com/section-image.jpg",
]);
expect(sectionEvents[0].tags).toContainEqual(["t", "section"]); expect(sectionEvents[0].tags).toContainEqual(["t", "section"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "complex"]); expect(sectionEvents[0].tags).toContainEqual(["t", "complex"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "metadata"]); expect(sectionEvents[0].tags).toContainEqual(["t", "metadata"]);
@ -387,7 +555,9 @@ index card`;
const validation = validate30040EventSet(content); const validation = validate30040EventSet(content);
expect(validation.valid).toBe(false); expect(validation.valid).toBe(false);
expect(validation.reason).toContain("30040 events must have a document title"); expect(validation.reason).toContain(
"30040 events must have a document title",
);
}); });
}); });
@ -400,11 +570,21 @@ This is just preamble content.`;
const tags: [string, string][] = []; const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-no-sections"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Document with No Sections"]); "d",
"document-with-no-sections",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Document with No Sections",
]);
expect(sectionEvents).toHaveLength(0); expect(sectionEvents).toHaveLength(0);
}); });
@ -418,16 +598,27 @@ Content here.`;
const tags: [string, string][] = []; const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-special-characters-test-more"]); expect(indexEvent.tags).toContainEqual([
expect(indexEvent.tags).toContainEqual(["title", "Document with Special Characters: Test & More!"]); "d",
"document-with-special-characters-test-more",
]);
expect(indexEvent.tags).toContainEqual([
"title",
"Document with Special Characters: Test & More!",
]);
expect(sectionEvents).toHaveLength(1); expect(sectionEvents).toHaveLength(1);
}); });
it("should handle document with very long title", () => { it("should handle document with very long title", () => {
const content = `= This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality const content =
`= This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality
:summary: This document has a very long title :summary: This document has a very long title
== Section 1 == Section 1
@ -436,10 +627,17 @@ Content here.`;
const tags: [string, string][] = []; const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); const { indexEvent, sectionEvents } = build30040EventSet(
content,
tags,
baseEvent,
);
expect(indexEvent.kind).toBe(30040); expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["title", "This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality"]); expect(indexEvent.tags).toContainEqual([
"title",
"This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality",
]);
expect(sectionEvents).toHaveLength(1); expect(sectionEvents).toHaveLength(1);
}); });
}); });

2
tests/unit/latexRendering.test.ts

@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest"; import { describe, expect, it } from "vitest";
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser"; import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { join } from "path"; import { join } from "path";

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

Loading…
Cancel
Save