Browse Source

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

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

13
Dockerfile

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

16
README.md

@ -18,21 +18,25 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events? @@ -18,21 +18,25 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?
Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed.
Once you've cloned this repo, install dependencies with NPM:
```bash
npm install
```
or with Deno:
```bash
deno install
```
then start a development server with Node:
```bash
npm run dev
```
or with Deno:
```bash
deno task dev
```
@ -42,21 +46,25 @@ deno task dev @@ -42,21 +46,25 @@ deno task dev
Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well.
To build a production version of your app with Node, use:
```bash
npm run build
```
or with Deno:
```bash
deno task build
```
You can preview the (non-static) production build with:
```bash
npm run preview
```
or with Deno:
```bash
deno task preview
```
@ -66,11 +74,13 @@ deno task preview @@ -66,11 +74,13 @@ deno task preview
This docker container performs the build.
To build the container:
```bash
docker build . -t gc-alexandria
```
To run the container, in detached mode (-d):
```bash
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
```
@ -95,25 +105,29 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS @@ -95,25 +105,29 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments.
To build the app for local development:
```bash
docker build -t local-alexandria -f Dockerfile.local .
```
To run the local development build:
```bash
docker run -d -p 3000:3000 local-alexandria
```
## 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.
```bash
npm run test
```
For the Playwright end-to-end (e2e) tests:
```bash
npx playwright test
```

3
deno.lock

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

2
docker-compose.yaml

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

2286
package-lock.json generated

File diff suppressed because it is too large Load Diff

3
package.json

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
"test": "vitest"
},
"dependencies": {
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
@ -26,6 +26,7 @@ @@ -26,6 +26,7 @@
"highlight.js": "^11.11.1",
"node-emoji": "^2.2.0",
"nostr-tools": "2.10.x",
"plantuml-encoder": "^1.4.0",
"qrcode": "^1.5.4"
},
"devDependencies": {

28
playwright.config.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test'; @@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e/',
testDir: "./tests/e2e/",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@ -22,34 +22,31 @@ export default defineConfig({ @@ -22,34 +22,31 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],
['html', { outputFolder: './tests/e2e/html-report' }]
],
reporter: [["list"], ["html", { outputFolder: "./tests/e2e/html-report" }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
@ -84,10 +81,10 @@ export default defineConfig({ @@ -84,10 +81,10 @@ export default defineConfig({
// testIgnore: '*test-assets',
// Glob patterns or regular expressions that match test files.
testMatch: '*.pw.spec.ts',
testMatch: "*.pw.spec.ts",
// Folder for test artifacts such as screenshots, videos, traces, etc.
outputDir: './tests/e2e/test-results',
outputDir: "./tests/e2e/test-results",
// path to the global setup files.
// globalSetup: require.resolve('./global-setup'),
@ -102,5 +99,4 @@ export default defineConfig({ @@ -102,5 +99,4 @@ export default defineConfig({
// Maximum time expect() should wait for the condition to be met.
timeout: 5000,
},
});

5
postcss.config.js

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

54
src/app.css

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
@import './styles/base.css';
@import './styles/scrollbar.css';
@import './styles/publications.css';
@import './styles/visualize.css';
@import "./styles/base.css";
@import "./styles/scrollbar.css";
@import "./styles/publications.css";
@import "./styles/visualize.css";
@import "./styles/events.css";
@import './styles/asciidoc.css';
/* Custom styles */
@layer base {
.leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
}
.btn-leather.text-xs {
@ -27,8 +27,8 @@ @@ -27,8 +27,8 @@
@apply h-4 w-4;
}
div[role='tooltip'] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700;
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;
}
.image-border {
@ -46,11 +46,11 @@ @@ -46,11 +46,11 @@
div.card-leather h4,
div.card-leather h5,
div.card-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
div.card-leather .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
@apply text-gray-900 hover:text-primary-700 dark:text-gray-100 dark:hover:text-primary-300;
}
main {
@ -68,13 +68,13 @@ @@ -68,13 +68,13 @@
main.main-leather,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
}
div.note-leather,
p.note-leather,
section.note-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 p-2 rounded;
}
.edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
h4.h-leather,
h5.h-leather,
h6.h-leather {
@apply text-gray-800 dark:text-gray-300;
@apply text-gray-900 dark:text-gray-100;
}
h1.h-leather {
@ -126,11 +126,11 @@ @@ -126,11 +126,11 @@
div.modal-leather > div > h4,
div.modal-leather > div > h5,
div.modal-leather > div > h6 {
@apply text-gray-800 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-300;
@apply text-gray-900 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-100;
}
div.modal-leather button {
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
/* Navbar */
@ -143,7 +143,7 @@ @@ -143,7 +143,7 @@
}
nav.navbar-leather svg {
@apply fill-gray-800 hover:fill-primary-400 dark:fill-gray-300 dark:hover:fill-primary-500;
@apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100 dark:hover:fill-primary-400;
}
nav.navbar-leather h1,
@ -152,7 +152,7 @@ @@ -152,7 +152,7 @@
nav.navbar-leather h4,
nav.navbar-leather h5,
nav.navbar-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
/* Sidebar */
@ -188,14 +188,14 @@ @@ -188,14 +188,14 @@
div.textarea-leather,
div.textarea-leather textarea {
@apply text-gray-800 dark:text-gray-300;
@apply text-gray-900 dark:text-gray-100;
}
div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300;
@apply text-gray-900 dark:text-gray-100;
}
div[role='tooltip'] button.btn-leather .tooltip-leather {
div[role="tooltip"] button.btn-leather .tooltip-leather {
@apply bg-primary-100 dark:bg-primary-800;
}
@ -216,7 +216,7 @@ @@ -216,7 +216,7 @@
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
@apply text-gray-900 dark:text-gray-100 pt-4;
}
.h1-leather {
@ -246,11 +246,11 @@ @@ -246,11 +246,11 @@
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
@apply underline cursor-pointer hover:text-primary-600 dark:hover:text-primary-400;
}
/* Card with transition */
@ -277,7 +277,6 @@ @@ -277,7 +277,6 @@
}
@layer components {
/* Legend */
.leather-legend {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded;
@ -287,7 +286,7 @@ @@ -287,7 +286,7 @@
/* Tooltip */
.tooltip-leather {
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 transition-colors duration-200;
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 transition-colors duration-200;
max-width: 400px;
z-index: 1000;
}
@ -378,7 +377,7 @@ @@ -378,7 +377,7 @@
}
.stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded-lg;
}
.literalblock {
@ -396,7 +395,6 @@ @@ -396,7 +395,6 @@
thead,
tbody {
th,
td {
@apply border border-gray-200 dark:border-gray-700;
@ -495,7 +493,7 @@ @@ -495,7 +493,7 @@
input[type="tel"],
input[type="url"],
textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-400 dark:focus:border-primary-500;
@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;
}
}

31
src/app.html

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

555
src/lib/components/CommentBox.svelte

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

398
src/lib/components/EventDetails.svelte

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

448
src/lib/components/EventInput.svelte

@ -0,0 +1,448 @@ @@ -0,0 +1,448 @@
<script lang='ts'>
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { userPubkey } from '$lib/stores/authStore.Svelte';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts';
import { Button } from "flowbite-svelte";
import { nip19 } from "nostr-tools";
import { goto } from "$app/navigation";
let kind = $state<number>(30023);
let tags = $state<[string, string][]>([]);
let content = $state('');
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]);
let title = $state('');
let dTag = $state('');
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state('');
let lastPublishedEventId = $state<string | null>(null);
/**
* Extracts the first Markdown/AsciiDoc header as the title.
*/
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : '';
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
console.log('Content input - extracted title:', extracted);
title = extracted;
}
}
function handleTitleInput(e: Event) {
title = (e.target as HTMLInputElement).value;
titleManuallyEdited = true;
}
function handleDTagInput(e: Event) {
dTag = (e.target as HTMLInputElement).value;
dTagManuallyEdited = true;
}
$effect(() => {
console.log('Effect running - title:', title, 'dTagManuallyEdited:', dTagManuallyEdited);
if (!dTagManuallyEdited) {
const newDTag = titleToDTag(title);
console.log('Setting dTag to:', newDTag);
dTag = newDTag;
}
});
function updateTag(index: number, key: string, value: string): void {
tags = tags.map((t, i) => i === index ? [key, value] : t);
}
function addTag(): void {
tags = [...tags, ['', '']];
}
function removeTag(index: number): void {
tags = tags.filter((_, i) => i !== index);
}
function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
function validate(): { valid: boolean; reason?: string } {
const currentUserPubkey = get(userPubkey as any);
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' };
const pubkey = String(currentUserPubkey);
if (!content.trim()) return { valid: false, reason: 'Content required.' };
if (kind === 30023) {
const v = validateNotAsciidoc(content);
if (!v.valid) return v;
}
if (kind === 30040) {
const v = validate30040EventSet(content);
if (!v.valid) return v;
}
if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
if (!v.valid) return v;
}
return { valid: true };
}
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = '';
if (requiresDTag(kind) && (!dTag || dTag.trim() === '')) {
dTagError = 'A d-tag is required.';
return;
}
handlePublish();
}
async function handlePublish(): Promise<void> {
error = null;
success = null;
publishedRelays = [];
loading = true;
createdAt = Math.floor(Date.now() / 1000);
try {
const ndk = get(ndkInstance);
const currentUserPubkey = get(userPubkey as any);
if (!ndk || !currentUserPubkey) {
error = 'NDK or pubkey missing.';
loading = false;
return;
}
const pubkey = String(currentUserPubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
error = 'Invalid public key: must be a 64-character hex string.';
loading = false;
return;
}
// Validate before proceeding
const validation = validate();
if (!validation.valid) {
error = validation.reason || 'Validation failed.';
loading = false;
return;
}
const baseEvent = { pubkey, created_at: createdAt };
let events: NDKEvent[] = [];
console.log('Publishing event with kind:', kind);
console.log('Content length:', content.length);
console.log('Content preview:', content.substring(0, 100));
console.log('Tags:', tags);
console.log('Title:', title);
console.log('DTag:', dTag);
if (Number(kind) === 30040) {
console.log('=== 30040 EVENT CREATION START ===');
console.log('Creating 30040 event set with content:', content);
try {
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
console.log('Index event:', indexEvent);
console.log('Section events:', sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log('Total events to publish:', events.length);
// Debug the index event to ensure it's correct
const indexEventData = {
content: indexEvent.content,
tags: indexEvent.tags.map(tag => [tag[0], tag[1]] as [string, string]),
kind: indexEvent.kind || 30040
};
const analysis = debug30040Event(indexEventData);
if (!analysis.valid) {
console.warn('30040 index event has issues:', analysis.issues);
}
console.log('=== 30040 EVENT CREATION END ===');
} catch (error) {
console.error('Error in build30040EventSet:', error);
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : 'Unknown error'}`;
loading = false;
return;
}
} else {
let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events
if (requiresDTag(kind)) {
const dTagIndex = eventTags.findIndex(([k]) => k === 'd');
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, '');
if (dTagValue) {
if (dTagIndex >= 0) {
// Update existing d-tag
eventTags[dTagIndex] = ['d', dTagValue];
} else {
// Add new d-tag
eventTags = [...eventTags, ['d', dTagValue]];
}
}
}
// Add title tag if we have a title
const titleValue = title.trim() || getTitleTagForEvent(kind, content);
if (titleValue) {
eventTags = [...eventTags, ['title', titleValue]];
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// Create event with proper serialization
const eventData = {
kind,
content: prefixedContent,
tags: eventTags,
pubkey,
created_at: createdAt,
};
events = [new NDKEventClass(ndk, eventData)];
}
let atLeastOne = false;
let relaysPublished: string[] = [];
for (let i = 0; i < events.length; i++) {
const event = events[i];
try {
console.log('Publishing event:', {
kind: event.kind,
content: event.content,
tags: event.tags,
hasContent: event.content && event.content.length > 0
});
// Always sign with a plain object if window.nostr is available
// Create a completely plain object to avoid proxy cloning issues
const plainEvent = {
kind: Number(event.kind),
pubkey: String(event.pubkey),
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)),
tags: event.tags.map(tag => [String(tag[0]), String(tag[1])]),
content: String(event.content),
};
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ('id' in signed) {
event.id = signed.id as string;
}
} else {
await event.sign();
}
// Use direct WebSocket publishing like CommentBox does
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Try to publish to relays directly
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ...standardRelays];
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
relaysPublished.push(relayUrl);
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (published) {
atLeastOne = true;
// For 30040, set lastPublishedEventId to the index event (last in array)
if (Number(kind) === 30040) {
if (i === events.length - 1) {
lastPublishedEventId = event.id;
}
} else {
lastPublishedEventId = event.id;
}
}
} catch (signError) {
console.error('Error signing/publishing event:', signError);
error = `Failed to sign event: ${signError instanceof Error ? signError.message : 'Unknown error'}`;
loading = false;
return;
}
}
loading = false;
if (atLeastOne) {
publishedRelays = relaysPublished;
success = `Published to ${relaysPublished.length} relay(s).`;
} else {
error = 'Failed to publish to any relay.';
}
} catch (err) {
console.error('Error in handlePublish:', err);
error = `Publishing failed: ${err instanceof Error ? err.message : 'Unknown error'}`;
loading = false;
}
}
/**
* Debug function to analyze a 30040 event and provide guidance.
*/
function debug30040Event(eventData: { content: string; tags: [string, string][]; kind: number }) {
const analysis = analyze30040Event(eventData);
console.log('30040 Event Analysis:', analysis);
if (!analysis.valid) {
console.log('Guidance:', get30040FixGuidance());
}
return analysis;
}
function viewPublishedEvent() {
if (lastPublishedEventId) {
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
</script>
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'>
<h2 class='text-xl font-bold mb-4'>Publish Nostr Event</h2>
<form class='space-y-4' onsubmit={handleSubmit}>
<div>
<label class='block font-medium mb-1' for='event-kind'>Kind</label>
<input id='event-kind' type='text' class='input input-bordered w-full' bind:value={kind} required />
{#if !isValidKind(kind)}
<div class="text-red-600 text-sm mt-1">
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if kind === 30040}
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded">
<strong>30040 - Publication Index:</strong> {get30040EventDescription()}
</div>
{/if}
</div>
<div>
<label class='block font-medium mb-1' for='tags-container'>Tags</label>
<div id='tags-container' class='space-y-2'>
{#each tags as [key, value], i}
<div class='flex gap-2'>
<input type='text' class='input input-bordered flex-1' placeholder='tag' bind:value={tags[i][0]} oninput={e => updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} />
<input type='text' class='input input-bordered flex-1' placeholder='value' bind:value={tags[i][1]} oninput={e => updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} />
<button type='button' class='btn btn-error btn-sm' onclick={() => removeTag(i)} disabled={tags.length === 1}>×</button>
</div>
{/each}
<div class='flex justify-end'>
<button type='button' class='btn btn-primary btn-sm border border-primary-600 px-3 py-1' onclick={addTag}>Add Tag</button>
</div>
</div>
</div>
<div>
<label class='block font-medium mb-1' for='event-content'>Content</label>
<textarea
id='event-content'
bind:value={content}
oninput={handleContentInput}
placeholder='Content (start with a header for the title)'
class='textarea textarea-bordered w-full h-40'
required
></textarea>
</div>
<div>
<label class='block font-medium mb-1' for='event-title'>Title</label>
<input
type='text'
id='event-title'
bind:value={title}
oninput={handleTitleInput}
placeholder='Title (auto-filled from header)'
class='input input-bordered w-full'
/>
</div>
<div>
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label>
<input
type='text'
id='event-d-tag'
bind:value={dTag}
oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full'
required={requiresDTag(kind)}
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>
{/if}
</div>
<div class='flex justify-end'>
<button type='submit' class='btn btn-primary border border-primary-600 px-4 py-2' disabled={loading}>Publish</button>
</div>
{#if loading}
<span class='ml-2 text-gray-500'>Publishing...</span>
{/if}
{#if error}
<div class='mt-2 text-red-600'>{error}</div>
{/if}
{#if success}
<div class='mt-2 text-green-600'>{success}</div>
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div>
{#if lastPublishedEventId}
<div class='mt-2 text-green-700'>
Event ID: <span class='font-mono'>{lastPublishedEventId}</span>
<Button onclick={viewPublishedEvent} class='text-primary-600 dark:text-primary-500 hover:underline ml-2'>
View your event
</Button>
</div>
{/if}
{/if}
</form>
</div>

2
src/lib/components/EventLimitControl.svelte

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

10
src/lib/components/EventRenderLevelLimit.svelte

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

659
src/lib/components/EventSearch.svelte

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

42
src/lib/components/Login.svelte

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

370
src/lib/components/LoginMenu.svelte

@ -0,0 +1,370 @@ @@ -0,0 +1,370 @@
<script lang='ts'>
import { Avatar, Popover } from 'flowbite-svelte';
import { UserOutline, ArrowRightToBracketOutline } from 'flowbite-svelte-icons';
import { userStore, loginWithExtension, loginWithAmber, loginWithNpub, logoutUser } from '$lib/stores/userStore';
import { get } from 'svelte/store';
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
// UI state
let isLoadingExtension: boolean = $state(false);
let isLoadingAmber: boolean = $state(false);
let result: string | null = $state(null);
let nostrConnectUri: string | undefined = $state(undefined);
let showQrCode: boolean = $state(false);
let qrCodeDataUrl: string | undefined = $state(undefined);
let loginButtonRef: HTMLElement | undefined = $state();
let resultTimeout: ReturnType<typeof setTimeout> | null = null;
let profileAvatarId = 'profile-avatar-btn';
let showAmberFallback = $state(false);
let fallbackCheckInterval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1') {
console.log('LoginMenu: Found fallback flag on mount, showing modal');
showAmberFallback = true;
}
});
// Subscribe to userStore
let user = $state(get(userStore));
userStore.subscribe(val => {
user = val;
// Check for fallback flag when user state changes to signed in
if (val.signedIn && localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) {
console.log('LoginMenu: User signed in and fallback flag found, showing modal');
showAmberFallback = true;
}
// Set up periodic check when user is signed in
if (val.signedIn && !fallbackCheckInterval) {
fallbackCheckInterval = setInterval(() => {
if (localStorage.getItem('alexandria/amber/fallback') === '1' && !showAmberFallback) {
console.log('LoginMenu: Found fallback flag during periodic check, showing modal');
showAmberFallback = true;
}
}, 500); // Check every 500ms
} else if (!val.signedIn && fallbackCheckInterval) {
clearInterval(fallbackCheckInterval);
fallbackCheckInterval = null;
}
});
// Generate QR code
const generateQrCode = async (text: string): Promise<string> => {
try {
const QRCode = await import('qrcode');
return await QRCode.toDataURL(text, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
} catch (err) {
console.error('Failed to generate QR code:', err);
return '';
}
};
// Copy to clipboard function
const copyToClipboard = async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
result = '✅ URI copied to clipboard!';
} catch (err) {
result = '❌ Failed to copy to clipboard';
}
};
// Helper to show result message near avatar and auto-dismiss
function showResultMessage(msg: string) {
result = msg;
if (resultTimeout) {
clearTimeout(resultTimeout);
}
resultTimeout = setTimeout(() => {
result = null;
}, 4000);
}
// Login handlers
const handleBrowserExtensionLogin = async () => {
isLoadingExtension = true;
isLoadingAmber = false;
try {
await loginWithExtension();
} catch (err: unknown) {
showResultMessage(`❌ Browser extension connection failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
isLoadingExtension = false;
}
};
const handleAmberLogin = async () => {
isLoadingAmber = true;
isLoadingExtension = false;
try {
const ndk = new NDK();
const relay = 'wss://relay.nsec.app';
const localNsec = localStorage.getItem('amber/nsec') ?? NDKPrivateKeySigner.generate().nsec;
const amberSigner = NDKNip46Signer.nostrconnect(ndk, relay, localNsec, {
name: 'Alexandria',
perms: 'sign_event:1;sign_event:4',
});
if (amberSigner.nostrConnectUri) {
nostrConnectUri = amberSigner.nostrConnectUri ?? undefined;
showQrCode = true;
qrCodeDataUrl = (await generateQrCode(amberSigner.nostrConnectUri)) ?? undefined;
const user = await amberSigner.blockUntilReady();
await loginWithAmber(amberSigner, user);
showQrCode = false;
} else {
throw new Error('Failed to generate Nostr Connect URI');
}
} catch (err: unknown) {
showResultMessage(`❌ Amber connection failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
isLoadingAmber = false;
}
};
const handleReadOnlyLogin = async () => {
const inputNpub = prompt('Enter your npub (public key):');
if (inputNpub) {
try {
await loginWithNpub(inputNpub);
} catch (err: unknown) {
showResultMessage(`❌ npub login failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
};
const handleLogout = () => {
localStorage.removeItem('amber/nsec');
localStorage.removeItem('alexandria/amber/fallback');
logoutUser();
};
function handleAmberReconnect() {
showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback');
handleAmberLogin();
}
function handleAmberFallbackDismiss() {
showAmberFallback = false;
localStorage.removeItem('alexandria/amber/fallback');
}
function shortenNpub(long: string | undefined) {
if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4);
}
function toNullAsUndefined(val: string | null): string | undefined {
return val === null ? undefined : val;
}
function nullToUndefined(val: string | null | undefined): string | undefined {
return val === null ? undefined : val;
}
</script>
<div class="relative">
{#if !user.signedIn}
<!-- Login button -->
<div class="group">
<button
bind:this={loginButtonRef}
id="login-avatar"
class="h-6 w-6 rounded-full bg-gray-300 flex items-center justify-center cursor-pointer hover:bg-gray-400 transition-colors"
>
<UserOutline class="h-4 w-4 text-gray-600" />
</button>
<Popover
placement="bottom"
triggeredBy="#login-avatar"
class='popover-leather w-[200px]'
trigger='click'
>
<div class='flex flex-col space-y-2'>
<h3 class='text-lg font-bold mb-2'>Login with...</h3>
<button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50'
onclick={handleBrowserExtensionLogin}
disabled={isLoadingExtension || isLoadingAmber}
>
{#if isLoadingExtension}
🔄 Connecting...
{:else}
🌐 Browser extension
{/if}
</button>
<button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500 disabled:opacity-50'
onclick={handleAmberLogin}
disabled={isLoadingAmber || isLoadingExtension}
>
{#if isLoadingAmber}
🔄 Connecting...
{:else}
📱 Amber: NostrConnect
{/if}
</button>
<button
class='btn-leather text-nowrap flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
onclick={handleReadOnlyLogin}
>
📖 npub (read only)
</button>
</div>
</Popover>
{#if result}
<div class="absolute right-0 top-10 z-50 bg-gray-100 p-3 rounded text-sm break-words whitespace-pre-line max-w-lg shadow-lg border border-gray-300">
{result}
<button class="ml-2 text-gray-500 hover:text-gray-700" onclick={() => result = null}>✖</button>
</div>
{/if}
</div>
{:else}
<!-- User profile -->
<div class="group">
<button
class='h-6 w-6 rounded-full p-0 border-0 bg-transparent cursor-pointer'
id={profileAvatarId}
type='button'
aria-label='Open profile menu'
>
<Avatar
rounded
class='h-6 w-6 cursor-pointer'
src={user.profile?.picture || undefined}
alt={user.profile?.displayName || user.profile?.name || 'User'}
/>
</button>
<Popover
placement="bottom"
triggeredBy={`#${profileAvatarId}`}
class='popover-leather w-[220px]'
trigger='click'
>
<div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col'>
<h3 class='text-lg font-bold'>{user.profile?.displayName || user.profile?.name || (user.npub ? shortenNpub(user.npub) : 'Unknown')}</h3>
<ul class="space-y-2 mt-2">
<li>
<button
class='text-sm text-primary-600 dark:text-primary-400 underline hover:text-primary-400 dark:hover:text-primary-500 px-0 bg-transparent border-none cursor-pointer'
onclick={() => goto(`/events?id=${user.npub}`)}
type='button'
>
{user.npub ? shortenNpub(user.npub) : 'Unknown'}
</button>
</li>
<li class="text-xs text-gray-500">
{#if user.loginMethod === 'extension'}
Logged in with extension
{:else if user.loginMethod === 'amber'}
Logged in with Amber
{:else if user.loginMethod === 'npub'}
Logged in with npub
{:else}
Unknown login method
{/if}
</li>
<li>
<button
id='sign-out-button'
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
onclick={handleLogout}
>
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out
</button>
</li>
</ul>
</div>
</div>
</Popover>
</div>
{/if}
</div>
{#if showQrCode && qrCodeDataUrl}
<!-- QR Code Modal -->
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Scan with Amber</h2>
<p class="text-sm text-gray-600 mb-4">Open Amber on your phone and scan this QR code</p>
<div class="flex justify-center mb-4">
<img
src={qrCodeDataUrl || ''}
alt="Nostr Connect QR Code"
class="border-2 border-gray-300 rounded-lg"
width="256"
height="256"
/>
</div>
<div class="space-y-2">
<label for="nostr-connect-uri-modal" class="block text-sm font-medium text-gray-700">Or copy the URI manually:</label>
<div class="flex">
<input
id="nostr-connect-uri-modal"
type="text"
value={nostrConnectUri || ''}
readonly
class="flex-1 border border-gray-300 rounded-l px-3 py-2 text-sm bg-gray-50"
placeholder="nostrconnect://..."
/>
<button
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-r text-sm font-medium transition-colors"
onclick={() => copyToClipboard(nostrConnectUri || '')}
>
📋 Copy
</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-4">
<p>1. Open Amber on your phone</p>
<p>2. Scan the QR code above</p>
<p>3. Approve the connection in Amber</p>
</div>
<button
class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={() => showQrCode = false}
>
Close
</button>
</div>
</div>
</div>
{/if}
{#if showAmberFallback}
<div class="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg border border-primary-300">
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Amber Session Restored</h2>
<p class="text-sm text-gray-600 mb-4">
Your Amber wallet session could not be restored automatically, so you've been switched to read-only mode.<br/>
You can still browse and read content, but you'll need to reconnect Amber to publish or comment.
</p>
<button
class="mt-4 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberReconnect}
>
Reconnect Amber
</button>
<button
class="mt-2 ml-4 bg-gray-300 hover:bg-gray-400 text-gray-800 px-4 py-2 rounded text-sm font-medium transition-colors"
onclick={handleAmberFallbackDismiss}
>
Continue in Read-Only Mode
</button>
</div>
</div>
</div>
{/if}

72
src/lib/components/LoginModal.svelte

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

12
src/lib/components/Modal.svelte

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

4
src/lib/components/Navigation.svelte

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

179
src/lib/components/Preview.svelte

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

6
src/lib/components/Publication.svelte

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

382
src/lib/components/PublicationFeed.svelte

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

160
src/lib/components/PublicationHeader.svelte

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

143
src/lib/components/PublicationSection.svelte

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

162
src/lib/components/RelayActions.svelte

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

48
src/lib/components/RelayDisplay.svelte

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

167
src/lib/components/RelayStatus.svelte

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

12
src/lib/components/Toc.svelte

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,11 +1,12 @@ @@ -1,11 +1,12 @@
<script lang='ts'>
<script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logout, ndkInstance } from '$lib/ndk';
import { logoutUser } from '$lib/stores/userStore';
import { ndkInstance } from '$lib/ndk';
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = './events?id='
import { get } from 'svelte/store';
import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props();
@ -16,25 +17,32 @@ let tag = $derived(profile?.name); @@ -16,25 +17,32 @@ let tag = $derived(profile?.name);
let npub = $state<string | undefined>(undefined);
$effect(() => {
const user = $ndkInstance
.getUser({ pubkey: pubkey ?? undefined });
const ndk = get(ndkInstance);
if (!ndk) return;
const user = ndk.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub;
user.fetchProfile()
.then(userProfile => {
.then((userProfile: NDKUserProfile | null) => {
profile = userProfile;
});
});
async function handleSignOutClick() {
logout($ndkInstance.activeUser!);
logoutUser();
profile = null;
}
function handleViewProfile() {
if (npub) {
goto(`/events?id=${encodeURIComponent(npub)}`);
}
}
function shortenNpub(long: string | undefined) {
if (!long) return '';
return long.slice(0, 8) + '…' + long.slice(-4);
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
}
</script>
@ -43,7 +51,7 @@ function shortenNpub(long: string|undefined) { @@ -43,7 +51,7 @@ function shortenNpub(long: string|undefined) {
<div class="group">
<Avatar
rounded
class='h-6 w-6 cursor-pointer'
class="h-6 w-6 cursor-pointer"
src={pfp}
alt={username}
id="profile-avatar"
@ -52,32 +60,42 @@ function shortenNpub(long: string|undefined) { @@ -52,32 +60,42 @@ function shortenNpub(long: string|undefined) {
<Popover
placement="bottom"
triggeredBy="#profile-avatar"
class='popover-leather w-[180px]'
trigger='hover'
class="popover-leather w-[180px]"
trigger="hover"
>
<div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col'>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col">
{#if username}
<h3 class='text-lg font-bold'>{username}</h3>
{#if isNav}<h4 class='text-base'>@{tag}</h4>{/if}
<h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{/if}
<ul class="space-y-2 mt-2">
<li>
<CopyToClipboard displayText={shortenNpub(npub)} copyText={npub} />
<CopyToClipboard
displayText={shortenNpub(npub)}
copyText={npub}
/>
</li>
<li>
<a class='hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0' href='{externalProfileDestination}{npub}' target='_blank'>
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span>
</a>
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
>
<UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
</button>
</li>
{#if isNav}
<li>
<button
id='sign-out-button'
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
id="sign-out-button"
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick}
>
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out
<ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button>
</li>
{:else}

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

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

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

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

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

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

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

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script>
export let size = 24; // default size
export let className = '';
export let className = "";
</script>
<svg

48
src/lib/consts.ts

@ -1,23 +1,43 @@ @@ -1,23 +1,43 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
export const communityRelay = [ 'wss://theforest.nostr1.com' ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ];
export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelays = ["wss://profiles.nostr1.com", "wss://aggr.nostr.land", "wss://relay.noswhere.com"];
export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com",
// Removed gitcitadel.nostr1.com as it's causing connection issues
//'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu',
];
// Non-auth relays for anonymous users
export const anonymousRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
"wss://profiles.nostr1.com",
"wss://freelay.sovbit.host",
];
export const fallbackRelays = [
'wss://purplepag.es',
'wss://indexer.coracle.social',
'wss://relay.noswhere.com',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://relay.lumina.rocks',
'wss://nostr.wine',
'wss://nostr.land'
"wss://purplepag.es",
"wss://indexer.coracle.social",
"wss://relay.noswhere.com",
"wss://aggr.nostr.land",
"wss://nostr.land",
"wss://nostr.wine",
"wss://nostr.sovbit.host",
"wss://freelay.sovbit.host",
"wss://nostr21.com",
"wss://greensoul.space",
"wss://relay.damus.io",
"wss://relay.nostr.band",
];
export enum FeedType {
StandardRelays = 'standard',
UserRelays = 'user',
StandardRelays = "standard",
UserRelays = "user",
}
export const loginStorageKey = 'alexandria/login/pubkey';
export const feedTypeStorageKey = 'alexandria/feed/type';
export const loginStorageKey = "alexandria/login/pubkey";
export const feedTypeStorageKey = "alexandria/feed/type";

80
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts';
import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType {
Branch,
@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
};
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent);
@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
`PublicationTree: Parent node with address ${parentAddress} not found.`,
);
}
@ -116,7 +119,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -116,7 +119,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
`PublicationTree: Parent node with address ${parentAddress} not found.`,
);
}
@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
}
return Promise.all(
node.children?.map(async child =>
(await child.value())?.address ?? null
) ?? []
node.children?.map(
async (child) => (await child.value())?.address ?? null,
) ?? [],
);
}
/**
@ -163,7 +168,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -163,7 +168,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
}
const hierarchy: NDKEvent[] = [this.#events.get(address)!];
@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #region Iteration Cursor
#cursor = new class {
#cursor = new (class {
target: PublicationTreeNode | null | undefined;
#tree: PublicationTree;
@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) {
if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value();
this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
?.value();
} else {
this.target = await this.#tree.#nodes.get(address)?.value();
}
@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
);
if (currentIndex === -1) {
@ -288,7 +298,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -288,7 +298,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
);
if (currentIndex === -1) {
@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent;
return true;
}
}(this);
})(this);
// #endregion
@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!;
let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
while (stack.length > 0) {
const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) {
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`);
throw new Error(
`PublicationTree: Node with address ${currentAddress} not found.`,
);
}
currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) {
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`);
throw new Error(
`PublicationTree: Event with address ${currentAddress} not found.`,
);
}
// Stop immediately if the target of the search is found.
@ -431,8 +448,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -431,8 +448,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a')
.map(tag => tag[1]);
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
@ -464,7 +481,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -464,7 +481,16 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#addNode(address: string, parentNode: PublicationTreeNode) {
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode));
if (this.#nodes.has(address)) {
console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return;
}
const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
}
@ -480,18 +506,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -480,18 +506,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
async #resolveNode(
address: string,
parentNode: PublicationTreeNode
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':');
const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
'#d': [dTag],
"#d": [dTag],
});
if (!event) {
console.debug(
`PublicationTree: Event with address ${address} not found.`
`PublicationTree: Event with address ${address} not found.`,
);
return {
@ -505,7 +531,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -505,7 +531,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const node: PublicationTreeNode = {
type: this.#getNodeType(event),
@ -523,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -523,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) {
if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
return PublicationTreeNodeType.Branch;
}

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

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

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

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

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

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

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

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

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

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

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

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

478
src/lib/ndk.ts

@ -1,16 +1,283 @@ @@ -1,16 +1,283 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk';
import NDK, {
NDKNip07Signer,
NDKRelay,
NDKRelayAuthPolicies,
NDKRelaySet,
NDKUser,
NDKEvent,
} from '@nostr-dev-kit/ndk';
import { get, writable, type Writable } from 'svelte/store';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays } from './consts';
import { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts';
import { feedType } from './stores';
import { userStore } from './stores/userStore';
import { userPubkey } from '$lib/stores/authStore.Svelte';
export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]);
export const outboxRelays = writable<string[]>([]);
export const ndkSignedIn: Writable<boolean> = writable(false);
/**
* Custom authentication policy that handles NIP-42 authentication manually
* when the default NDK authentication fails
*/
class CustomRelayAuthPolicy {
private ndk: NDK;
private challenges: Map<string, string> = new Map();
constructor(ndk: NDK) {
this.ndk = ndk;
}
/**
* Handles authentication for a relay
* @param relay The relay to authenticate with
* @returns Promise that resolves when authentication is complete
*/
async authenticate(relay: NDKRelay): Promise<void> {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn(
"[NDK.ts] No signer or active user available for relay authentication",
);
return;
}
try {
console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`);
// Listen for AUTH challenges
relay.on("auth", (challenge: string) => {
console.debug(
`[NDK.ts] Received AUTH challenge from ${relay.url}:`,
challenge,
);
this.challenges.set(relay.url, challenge);
this.handleAuthChallenge(relay, challenge);
});
// Listen for auth-required errors (handle via notice events)
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message);
this.handleAuthRequired(relay, message);
}
});
// Listen for successful authentication
relay.on("authed", () => {
console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`);
});
// Listen for authentication failures
relay.on("auth:failed", (error: any) => {
console.error(
`[NDK.ts] Authentication failed for ${relay.url}:`,
error,
);
});
} catch (error) {
console.error(
`[NDK.ts] Error setting up authentication for ${relay.url}:`,
error,
);
}
}
/**
* Handles AUTH challenge from relay
*/
private async handleAuthChallenge(
relay: NDKRelay,
challenge: string,
): Promise<void> {
try {
if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn("[NDK.ts] No signer available for AUTH challenge");
return;
}
// Create NIP-42 authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", relay.url],
["challenge", challenge],
],
content: "",
pubkey: this.ndk.activeUser.pubkey,
};
// Create and sign the authentication event using NDKEvent
const authNDKEvent = new NDKEvent(this.ndk, authEvent);
await authNDKEvent.sign();
// Send AUTH message to relay using the relay's publish method
await relay.publish(authNDKEvent);
console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`);
} catch (error) {
console.error(
`[NDK.ts] Error handling AUTH challenge for ${relay.url}:`,
error,
);
}
}
/**
* Handles auth-required error from relay
*/
private async handleAuthRequired(
relay: NDKRelay,
message: string,
): Promise<void> {
const challenge = this.challenges.get(relay.url);
if (challenge) {
await this.handleAuthChallenge(relay, challenge);
} else {
console.warn(
`[NDK.ts] Auth required from ${relay.url} but no challenge available`,
);
}
}
}
/**
* Checks if the current environment might cause WebSocket protocol downgrade
*/
export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug("[NDK.ts] Environment Check for WebSocket Protocol:");
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
const isHttp = window.location.protocol === "http:";
const isHttps = window.location.protocol === "https:";
console.debug("[NDK.ts] - Is localhost:", isLocalhost);
console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug("[NDK.ts] - Is HTTP:", isHttp);
console.debug("[NDK.ts] - Is HTTPS:", isHttps);
if (isLocalhost && isHttp) {
console.warn(
"[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected",
);
console.warn("[NDK.ts] This is normal for development environments");
} else if (isHttp) {
console.error(
"[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure",
);
console.error("[NDK.ts] Consider using HTTPS in production");
} else if (isHttps) {
console.debug(
"[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work",
);
}
}
/**
* Checks WebSocket protocol support and logs diagnostic information
*/
export function checkWebSocketSupport(): void {
console.debug("[NDK.ts] WebSocket Support Diagnostics:");
console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug("[NDK.ts] - Hostname:", window.location.hostname);
console.debug("[NDK.ts] - Port:", window.location.port);
console.debug("[NDK.ts] - User Agent:", navigator.userAgent);
// Test if secure WebSocket is supported
try {
const testWs = new WebSocket("wss://echo.websocket.org");
testWs.onopen = () => {
console.debug("[NDK.ts] ✓ Secure WebSocket (wss://) is supported");
testWs.close();
};
testWs.onerror = () => {
console.warn("[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported");
};
} catch (error) {
console.warn("[NDK.ts] ✗ WebSocket test failed:", error);
}
}
/**
* Tests connection to a relay and returns connection status
* @param relayUrl The relay URL to test
* @param ndk The NDK instance
* @returns Promise that resolves to connection status
*/
export async function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean;
requiresAuth: boolean;
error?: string;
actualUrl?: string;
}> {
return new Promise((resolve) => {
console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl);
const relay = new NDKRelay(secureUrl, undefined, new NDK());
let authRequired = false;
let connected = false;
let error: string | undefined;
let actualUrl: string | undefined;
const timeout = setTimeout(() => {
relay.disconnect();
resolve({
connected: false,
requiresAuth: authRequired,
error: "Connection timeout",
actualUrl,
});
}, 5000);
relay.on("connect", () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`);
connected = true;
actualUrl = secureUrl;
clearTimeout(timeout);
relay.disconnect();
resolve({
connected: true,
requiresAuth: authRequired,
error,
actualUrl,
});
});
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
authRequired = true;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
}
});
export const activePubkey: Writable<string | null> = writable(null);
relay.on("disconnect", () => {
if (!connected) {
error = "Connection failed";
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl,
});
}
});
export const inboxRelays: Writable<string[]> = writable([]);
export const outboxRelays: Writable<string[]> = writable([]);
// Log the actual WebSocket URL being used
console.debug(`[NDK.ts] Attempting connection to: ${secureUrl}`);
relay.connect();
});
}
/**
* Gets the user's pubkey from local storage, if it exists.
@ -47,7 +314,7 @@ export function clearLogin(): void { @@ -47,7 +314,7 @@ export function clearLogin(): void {
* @param type The type of relay list to designate.
* @returns The constructed key.
*/
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`;
}
@ -57,14 +324,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { @@ -57,14 +324,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
* @param inboxes The user's inbox relays.
* @param outboxes The user's outbox relays.
*/
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void {
function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem(
getRelayStorageKey(user, 'inbox'),
JSON.stringify(Array.from(inboxes).map(relay => relay.url))
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))
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
}
@ -76,24 +347,104 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR @@ -76,24 +347,104 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR
*/
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]')
JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
);
const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]')
JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
);
return [inboxes, outboxes];
}
export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox'));
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, "outbox"));
}
/**
* Ensures a relay URL uses secure WebSocket protocol
* @param url The relay URL to secure
* @returns The URL with wss:// protocol
*/
function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present
const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) {
console.warn(
`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`,
);
}
return secureUrl;
}
/**
* Creates a relay with proper authentication handling
*/
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url);
// Add connection timeout and error handling
const relay = new NDKRelay(
secureUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
// Set up connection timeout
const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect();
}, 10000); // 10 second timeout
// Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
authPolicy.authenticate(relay);
});
} else {
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
}
// Add error handling
relay.on("disconnect", () => {
console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
return relay;
}
export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays
const user = get(userStore);
// Filter out problematic relays that are known to cause connection issues
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
// Filter out gitcitadel.nostr1.com which is causing connection issues
if (relay.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay: ${relay}`);
return false;
}
return true;
});
};
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => new NDKRelay(
new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
@ -101,7 +452,7 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -101,7 +452,7 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
ndk
)
: new NDKRelaySet(
new Set(standardRelays.map(relay => new NDKRelay(
new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
@ -117,21 +468,45 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -117,21 +468,45 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
*/
export function initNdk(): NDK {
const startingPubkey = getPersistedLogin();
const [startingInboxes, _] = startingPubkey != null
const [startingInboxes, _] =
startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
// Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (
startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays
).map(ensureSecureWebSocket);
console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls);
const ndk = new NDK({
autoConnectUserRelays: true,
enableOutboxModel: true,
explicitRelayUrls: startingInboxes != null
? Array.from(startingInboxes.values())
: standardRelays,
explicitRelayUrls: secureRelayUrls,
});
// TODO: Should we prompt the user to confirm authentication?
// Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
ndk.connect().then(() => console.debug("ndk connected"));
// Connect with better error handling
ndk.connect()
.then(() => {
console.debug("[NDK.ts] NDK connected successfully");
})
.catch((error) => {
console.error("[NDK.ts] Failed to connect NDK:", error);
// Try to reconnect after a delay
setTimeout(() => {
console.debug("[NDK.ts] Attempting to reconnect...");
ndk.connect().catch((retryError) => {
console.error("[NDK.ts] Reconnection failed:", retryError);
});
}, 5000);
});
return ndk;
}
@ -142,7 +517,9 @@ export function initNdk(): NDK { @@ -142,7 +517,9 @@ export function initNdk(): NDK {
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists.
*/
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> {
export async function loginWithExtension(
pubkey?: string,
): Promise<NDKUser | null> {
try {
const ndk = get(ndkInstance);
const signer = new NDKNip07Signer();
@ -150,12 +527,14 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul @@ -150,12 +527,14 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
// TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('Switching pubkeys from last login.');
console.debug("[NDK.ts] Switching pubkeys from last login.");
}
activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser);
const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
@ -163,8 +542,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul @@ -163,8 +542,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url));
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url));
inboxRelays.set(
Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes);
@ -188,7 +571,9 @@ export function logout(user: NDKUser): void { @@ -188,7 +571,9 @@ export function logout(user: NDKUser): void {
clearLogin();
clearPersistedRelays(user);
activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
}
/**
@ -196,10 +581,10 @@ export function logout(user: NDKUser): void { @@ -196,10 +581,10 @@ export function logout(user: NDKUser): void {
* relay sets.
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`.
*/
async function getUserPreferredRelays(
export async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = fallbackRelays
fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
@ -217,27 +602,40 @@ async function getUserPreferredRelays( @@ -217,27 +602,40 @@ async function getUserPreferredRelays(
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
// Filter out problematic relays
const filterProblematicRelay = (url: string): boolean => {
if (url.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`);
return false;
}
return true;
};
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk);
if (filterProblematicRelay(url)) {
const relay = createRelayWithAuth(url, ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
}
});
} else {
relayList.tags.forEach(tag => {
relayList.tags.forEach((tag) => {
if (filterProblematicRelay(tag[1])) {
switch (tag[0]) {
case 'r':
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
case "r":
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
case 'w':
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
case "w":
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
default:
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk));
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
}
}
});
}

429
src/lib/parser.ts

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

14
src/lib/snippets/PublicationSnippets.svelte

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

54
src/lib/snippets/UserSnippets.svelte

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

5
src/lib/stores.ts

@ -7,14 +7,13 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]); @@ -7,14 +7,13 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = {
toc: false,
blog: true,
main: true,
inner: false,
discussion: false,
editing: false
editing: false,
};
function createVisibilityStore() {
@ -24,7 +23,7 @@ function createVisibilityStore() { @@ -24,7 +23,7 @@ function createVisibilityStore() {
subscribe,
set,
update,
reset: () => set({ ...defaultVisibility })
reset: () => set({ ...defaultVisibility }),
};
}

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

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

2
src/lib/stores/relayStore.ts

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

301
src/lib/stores/userStore.ts

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

8
src/lib/types.ts

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

26
src/lib/utils.ts

@ -12,9 +12,9 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -12,9 +12,9 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
}
export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = getMatchingTags(event, 'd')[0]?.[1];
const dTag = getMatchingTags(event, "d")[0]?.[1];
if (!dTag) {
throw new Error('Event does not have a d tag');
throw new Error("Event does not have a d tag");
}
return nip19.naddrEncode({
@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) { @@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) {
export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// The filter object supports only limited parameters, so we need to filter out events that
// don't respect NKBIP-01.
events.forEach(event => {
events.forEach((event) => {
// Index events have no content, and they must have `title`, `d`, and `e` tags.
if (
(event.content != null && event.content.length > 0)
|| getMatchingTags(event, 'title').length === 0
|| getMatchingTags(event, 'd').length === 0
|| (
getMatchingTags(event, 'a').length === 0
&& getMatchingTags(event, 'e').length === 0
)
(event.content != null && event.content.length > 0) ||
getMatchingTags(event, "title").length === 0 ||
getMatchingTags(event, "d").length === 0 ||
(getMatchingTags(event, "a").length === 0 &&
getMatchingTags(event, "e").length === 0)
) {
events.delete(event);
}
@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
*/
export async function findIndexAsync<T>(
array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> {
for (let i = 0; i < array.length; i++) {
if (await predicate(array[i], i, array)) {
@ -152,14 +150,14 @@ export async function findIndexAsync<T>( @@ -152,14 +150,14 @@ export async function findIndexAsync<T>(
declare global {
interface Array<T> {
findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number>;
}
}
Array.prototype.findIndexAsync = function <T>(
this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> {
return findIndexAsync(this, predicate);
};
@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>( @@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>(
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;

86
src/lib/utils/community_checker.ts

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

400
src/lib/utils/event_input_utils.ts

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

224
src/lib/utils/event_search.ts

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

132
src/lib/utils/indexEventCache.ts

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

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

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

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

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

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

@ -1,13 +1,29 @@ @@ -1,13 +1,29 @@
import { parseBasicmarkup } from './basicMarkupParser';
import hljs from 'highlight.js';
import 'highlight.js/lib/common'; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only
import { parseBasicmarkup } from "./basicMarkupParser";
import hljs from "highlight.js";
import "highlight.js/lib/common"; // Import common languages
import "highlight.js/styles/github-dark.css"; // Dark theme only
// Register common languages
hljs.configure({
ignoreUnescapedHTML: true
ignoreUnescapedHTML: true,
});
// Escapes HTML characters for safe display
function escapeHtml(text: string): string {
const div = typeof document !== 'undefined' ? document.createElement('div') : null;
if (div) {
div.textContent = text;
return div.innerHTML;
}
// Fallback for non-browser environments
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Regular expressions for advanced markup elements
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
@ -17,18 +33,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; @@ -17,18 +33,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
const CODE_BLOCK_REGEX = /^```(\w*)$/;
// LaTeX math regex patterns
const INLINE_MATH_REGEX = /\$([^$\n]+?)\$/g;
const DISPLAY_MATH_REGEX = /\$\$([\s\S]*?)\$\$/g;
const LATEX_BLOCK_REGEX = /\\\[([\s\S]*?)\\\]/g;
const LATEX_INLINE_REGEX = /\\\(([^)]+?)\\\)/g;
// Add regex for LaTeX display math environments (e.g., \begin{pmatrix}...\end{pmatrix})
// Improved regex: match optional whitespace/linebreaks before and after, and allow for indented environments
const LATEX_ENV_BLOCK_REGEX =
/(?:^|\n)\s*\\begin\{([a-zA-Z*]+)\}([\s\S]*?)\\end\{\1\}\s*(?=\n|$)/gm;
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Tailwind classes for each heading level
const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
"text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h1
"text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h2
"text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h3
"text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h4
"text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h5
"text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h6
];
// Process ATX-style headings (# Heading)
@ -39,11 +65,14 @@ function processHeadings(content: string): string { @@ -39,11 +65,14 @@ function processHeadings(content: string): string {
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
processedContent = processedContent.replace(
ALTERNATE_HEADING_REGEX,
(_, text, level) => {
const headingLevel = level[0] === "=" ? 1 : 2;
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
},
);
return processedContent;
}
@ -53,24 +82,25 @@ function processHeadings(content: string): string { @@ -53,24 +82,25 @@ function processHeadings(content: string): string {
*/
function processTables(content: string): string {
try {
if (!content) return '';
if (!content) return "";
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim());
const rows = match.split("\n").filter((row) => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
.split('|')
.split("|")
.slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim());
.map((cell) => cell.trim());
};
// Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
const hasHeader =
rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows
let headerCells: string[] = [];
@ -91,33 +121,33 @@ function processTables(content: string): string { @@ -91,33 +121,33 @@ function processTables(content: string): string {
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += "<thead>\n<tr>\n";
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 += '</tr>\n</thead>\n';
html += "</tr>\n</thead>\n";
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
html += "<tbody>\n";
bodyRows.forEach((row) => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += "<tr>\n";
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 += '</tr>\n';
html += "</tr>\n";
});
html += '</tbody>\n</table>\n</div>';
html += "</tbody>\n</table>\n</div>";
return html;
} catch (e: unknown) {
console.error('Error processing table row:', e);
console.error("Error processing table row:", e);
return match;
}
});
} catch (e: unknown) {
console.error('Error in processTables:', e);
console.error("Error in processTables:", e);
return content;
}
}
@ -126,8 +156,9 @@ function processTables(content: string): string { @@ -126,8 +156,9 @@ function processTables(content: string): string {
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
return content.replace(
HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">',
);
}
@ -136,7 +167,7 @@ function processHorizontalRules(content: string): string { @@ -136,7 +167,7 @@ function processHorizontalRules(content: string): string {
*/
function processFootnotes(content: string): string {
try {
if (!content) return '';
if (!content) return "";
// Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map<string, string>();
@ -146,15 +177,19 @@ function processFootnotes(content: string): string { @@ -146,15 +177,19 @@ function processFootnotes(content: string): string {
});
// Remove all footnote definition lines from the main content
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, '');
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, "");
// Track all references to each footnote
const referenceOrder: { id: string, refNum: number, label: string }[] = [];
const referenceOrder: { id: string; refNum: number; label: string }[] = [];
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
processedContent = processedContent.replace(
FOOTNOTE_REFERENCE_REGEX,
(match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
console.warn(
`Footnote reference [^${id}] found but no definition exists`,
);
return match;
}
const refNum = globalRefNum++;
@ -162,32 +197,37 @@ function processFootnotes(content: string): string { @@ -162,32 +197,37 @@ function processFootnotes(content: string): string {
referenceMap.get(id)!.push(refNum);
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>`;
});
},
);
// Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
processedContent +=
'\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference
const seen = new Set<string>();
for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue;
seen.add(id);
const text = footnotes.get(id) || '';
const text = footnotes.get(id) || "";
// List of backrefs for this footnote
const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`
).join(' ');
const backrefs = refs
.map(
(num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`,
)
.join(" ");
// If label is not a number, show it after all backrefs
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 += '</ol>';
processedContent += "</ol>";
}
return processedContent;
} catch (error) {
console.error('Error processing footnotes:', error);
console.error("Error processing footnotes:", error);
return content;
}
}
@ -202,9 +242,9 @@ function processBlockquotes(content: string): string { @@ -202,9 +242,9 @@ function processBlockquotes(content: string): string {
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split('\n')
.map(line => line.replace(/^>[ \t]?/, ''))
.join('\n')
.split("\n")
.map((line) => line.replace(/^>[ \t]?/, ""))
.join("\n")
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
@ -214,13 +254,16 @@ function processBlockquotes(content: string): string { @@ -214,13 +254,16 @@ function processBlockquotes(content: string): string {
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
function processCodeBlocks(text: string): {
text: string;
blocks: Map<string, string>;
} {
const lines = text.split("\n");
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let currentLanguage = "";
let blockCount = 0;
let lastWasCodeBlock = false;
@ -239,11 +282,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -239,11 +282,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
// Ending current code block
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
const code = currentCode.join("\n");
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
if (currentLanguage.toLowerCase() === "json") {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
@ -251,24 +294,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -251,24 +294,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
}
}
blocks.set(id, JSON.stringify({
blocks.set(
id,
JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
raw: true,
}),
);
processedLines.push(''); // Add spacing before code block
processedLines.push(""); // Add spacing before code block
processedLines.push(id);
processedLines.push(''); // Add spacing after code block
processedLines.push(""); // Add spacing after code block
inCodeBlock = false;
currentCode = [];
currentLanguage = '';
currentLanguage = "";
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
if (lastWasCodeBlock && line.trim()) {
processedLines.push('');
processedLines.push("");
lastWasCodeBlock = false;
}
processedLines.push(line);
@ -279,11 +325,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -279,11 +325,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
const code = currentCode.join("\n");
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
if (currentLanguage.toLowerCase() === "json") {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
@ -291,19 +337,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -291,19 +337,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
}
}
blocks.set(id, JSON.stringify({
blocks.set(
id,
JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push('');
raw: true,
}),
);
processedLines.push("");
processedLines.push(id);
processedLines.push('');
processedLines.push("");
}
return {
text: processedLines.join('\n'),
blocks
text: processedLines.join("\n"),
blocks,
};
}
@ -322,12 +371,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { @@ -322,12 +371,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
try {
const highlighted = hljs.highlight(code, {
language,
ignoreIllegals: true
ignoreIllegals: true,
}).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
console.warn("Failed to highlight code block:", e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ""}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
@ -335,55 +384,346 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { @@ -335,55 +384,346 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
result = result.replace(id, html);
} catch (e: unknown) {
console.error('Error restoring code block:', e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
console.error("Error restoring code block:", e);
result = result.replace(
id,
'<pre class="code-block"><code class="hljs">Error processing code block</code></pre>',
);
}
}
return result;
}
/**
* Process $...$ and $$...$$ math blocks: render as LaTeX if recognized, otherwise as AsciiMath
* This must run BEFORE any paragraph or inline code formatting.
*/
function processDollarMath(content: string): string {
// Display math: $$...$$ (multi-line, not empty)
content = content.replace(/\$\$([\s\S]*?\S[\s\S]*?)\$\$/g, (match, expr) => {
if (isLaTeXContent(expr)) {
return `<div class="math-block">$$${expr}$$</div>`;
} else {
// Strip all $ or $$ from AsciiMath
const clean = expr.replace(/\$+/g, '').trim();
return `<div class="math-block" data-math-type="asciimath">${clean}</div>`;
}
});
// Inline math: $...$ (not empty, not just whitespace)
content = content.replace(/\$([^\s$][^$\n]*?)\$/g, (match, expr) => {
if (isLaTeXContent(expr)) {
return `<span class="math-inline">$${expr}$</span>`;
} else {
const clean = expr.replace(/\$+/g, '').trim();
return `<span class="math-inline" data-math-type="asciimath">${clean}</span>`;
}
});
return content;
}
/**
* Process LaTeX math expressions only within inline code blocks
*/
function processMathExpressions(content: string): string {
// Only process LaTeX within inline code blocks (backticks)
return content.replace(INLINE_CODE_REGEX, (match, code) => {
const trimmedCode = code.trim();
// Check for unsupported LaTeX environments (like tabular) first
if (/\\begin\{tabular\}|\\\\begin\{tabular\}/.test(trimmedCode)) {
return `<div class="unrendered-latex">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Unrendered, as it is LaTeX typesetting, not a formula:
</p>
<pre class="bg-gray-100 dark:bg-gray-900 p-2 rounded text-xs overflow-x-auto">
<code>${escapeHtml(trimmedCode)}</code>
</pre>
</div>`;
}
// Check if the code contains LaTeX syntax
if (isLaTeXContent(trimmedCode)) {
// Detect LaTeX display math (\\[...\\])
if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\\\[|\\\]$/g, '');
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect display math ($$...$$)
if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$\$|\$\$$/g, '');
return `<div class="math-block">$$${inner}$$</div>`;
}
// Detect inline math ($...$)
if (/^\$[\s\S]*\$$/.test(trimmedCode)) {
// Remove the delimiters for rendering
const inner = trimmedCode.replace(/^\$|\$$/g, '');
return `<span class="math-inline">$${inner}$</span>`;
}
// Default to inline math for any other LaTeX content
return `<span class="math-inline">$${trimmedCode}$</span>`;
} else {
// Check for edge cases that should remain as code, not math
// These patterns indicate code that contains dollar signs but is not math
const codePatterns = [
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/, // Variable assignment like "const price ="
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/, // Function call like "echo("
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\{/, // Object literal like "const obj = {"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\[/, // Array literal like "const arr = ["
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*</, // JSX or HTML like "const element = <"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*`/, // Template literal like "const str = `"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*'/, // String literal like "const str = '"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*"/, // String literal like "const str = \""
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*;/, // Statement ending like "const x = 1;"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*$/, // Just a variable name
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Two identifiers like "const price = amount"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Number like "const x = 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Complex expression like "const price = amount +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Three identifiers like "const price = amount + tax"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Two identifiers and number like "const price = amount + 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Identifier, number, operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Identifier, number, identifier like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[0-9]/, // Identifier, number, number like "const x = 1 + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Complex like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Complex like "const x = 1 + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + y +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + y + z"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + y + 2"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + 2 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + 2 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + 2 + 3"
// Additional patterns for JavaScript template literals and other code
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*`/, // Template literal assignment like "const str = `"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*'/, // String assignment like "const str = '"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*"/, // String assignment like "const str = \""
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]/, // Number assignment like "const x = 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Variable assignment like "const x = y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[+\-*/%=<>!&|^~]/, // Assignment with operator like "const x = +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Assignment with variable and operator like "const x = y +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with two variables and operator like "const x = y + z"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Assignment with number and operator like "const x = 1 +"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with number, operator, variable like "const x = 1 + y"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with variable, operator, number like "const x = y + 1"
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with number, operator, number like "const x = 1 + 2"
];
// If it matches code patterns, treat as regular code
if (codePatterns.some(pattern => pattern.test(trimmedCode))) {
const escapedCode = trimmedCode
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
}
// Return as regular inline code
const escapedCode = trimmedCode
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
}
});
}
/**
* Checks if content contains LaTeX syntax
*/
function isLaTeXContent(content: string): boolean {
const trimmed = content.trim();
// Check for simple math expressions first (like AsciiMath)
if (/^\$[^$]+\$$/.test(trimmed)) {
return true;
}
// Check for display math
if (/^\$\$[\s\S]*\$\$$/.test(trimmed)) {
return true;
}
// Check for LaTeX display math
if (/^\\\[[\s\S]*\\\]$/.test(trimmed)) {
return true;
}
// Check for LaTeX environments with double backslashes (like tabular)
if (/\\\\begin\{[^}]+\}/.test(trimmed) || /\\\\end\{[^}]+\}/.test(trimmed)) {
return true;
}
// Check for common LaTeX patterns
const latexPatterns = [
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
/\\\\[a-zA-Z]+/, // LaTeX commands with double backslashes like \\frac, \\sum, etc.
/\\[\(\)\[\]]/, // LaTeX delimiters like \(, \), \[, \]
/\\\\[\(\)\[\]]/, // LaTeX delimiters with double backslashes like \\(, \\), \\[, \\]
/\\\[[\s\S]*?\\\]/, // LaTeX display math \[ ... \]
/\\\\\[[\s\S]*?\\\\\]/, // LaTeX display math with double backslashes \\[ ... \\]
/\\begin\{/, // LaTeX environments
/\\\\begin\{/, // LaTeX environments with double backslashes
/\\end\{/, // LaTeX environments
/\\\\end\{/, // LaTeX environments with double backslashes
/\\begin\{array\}/, // LaTeX array environment
/\\\\begin\{array\}/, // LaTeX array environment with double backslashes
/\\end\{array\}/,
/\\\\end\{array\}/,
/\\begin\{matrix\}/, // LaTeX matrix environment
/\\\\begin\{matrix\}/, // LaTeX matrix environment with double backslashes
/\\end\{matrix\}/,
/\\\\end\{matrix\}/,
/\\begin\{bmatrix\}/, // LaTeX bmatrix environment
/\\\\begin\{bmatrix\}/, // LaTeX bmatrix environment with double backslashes
/\\end\{bmatrix\}/,
/\\\\end\{bmatrix\}/,
/\\begin\{pmatrix\}/, // LaTeX pmatrix environment
/\\\\begin\{pmatrix\}/, // LaTeX pmatrix environment with double backslashes
/\\end\{pmatrix\}/,
/\\\\end\{pmatrix\}/,
/\\begin\{tabular\}/, // LaTeX tabular environment
/\\\\begin\{tabular\}/, // LaTeX tabular environment with double backslashes
/\\end\{tabular\}/,
/\\\\end\{tabular\}/,
/\$\$/, // Display math delimiters
/\$[^$]+\$/, // Inline math delimiters
/\\text\{/, // LaTeX text command
/\\\\text\{/, // LaTeX text command with double backslashes
/\\mathrm\{/, // LaTeX mathrm command
/\\\\mathrm\{/, // LaTeX mathrm command with double backslashes
/\\mathbf\{/, // LaTeX bold command
/\\\\mathbf\{/, // LaTeX bold command with double backslashes
/\\mathit\{/, // LaTeX italic command
/\\\\mathit\{/, // LaTeX italic command with double backslashes
/\\sqrt/, // Square root
/\\\\sqrt/, // Square root with double backslashes
/\\frac/, // Fraction
/\\\\frac/, // Fraction with double backslashes
/\\sum/, // Sum
/\\\\sum/, // Sum with double backslashes
/\\int/, // Integral
/\\\\int/, // Integral with double backslashes
/\\lim/, // Limit
/\\\\lim/, // Limit with double backslashes
/\\infty/, // Infinity
/\\\\infty/, // Infinity with double backslashes
/\\alpha/, // Greek letters
/\\\\alpha/, // Greek letters with double backslashes
/\\beta/,
/\\\\beta/,
/\\gamma/,
/\\\\gamma/,
/\\delta/,
/\\\\delta/,
/\\theta/,
/\\\\theta/,
/\\lambda/,
/\\\\lambda/,
/\\mu/,
/\\\\mu/,
/\\pi/,
/\\\\pi/,
/\\sigma/,
/\\\\sigma/,
/\\phi/,
/\\\\phi/,
/\\omega/,
/\\\\omega/,
/\\partial/, // Partial derivative
/\\\\partial/, // Partial derivative with double backslashes
/\\nabla/, // Nabla
/\\\\nabla/, // Nabla with double backslashes
/\\cdot/, // Dot product
/\\\\cdot/, // Dot product with double backslashes
/\\times/, // Times
/\\\\times/, // Times with double backslashes
/\\div/, // Division
/\\\\div/, // Division with double backslashes
/\\pm/, // Plus-minus
/\\\\pm/, // Plus-minus with double backslashes
/\\mp/, // Minus-plus
/\\\\mp/, // Minus-plus with double backslashes
/\\leq/, // Less than or equal
/\\\\leq/, // Less than or equal with double backslashes
/\\geq/, // Greater than or equal
/\\\\geq/, // Greater than or equal with double backslashes
/\\neq/, // Not equal
/\\\\neq/, // Not equal with double backslashes
/\\approx/, // Approximately equal
/\\\\approx/, // Approximately equal with double backslashes
/\\equiv/, // Equivalent
/\\\\equiv/, // Equivalent with double backslashes
/\\propto/, // Proportional
/\\\\propto/, // Proportional with double backslashes
/\\in/, // Element of
/\\\\in/, // Element of with double backslashes
/\\notin/, // Not element of
/\\\\notin/, // Not element of with double backslashes
/\\subset/, // Subset
/\\\\subset/, // Subset with double backslashes
/\\supset/, // Superset
/\\\\supset/, // Superset with double backslashes
/\\cup/, // Union
/\\\\cup/, // Union with double backslashes
/\\cap/, // Intersection
/\\\\cap/, // Intersection with double backslashes
/\\emptyset/, // Empty set
/\\\\emptyset/, // Empty set with double backslashes
/\\mathbb\{/, // Blackboard bold
/\\\\mathbb\{/, // Blackboard bold with double backslashes
/\\mathcal\{/, // Calligraphic
/\\\\mathcal\{/, // Calligraphic with double backslashes
/\\mathfrak\{/, // Fraktur
/\\\\mathfrak\{/, // Fraktur with double backslashes
/\\mathscr\{/, // Script
/\\\\mathscr\{/, // Script with double backslashes
];
return latexPatterns.some(pattern => pattern.test(trimmed));
}
/**
* Parse markup text with advanced formatting
*/
export async function parseAdvancedmarkup(text: string): Promise<string> {
if (!text) return '';
if (!text) return "";
try {
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode;
// Step 2: Process block-level elements
// Step 2: Process $...$ and $$...$$ math blocks (LaTeX or AsciiMath)
processedText = processDollarMath(processedText);
// Step 3: Process LaTeX math expressions ONLY within inline code blocks (legacy support)
processedText = processMathExpressions(processedText);
// Step 4: Process block-level elements (tables, blockquotes, headings, horizontal rules)
processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText);
// Process inline elements
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
// Process footnotes (only references, not definitions)
// Step 5: Process footnotes (only references, not definitions)
processedText = processFootnotes(processedText);
// Process basic markup (which will also handle Nostr identifiers)
// Step 6: Process basic markup (which will also handle Nostr identifiers)
// This includes paragraphs, inline code, links, lists, etc.
processedText = await parseBasicmarkup(processedText);
// Step 3: Restore code blocks
// Step 7: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (e: unknown) {
console.error('Error in parseAdvancedmarkup:', e);
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
console.error("Error in parseAdvancedmarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`;
}
}

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

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

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

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
import { processNostrIdentifiers } from "../nostrUtils";
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Replaces wikilinks in the format [[target]] or [[target|display]] with
* clickable links to the events page.
*/
function replaceWikilinks(html: string): string {
// [[target page]] or [[target page|display text]]
return html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(
/<a id="([^"]+)"><\/a>/g,
(_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
}
);
}
/**
* Processes nostr addresses in HTML content, but skips addresses that are
* already within hyperlink tags.
*/
async function processNostrAddresses(html: string): Promise<string> {
// Helper to check if a match is within an existing <a> tag
function isWithinLink(text: string, index: number): boolean {
// Look backwards from the match position to find the nearest <a> tag
const before = text.slice(0, index);
const lastOpenTag = before.lastIndexOf("<a");
const lastCloseTag = before.lastIndexOf("</a>");
// If we find an opening <a> tag after the last closing </a> tag, we're inside a link
return lastOpenTag > lastCloseTag;
}
// Process nostr addresses that are not within existing links
const nostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedHtml = html;
// Find all nostr addresses
const matches = Array.from(processedHtml.matchAll(nostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if already within a link
if (isWithinLink(processedHtml, matchIndex)) {
continue;
}
// Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch);
// Replace the match in the HTML
processedHtml =
processedHtml.slice(0, matchIndex) +
processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
}
return processedHtml;
}
/**
* Fixes AsciiDoctor stem blocks for MathJax rendering.
* Joins split spans and wraps content in $$...$$ for block math.
*/
function fixStemBlocks(html: string): string {
// Replace <div class="stemblock"><div class="content"><span>$</span>...<span>$</span></div></div>
// with <div class="stemblock"><div class="content">$$...$$</div></div>
return html.replace(
/<div class="stemblock">\s*<div class="content">\s*<span>\$<\/span>([\s\S]*?)<span>\$<\/span>\s*<\/div>\s*<\/div>/g,
(_match, mathContent) => {
// Remove any extra tags inside mathContent
const cleanMath = mathContent.replace(/<\/?span[^>]*>/g, "").trim();
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`;
},
);
}
/**
* Post-processes asciidoctor HTML output to add wikilink and nostr address rendering.
* This function should be called after asciidoctor.convert() to enhance the HTML output.
*/
export async function postProcessAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html;
try {
// First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain)
processedHtml = replaceWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links)
processedHtml = await processNostrAddresses(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
return processedHtml;
} catch (error) {
console.error("Error in postProcessAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails
}
}

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

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

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

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

24
src/lib/utils/mime.ts

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

431
src/lib/utils/nostrEventService.ts

@ -0,0 +1,431 @@ @@ -0,0 +1,431 @@
import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
export interface RootEventInfo {
rootId: string;
rootPubkey: string;
rootRelay: string;
rootKind: number;
rootAddress: string;
rootIValue: string;
rootIRelay: string;
isRootA: boolean;
isRootI: boolean;
}
export interface ParentEventInfo {
parentId: string;
parentPubkey: string;
parentRelay: string;
parentKind: number;
parentAddress: string;
}
export interface EventPublishResult {
success: boolean;
relay?: string;
eventId?: string;
error?: string;
}
/**
* Helper function to find a tag by its first element
*/
function findTag(tags: string[][], tagName: string): string[] | undefined {
return tags?.find((t: string[]) => t[0] === tagName);
}
/**
* Helper function to get tag value safely
*/
function getTagValue(tags: string[][], tagName: string, index: number = 1): string {
const tag = findTag(tags, tagName);
return tag?.[index] || '';
}
/**
* Helper function to create a tag array
*/
function createTag(name: string, ...values: (string | number)[]): string[] {
return [name, ...values.map(v => String(v))];
}
/**
* Helper function to add tags to an array
*/
function addTags(tags: string[][], ...newTags: string[][]): void {
tags.push(...newTags);
}
/**
* Extract root event information from parent event tags
*/
export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
const rootInfo: RootEventInfo = {
rootId: parent.id,
rootPubkey: getPubkeyString(parent.pubkey),
rootRelay: getRelayString(parent.relay),
rootKind: parent.kind || 1,
rootAddress: '',
rootIValue: '',
rootIRelay: '',
isRootA: false,
isRootI: false,
};
if (!parent.tags) return rootInfo;
const rootE = findTag(parent.tags, 'E');
const rootA = findTag(parent.tags, 'A');
const rootI = findTag(parent.tags, 'I');
rootInfo.isRootA = !!rootA;
rootInfo.isRootI = !!rootI;
if (rootE) {
rootInfo.rootId = rootE[1];
rootInfo.rootRelay = getRelayString(rootE[2]);
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
} else if (rootA) {
rootInfo.rootAddress = rootA[1];
rootInfo.rootRelay = getRelayString(rootA[2]);
rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
} else if (rootI) {
rootInfo.rootIValue = rootI[1];
rootInfo.rootIRelay = getRelayString(rootI[2]);
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
}
return rootInfo;
}
/**
* Extract parent event information
*/
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
const dTag = getTagValue(parent.tags || [], 'd');
const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : '';
return {
parentId: parent.id,
parentPubkey: getPubkeyString(parent.pubkey),
parentRelay: getRelayString(parent.relay),
parentKind: parent.kind || 1,
parentAddress,
};
}
/**
* Build root scope tags for NIP-22 threading
*/
function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] {
const tags: string[][] = [];
if (rootInfo.rootAddress) {
const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E';
addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay));
} else if (rootInfo.rootIValue) {
addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay));
} else {
addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay));
}
addTags(tags, createTag('K', rootInfo.rootKind));
if (rootInfo.rootPubkey && !rootInfo.rootIValue) {
addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay));
}
return tags;
}
/**
* Build parent scope tags for NIP-22 threading
*/
function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] {
const tags: string[][] = [];
if (parentInfo.parentAddress) {
const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e';
addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay));
}
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
return tags;
}
/**
* Build tags for a reply event based on parent and root information
*/
export function buildReplyTags(
parent: NDKEvent,
rootInfo: RootEventInfo,
parentInfo: ParentEventInfo,
kind: number
): string[][] {
const tags: string[][] = [];
const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
// Kind 1 replies use simple e/p tags
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay, 'root'),
createTag('p', parentInfo.parentPubkey)
);
// Add address for replaceable events
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
addTags(tags, createTag('a', parentAddress, '', 'root'));
}
}
} else {
// Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
if (dTag) {
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
if (isReplyToComment) {
// Root scope (uppercase) - use the original article
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)
);
// Parent scope (lowercase) - the comment we're replying to
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
} else {
// Top-level comment - root and parent are the same
addTags(
tags,
createTag('A', parentAddress, parentInfo.parentRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('a', parentAddress, parentInfo.parentRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
}
} else {
// Fallback to E/e tags if no d-tag found
if (isReplyToComment) {
addTags(
tags,
createTag('E', rootInfo.rootId, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
} else {
addTags(
tags,
createTag('E', parent.id, rootInfo.rootRelay),
createTag('K', rootInfo.rootKind),
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
}
}
} else {
// For regular events, use E/e tags
if (isReplyToComment) {
// Reply to a comment - distinguish root from parent
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
addTags(
tags,
createTag('e', parent.id, parentInfo.parentRelay),
createTag('k', parentInfo.parentKind),
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
);
} else {
// Top-level comment or regular event
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
addTags(tags, ...buildParentScopeTags(parent, parentInfo, rootInfo));
}
}
}
return tags;
}
/**
* Create and sign a Nostr event
*/
export async function createSignedEvent(
content: string,
pubkey: string,
kind: number,
tags: string[][]
): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content);
const eventToSign = {
kind: Number(kind),
created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
content: String(prefixedContent),
pubkey: pubkey,
};
let sig, id;
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(eventToSign);
sig = signed.sig as string;
id = 'id' in signed ? signed.id as string : getEventHash(eventToSign);
} else {
id = getEventHash(eventToSign);
sig = await signEvent(eventToSign);
}
return {
id,
sig,
event: {
...eventToSign,
id,
sig,
}
};
}
/**
* Publish event to a single relay
*/
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> {
const ws = new WebSocket(relayUrl);
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, TIMEOUTS.GENERAL);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
}
/**
* Publish event to relays
*/
export async function publishEvent(
signedEvent: any,
useOtherRelays = false,
useFallbackRelays = false,
userRelayPreference = false
): Promise<EventPublishResult> {
// Determine which relays to use
let relays = userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
}
// Try to publish to relays
for (const relayUrl of relays) {
try {
await publishToRelay(relayUrl, signedEvent);
return {
success: true,
relay: relayUrl,
eventId: signedEvent.id
};
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
return {
success: false,
error: "Failed to publish to any relays"
};
}
/**
* Navigate to the published event
*/
export function navigateToEvent(eventId: string): void {
try {
// Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn('Invalid event ID format:', eventId);
return;
}
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
} catch (error) {
console.error('Failed to encode event ID for navigation:', eventId, error);
}
}
// Helper functions to ensure relay and pubkey are always strings
function getRelayString(relay: any): string {
if (!relay) return '';
if (typeof relay === 'string') return relay;
if (typeof relay.url === 'string') return relay.url;
return '';
}
function getPubkeyString(pubkey: any): string {
if (!pubkey) return '';
if (typeof pubkey === 'string') return pubkey;
if (typeof pubkey.hex === 'function') return pubkey.hex();
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey;
return '';
}

397
src/lib/utils/nostrUtils.ts

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

2
src/lib/utils/npubCache.ts

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

328
src/lib/utils/profile_search.ts

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

141
src/lib/utils/relayDiagnostics.ts

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

105
src/lib/utils/searchCache.ts

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

124
src/lib/utils/search_constants.ts

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

69
src/lib/utils/search_types.ts

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

25
src/lib/utils/search_utility.ts

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

104
src/lib/utils/search_utils.ts

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

656
src/lib/utils/subscription_search.ts

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

27
src/routes/+layout.svelte

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

90
src/routes/+layout.ts

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

70
src/routes/+page.svelte

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

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

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

17
src/routes/about/+page.svelte

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

289
src/routes/contact/+page.svelte

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

802
src/routes/events/+page.svelte

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

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

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

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

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

40
src/routes/publication/+error.svelte

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

1
src/routes/publication/+page.svelte

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

46
src/routes/publication/+page.ts

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

28
src/routes/start/+page.svelte

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

20
src/routes/visualize/+page.svelte

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

2
src/styles/publications.css

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

2
src/styles/scrollbar.css

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
}
*::-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 */
}
}

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

Loading…
Cancel
Save