Browse Source

Merges pull request #83

fix: resolve CodeMirror duplicate instance error
master
silberengel 2 months ago
parent
commit
4363883dd2
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 3
      TECHNIQUE-create-test-highlights.md
  2. 2
      check-publication-structure.js
  3. 10
      create-test-comments.js
  4. 10
      create-test-highlights.js
  5. 3806
      deno.lock
  6. 458
      package-lock.json
  7. 1
      package.json
  8. 26
      src/lib/components/CommentViewer.svelte
  9. 2
      src/lib/components/ZettelEditor.svelte
  10. 81
      src/lib/components/cards/BlogHeader.svelte
  11. 67
      src/lib/components/publications/CommentLayer.svelte
  12. 657
      src/lib/components/publications/HighlightLayer.svelte
  13. 148
      src/lib/components/publications/Publication.svelte
  14. 8
      src/lib/components/publications/PublicationFeed.svelte
  15. 134
      src/lib/components/publications/PublicationSection.svelte
  16. 179
      src/lib/components/publications/SectionComments.svelte
  17. 14
      src/lib/components/util/CardActions.svelte
  18. 2
      src/lib/components/util/Profile.svelte
  19. 4
      src/lib/consts.ts
  20. 144
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  21. 1
      src/lib/utils/markup/asciidoctorExtensions.ts
  22. 15
      src/lib/utils/mockCommentData.ts
  23. 8
      src/lib/utils/mockHighlightData.ts
  24. 9
      src/lib/utils/network_detection.ts
  25. 23
      src/lib/utils/nostrUtils.ts
  26. 13
      src/lib/utils/searchCache.ts
  27. 3
      src/lib/utils/search_constants.ts
  28. 21
      src/lib/utils/subscription_search.ts
  29. 5
      src/lib/utils/websocket_utils.ts
  30. 4
      src/routes/about/+page.svelte
  31. 10
      src/routes/contact/+page.svelte
  32. 93
      src/routes/start/+page.svelte
  33. BIN
      static/screenshots/JaneEyre.png
  34. BIN
      static/screenshots/LandingPage.png
  35. BIN
      static/screenshots/ToC_blog.png
  36. BIN
      static/screenshots/ToC_normal.png
  37. BIN
      static/screenshots/Visualization.png
  38. BIN
      static/screenshots/YourRelays.png

3
TECHNIQUE-create-test-highlights.md

@ -47,7 +47,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; @@ -47,7 +47,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references
const relay = "wss://relay.nostr.band";
const relay = "wss://thecitadel.nostr1.com";
async function fetchPublication() {
return new Promise((resolve, reject) => {
@ -172,7 +172,6 @@ const sections = [ @@ -172,7 +172,6 @@ const sections = [
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];

2
check-publication-structure.js

@ -13,7 +13,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; @@ -13,7 +13,7 @@ const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log("\nRoot Address:", rootAddress);
// Fetch the index event to see what sections it references
const relay = "wss://relay.nostr.band";
const relay = "wss://thecitadel.nostr1.com";
async function fetchPublication() {
return new Promise((resolve, reject) => {

10
create-test-comments.js

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws";
import { activeInboxRelays } from "./src/lib/ndk.ts";
import { secondaryRelays } from "./src/lib/consts.ts";
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
@ -25,12 +27,8 @@ const sections = [ @@ -25,12 +27,8 @@ const sections = [
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
];
// Relays to publish to (matching CommentLayer's relay list)
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Relays to publish to - should match src/lib/consts.ts relay constants
const relays = [...secondaryRelays, ...activeInboxRelays];
// Test comments to create
const testComments = [

10
create-test-highlights.js

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import WebSocket from "ws";
import { activeInboxRelays } from "./src/lib/ndk.ts";
import { secondaryRelays } from "./src/lib/consts.ts";
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
@ -25,12 +27,8 @@ const sections = [ @@ -25,12 +27,8 @@ const sections = [
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
];
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.wine",
];
// Relays to publish to - should match src/lib/consts.ts relay constants
const relays = [...secondaryRelays, ...activeInboxRelays];
// Test highlights to create
// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content

3806
deno.lock

File diff suppressed because it is too large Load Diff

458
package-lock.json generated

@ -8,7 +8,6 @@ @@ -8,7 +8,6 @@
"name": "alexandria",
"version": "0.0.2",
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
@ -175,97 +174,6 @@ @@ -175,97 +174,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz",
"integrity": "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/basic-setup": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz",
"integrity": "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==",
"deprecated": "In version 6.0, this package has been renamed to just 'codemirror'",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^0.20.0",
"@codemirror/commands": "^0.20.0",
"@codemirror/language": "^0.20.0",
"@codemirror/lint": "^0.20.0",
"@codemirror/search": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/commands": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-0.20.0.tgz",
"integrity": "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/commands/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
@ -537,93 +445,6 @@ @@ -537,93 +445,6 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/language/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/language/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/lint": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-0.20.3.tgz",
"integrity": "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.2",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lint/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/lint/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/search": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-0.20.1.tgz",
"integrity": "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/search/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
@ -1415,12 +1236,6 @@ @@ -1415,12 +1236,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz",
"integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
@ -1456,15 +1271,6 @@ @@ -1456,15 +1271,6 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
@ -1535,15 +1341,6 @@ @@ -1535,15 +1341,6 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
@ -1665,13 +1462,12 @@ @@ -1665,13 +1462,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
@ -2268,18 +2064,17 @@ @@ -2268,18 +2064,17 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.43.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.1.tgz",
"integrity": "sha512-H8eXW5TSziSvt9d5IJ5pPyWGhXQLdmq+17H9j7aofA/TsfSvG8ZIpTjObphFRNagfIyoFGyoB3lOzdsGHKiKpw==",
"version": "2.49.5",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.5.tgz",
"integrity": "sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.3.2",
"devalue": "^5.6.2",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@ -2298,11 +2093,15 @@ @@ -2298,11 +2093,15 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
@ -3258,35 +3057,6 @@ @@ -3258,35 +3057,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/apexcharts": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.5.tgz",
@ -3450,20 +3220,6 @@ @@ -3450,20 +3220,6 @@
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -3476,20 +3232,6 @@ @@ -3476,20 +3232,6 @@
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -3667,46 +3409,6 @@ @@ -3667,46 +3409,6 @@
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@ -4419,11 +4121,10 @@ @@ -4419,11 +4121,10 @@
}
},
"node_modules/devalue": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"dev": true,
"license": "MIT"
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"dev": true
},
"node_modules/dexie": {
"version": "4.2.0",
@ -4978,20 +4679,6 @@ @@ -4978,20 +4679,6 @@
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -5162,6 +4849,7 @@ @@ -5162,6 +4849,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -5481,20 +5169,6 @@ @@ -5481,20 +5169,6 @@
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -5536,7 +5210,7 @@ @@ -5536,7 +5210,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
@ -5556,7 +5230,7 @@ @@ -5556,7 +5230,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@ -5573,17 +5247,6 @@ @@ -5573,17 +5247,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@ -5666,11 +5329,10 @@ @@ -5666,11 +5329,10 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^2.0.1"
@ -6220,17 +5882,6 @@ @@ -6220,17 +5882,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@ -6525,13 +6176,12 @@ @@ -6525,13 +6176,12 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
@ -6544,11 +6194,10 @@ @@ -6544,11 +6194,10 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
@ -7070,34 +6719,6 @@ @@ -7070,34 +6719,6 @@
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7661,20 +7282,6 @@ @@ -7661,20 +7282,6 @@
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/token-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
@ -7825,11 +7432,10 @@ @@ -7825,11 +7432,10 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

1
package.json

@ -17,7 +17,6 @@ @@ -17,7 +17,6 @@
"tokens": "node src/lib/theme/build-tokens.mjs"
},
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",

26
src/lib/components/CommentViewer.svelte

@ -523,6 +523,32 @@ @@ -523,6 +523,32 @@
}
}
/**
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.log(`[CommentViewer] Refreshing comments for event:`, event?.id);
// Clean up previous subscription
if (activeSub) {
activeSub.stop();
activeSub = null;
}
// Reset state
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
retryCount = 0;
isFetching = false;
// Refetch comments
if (event?.id && !isFetching) {
fetchComments();
}
}
// Cleanup on unmount
onMount(() => {
return () => {

2
src/lib/components/ZettelEditor.svelte

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
import { EditorState, StateField, StateEffect } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
import { basicSetup } from "@codemirror/basic-setup";
import { basicSetup } from "codemirror";
import { RangeSet } from "@codemirror/state";
import { onMount } from "svelte";
import {

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

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from "svelte/transition";
import { Card } from "flowbite-svelte";
import { Card, Button, Popover } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing";
@ -11,17 +11,32 @@ @@ -11,17 +11,32 @@
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion";
import {
EyeOutline,
EyeSlashOutline,
DotsVerticalOutline,
} from "flowbite-svelte-icons";
const {
rootId,
event,
onBlogUpdate,
active = true,
showActionsMenu = false,
commentsVisible = false,
highlightsVisible = false,
onToggleComments,
onToggleHighlights,
} = $props<{
rootId: string;
event: NDKEvent;
onBlogUpdate?: any;
active: boolean;
showActionsMenu?: boolean;
commentsVisible?: boolean;
highlightsVisible?: boolean;
onToggleComments?: () => void;
onToggleHighlights?: () => void;
}>();
const ndk = getNdkContext();
@ -84,6 +99,8 @@ @@ -84,6 +99,8 @@
function showBlog() {
onBlogUpdate?.(rootId);
}
let actionsMenuOpen = $state(false);
</script>
{#if title != null}
@ -117,6 +134,68 @@ @@ -117,6 +134,68 @@
{@render userBadge(authorPubkey, author, ndk)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div>
{#if showActionsMenu}
<div class="flex items-center">
<Button
type="button"
class="btn-leather !p-1 bg-primary-50 dark:bg-gray-800"
outline
onmouseenter={() => (actionsMenuOpen = true)}
>
<DotsVerticalOutline class="w-5 h-5" />
</Button>
{#if actionsMenuOpen}
<Popover
id="popover-blog-actions"
placement="bottom-end"
trigger="click"
class="popover-leather w-fit z-10"
onmouseleave={() => (actionsMenuOpen = false)}
>
<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={() => {
onToggleComments?.();
actionsMenuOpen = false;
}}
>
{#if commentsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Comments
{:else}
<EyeOutline class="inline mr-2" />
Show Comments
{/if}
</button>
</li>
<li>
<button
class="btn-leather w-full text-left"
onclick={() => {
onToggleHighlights?.();
actionsMenuOpen = false;
}}
>
{#if highlightsVisible}
<EyeSlashOutline class="inline mr-2" />
Hide Highlights
{:else}
<EyeOutline class="inline mr-2" />
Show Highlights
{/if}
</button>
</li>
</ul>
</div>
</div>
</Popover>
{/if}
</div>
{/if}
</div>
<div

67
src/lib/components/publications/CommentLayer.svelte

@ -72,19 +72,24 @@ @@ -72,19 +72,24 @@
try {
// Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR
// If we include both #e and #a, relays will only return comments that have BOTH
// NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication)
// Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to)
// IMPORTANT: Use uppercase #A filter to match NIP-22 root scope tags
// If we include both #e and #A, relays will only return comments that have BOTH
const filter: any = {
kinds: [1111],
limit: 500,
};
// Prefer #a (addressable events) since they're more specific and persistent
// NIP-22: Use uppercase #A filter to match root scope (section addresses)
// This will fetch both direct comments and replies (replies also have uppercase A tag)
if (allAddresses.length > 0) {
filter["#a"] = allAddresses;
filter["#A"] = allAddresses;
console.debug(`[CommentLayer] Fetching comments for addresses (NIP-22 #A filter):`, allAddresses);
} else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available
filter["#e"] = allEventIds;
console.debug(`[CommentLayer] Fetching comments for event IDs:`, allEventIds);
}
// Build explicit relay set (same pattern as HighlightLayer)
@ -168,6 +173,16 @@ @@ -168,6 +173,16 @@
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
// AI-NOTE: Debug logging to track comment reception
const aTags = ndkEvent.tags.filter((t: string[]) => t[0] === "a");
console.debug(`[CommentLayer] Received comment event:`, {
id: rawEvent.id?.substring(0, 8),
kind: rawEvent.kind,
aTags: aTags.map((t: string[]) => t[1]),
content: rawEvent.content?.substring(0, 50),
});
comments = [...comments, ndkEvent];
}
} else if (message[0] === "EOSE" && message[1] === subscriptionId) {
@ -202,6 +217,16 @@ @@ -202,6 +217,16 @@
// Wait for all relays to respond or timeout
await Promise.allSettled(fetchPromises);
// AI-NOTE: Debug logging to track comment fetching
console.debug(`[CommentLayer] Fetched ${comments.length} comments for addresses:`, allAddresses);
if (comments.length > 0) {
console.debug(`[CommentLayer] Comment addresses:`, comments.map(c => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = c.tags.find((t: string[]) => t[0] === "A");
return rootATag ? rootATag[1] : "no-A-tag";
}));
}
// Ensure loading is cleared even if checkAllResponses didn't fire
loading = false;
@ -214,25 +239,44 @@ @@ -214,25 +239,44 @@
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
let lastAddressesString = $state("");
// Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => {
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
// AI-NOTE: Debug logging to track effect execution
console.debug(`[CommentLayer] Effect running:`, {
eventIdsCount: eventIds.length,
eventAddressesCount: eventAddresses.length,
hasEventData,
addresses: eventAddresses,
});
// AI-NOTE: Also track the actual addresses string to detect when addresses change
// even if the count stays the same (e.g., when commentsVisible toggles)
const currentAddressesString = JSON.stringify(eventAddresses.sort());
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
// 2. (The count has changed OR the addresses have changed) since last fetch
// 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) {
const addressesChanged = currentAddressesString !== lastAddressesString;
const countChanged = currentCount !== lastFetchedCount;
if (hasEventData && (countChanged || addressesChanged) && !loading) {
// Clear any existing timeout
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
console.debug(`[CommentLayer] Effect triggered: count=${currentCount}, addresses changed=${addressesChanged}, addresses:`, eventAddresses);
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
lastFetchedCount = currentCount;
lastAddressesString = currentAddressesString;
fetchComments();
}, 500);
}
@ -249,11 +293,22 @@ @@ -249,11 +293,22 @@
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.debug(`[CommentLayer] refresh() called, current comments: ${comments.length}`);
// Clear existing comments
comments = [];
// Reset fetch count to force re-fetch
lastFetchedCount = 0;
// Collect current addresses to log what we're fetching
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
console.debug(`[CommentLayer] Refreshing comments for:`, {
eventIds: allEventIds,
addresses: allAddresses,
});
fetchComments();
}
</script>

657
src/lib/components/publications/HighlightLayer.svelte

@ -32,6 +32,9 @@ @@ -32,6 +32,9 @@
eventAddresses = [],
visible = $bindable(false),
useMockHighlights = false,
currentViewAddress,
rootAddress,
publicationType,
}: {
eventId?: string;
eventAddress?: string;
@ -39,6 +42,9 @@ @@ -39,6 +42,9 @@
eventAddresses?: string[];
visible?: boolean;
useMockHighlights?: boolean;
currentViewAddress?: string;
rootAddress?: string;
publicationType?: string;
} = $props();
const ndk = getNdkContext();
@ -52,20 +58,63 @@ @@ -52,20 +58,63 @@
let copyFeedback = $state<string | null>(null);
// Derived state for color mapping
// AI-NOTE: Increased opacity from 0.3 to 0.5 for better visibility
let colorMap = $derived.by(() => {
const map = new Map<string, string>();
highlights.forEach((highlight) => {
if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.5)`);
}
});
return map;
});
// Derived state for grouped highlights
// Filter highlights to only show those for the current view
// AI-NOTE:
// - If viewing a blog index (30040), don't show any highlights - highlights scoped to the blog
// contain text from individual articles (30041) which aren't loaded on the index
// - If viewing a blog entry (30041), show highlights scoped to that entry OR to the parent blog (30040)
// - If viewing a publication index, show all highlights scoped to the publication
let filteredHighlights = $derived.by(() => {
if (!currentViewAddress) {
// No current view specified - viewing publication/blog index
// For blogs, don't show highlights on the index - the text is in the articles, not the index
if (publicationType === "blog") {
return [];
}
// For regular publications, show all highlights
return highlights;
}
// Check if currentViewAddress is a blog entry (30041) or section
const isBlogEntry = currentViewAddress.startsWith('30041:');
// Filter highlights to only those scoped to the current view
// OR to the root publication if viewing a blog entry
return highlights.filter((highlight) => {
const aTag = highlight.tags.find((tag) => tag[0] === "a");
if (!aTag) return false;
const highlightAddress = aTag[1];
// Match if highlight is scoped to current view
if (highlightAddress === currentViewAddress) {
return true;
}
// If viewing a blog entry (30041), also show highlights scoped to the parent publication (30040)
if (isBlogEntry && rootAddress && highlightAddress === rootAddress) {
return true;
}
return false;
});
});
// Derived state for grouped highlights (using filtered highlights)
let groupedHighlights = $derived.by(() => {
return groupHighlightsByAuthor(highlights);
return groupHighlightsByAuthor(filteredHighlights);
});
/**
@ -281,36 +330,314 @@ @@ -281,36 +330,314 @@
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.debug(`[HighlightLayer] Found section element for ${targetAddress}, using it as search root`);
} else {
console.warn(`[HighlightLayer] Section element not found for ${targetAddress}, falling back to containerRef`);
}
}
return highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (!result) {
console.warn(`[HighlightLayer] highlightByOffset returned false for offsets ${offsetStart}-${offsetEnd} in ${targetAddress || 'container'}`);
}
return result;
}
// Track retry attempts for text highlighting
const textHighlightRetries = new Map<string, number>();
const MAX_TEXT_HIGHLIGHT_RETRIES = 10;
const TEXT_HIGHLIGHT_RETRY_DELAYS = [100, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000]; // ms
/**
* Find text in the DOM and highlight it (fallback method)
* @param text - The text to highlight
* @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section
* @param retryCount - Internal parameter for retry attempts
*/
function findAndHighlightText(
text: string,
color: string,
targetAddress?: string,
retryCount: number = 0,
): void {
if (!containerRef || !text || text.trim().length === 0) {
console.log(`[HighlightLayer] findAndHighlightText called: text="${text}", color="${color}", targetAddress="${targetAddress}", retryCount=${retryCount}`);
if (!text || text.trim().length === 0) {
console.warn(`[HighlightLayer] Empty text provided, returning`);
return;
}
// If we have a target address, search only in that section
let searchRoot: HTMLElement | Document = containerRef;
// AI-NOTE: When viewing a section directly (leaf), the section element might be outside containerRef
// So we search the entire document for the section element
let searchRoot: HTMLElement | Document | null = null;
let sectionElement: HTMLElement | null = null;
if (targetAddress) {
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
// Check if this is a publication address (30040) or section address (30041)
const isPublicationAddress = targetAddress.startsWith('30040:');
if (isPublicationAddress) {
// For publication-scoped highlights, search in all sections of that publication
// Find all section elements that belong to this publication
const allSections = document.querySelectorAll('section[id^="30041:"], section[id^="30040:"]');
const matchingSections: HTMLElement[] = [];
// Extract publication identifier from target address (pubkey:d-tag)
const pubParts = targetAddress.split(':');
if (pubParts.length >= 3) {
const pubKey = pubParts[1];
const pubDtag = pubParts[2];
// Find sections that belong to this publication
for (const section of allSections) {
const sectionId = section.id;
const sectionParts = sectionId.split(':');
if (sectionParts.length >= 3 && sectionParts[1] === pubKey) {
// This section belongs to the same publication
matchingSections.push(section as HTMLElement);
}
}
}
if (matchingSections.length > 0) {
// Create a container to search across all matching sections
// We'll search each section individually
console.debug(`[HighlightLayer] Found ${matchingSections.length} sections for publication ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
// Search in each matching section
for (const section of matchingSections) {
const contentSection = section.querySelector('section.publication-leather');
const searchTarget = (contentSection || section.querySelector('.section-content') || section) as HTMLElement;
if (searchTarget) {
// Use TreeWalker to find text in this section
const walker = document.createTreeWalker(
searchTarget,
NodeFilter.SHOW_TEXT,
null,
);
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Search for the text in this section's text nodes
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
if (!nodeText || nodeText.trim().length === 0) continue;
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
if (normalizedNodeText.includes(normalizedSearchText)) {
// Found match - highlight it
let index = normalizedNodeText.indexOf(normalizedSearchText);
if (index !== -1) {
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
if (actualIndex === -1) {
// Try flexible whitespace matching
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
const actualMatchText = regexMatch[0];
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const before = nodeText.substring(0, actualIndex);
const after = nodeText.substring(actualIndex + actualMatchText.length);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = actualMatchText;
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
} else {
// Found exact match
const parent = textNode.parentNode;
if (!parent) continue;
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
continue;
}
const matchLength = text.length;
const before = nodeText.substring(0, actualIndex);
const match = nodeText.substring(actualIndex, actualIndex + matchLength);
const after = nodeText.substring(actualIndex + matchLength);
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = match;
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Found and highlighted, done
}
}
}
}
}
}
// If we get here, we searched all sections but didn't find the text
// This might mean the content isn't loaded yet, so we'll retry
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text not found in publication sections yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find it after all retries
// Check if highlight was actually rendered before warning
const queryRoot = containerRef || document;
const existingHighlights = queryRoot.querySelectorAll("mark.highlight");
const highlightFound = Array.from(existingHighlights).some(mark =>
mark.textContent?.toLowerCase().includes(text.toLowerCase())
);
if (!highlightFound) {
console.debug(`[HighlightLayer] Text "${text}" not found in publication sections after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (content may not be loaded yet)`);
}
return;
}
} else {
// No sections found for this publication - might not be loaded yet
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find sections after all retries
console.debug(`[HighlightLayer] No sections found for publication ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (sections may not be loaded yet)`);
return;
}
}
} else {
// Section-scoped highlight - search in specific section element
sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
// AI-NOTE: The actual content is in a nested <section class="whitespace-normal publication-leather">
// created by contentParagraph snippet. Look for that nested section first.
const contentSection = sectionElement.querySelector('section.publication-leather');
if (contentSection) {
searchRoot = contentSection as HTMLElement;
console.debug(`[HighlightLayer] Found nested content section for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Fallback to .section-content div if nested section not found
const contentDiv = sectionElement.querySelector(".section-content");
if (contentDiv) {
searchRoot = contentDiv as HTMLElement;
console.debug(`[HighlightLayer] Found section-content div for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
} else {
// Last resort: search entire section
searchRoot = sectionElement;
console.debug(`[HighlightLayer] Found section element for ${targetAddress} (no nested content found), searching for text: "${text.substring(0, 50)}"`);
}
}
} else {
// Section not found - might not be loaded yet, retry if we haven't exceeded max retries
const retryKey = `${targetAddress}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section element not found for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
} else {
// Only warn if we truly couldn't find the section after all retries
console.debug(`[HighlightLayer] Section element not found for ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (section may not be loaded yet)`);
return;
}
}
}
}
// If no target address, use containerRef if available, otherwise document
if (!targetAddress) {
if (containerRef) {
searchRoot = containerRef;
console.debug(`[HighlightLayer] No target address, searching in containerRef for text: "${text.substring(0, 50)}"`);
} else {
searchRoot = document;
console.debug(`[HighlightLayer] No target address and no containerRef, searching in document for text: "${text.substring(0, 50)}"`);
}
}
if (!searchRoot) {
return;
}
// Use TreeWalker to find all text nodes
const walker = document.createTreeWalker(
searchRoot,
@ -318,39 +645,216 @@ @@ -318,39 +645,216 @@
null,
);
// AI-NOTE: First, check if the text exists in the full content
// This helps us know if we should continue searching
// Normalize whitespace for matching - highlights may have different whitespace than DOM
const fullText = searchRoot instanceof HTMLElement
? searchRoot.textContent || searchRoot.innerText || ""
: "";
const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
const normalizedFullText = fullText.replace(/\s+/g, ' ').toLowerCase();
// AI-NOTE: If content is empty or very short, the section might still be loading
// Check if we have meaningful content (more than just whitespace/formatting)
const hasContent = fullText.trim().length > 5; // At least 5 characters of actual content
if (!hasContent && sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
// Content not loaded yet, retry
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Section content not loaded yet for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
if (!normalizedFullText.includes(normalizedSearchText)) {
// Text not found - retry if section exists and we haven't exceeded max retries
if (sectionElement && retryCount < MAX_TEXT_HIGHLIGHT_RETRIES) {
const retryKey = `${targetAddress || 'no-address'}:${text}`;
const currentRetries = textHighlightRetries.get(retryKey) || 0;
if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
textHighlightRetries.set(retryKey, currentRetries + 1);
const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
console.debug(`[HighlightLayer] Text "${text}" not found in content yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES}). Full text (first 200 chars): "${fullText.substring(0, 200)}"`);
setTimeout(() => {
findAndHighlightText(text, color, targetAddress, retryCount + 1);
}, delay);
return;
}
}
// Only warn if we truly couldn't find the text after checking
// This might be a false negative if content is still loading
console.debug(
`[HighlightLayer] Text "${text}" not found in full content. Full text (first 200 chars): "${fullText.substring(0, 200)}"`,
);
return;
}
console.debug(
`[HighlightLayer] Text "${text}" found in full content. Searching text nodes...`,
);
// Collect all text nodes
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
console.debug(`[HighlightLayer] Found ${textNodes.length} text nodes to search`);
// Log first few text nodes for debugging
const sampleNodes = textNodes.slice(0, 20).filter(n => n.textContent && n.textContent.trim().length > 0);
console.log(`[HighlightLayer] Sample text nodes (first 20 non-empty):`, sampleNodes.map(n => `"${n.textContent?.substring(0, 50)}${(n.textContent?.length || 0) > 50 ? '...' : ''}"`));
// Search for the highlight text in text nodes
for (const textNode of textNodes) {
// normalizedSearchText is already defined above with whitespace normalization
for (let i = 0; i < textNodes.length; i++) {
const textNode = textNodes[i];
const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (!nodeText || nodeText.trim().length === 0) {
continue; // Skip empty text nodes
}
// Normalize node text for comparison (collapse whitespace)
const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
// Log every text node that contains the search text (for debugging)
if (normalizedNodeText.includes(normalizedSearchText)) {
console.log(`[HighlightLayer] Text node ${i} contains search text: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
}
// Try normalized match first (case-insensitive, whitespace-normalized)
let index = normalizedNodeText.indexOf(normalizedSearchText);
// If normalized match found, find the actual position in the original text
// by searching for the normalized pattern in the original text
if (index !== -1) {
// Find the actual start position in the original text
// We need to account for whitespace differences
const searchPattern = text.trim();
let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
// If that doesn't work, try finding by character position accounting for whitespace
if (actualIndex === -1) {
// Build a regex that matches the text with flexible whitespace
const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
const regex = new RegExp(flexiblePattern, 'i');
const regexMatch = nodeText.match(regex);
if (regexMatch && regexMatch.index !== undefined) {
actualIndex = regexMatch.index;
// Update the search text to the actual matched text for highlighting
const actualMatchText = regexMatch[0];
// Use the actual matched text length for highlighting
const before = nodeText.substring(0, actualIndex);
const matchedText = actualMatchText;
const after = nodeText.substring(actualIndex + actualMatchText.length);
const parent = textNode.parentNode;
if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue;
}
console.debug(`[HighlightLayer] Found match at index ${actualIndex}: "${matchedText}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit";
highlightSpan.style.fontWeight = "inherit";
highlightSpan.textContent = matchedText;
// Replace the text node with the highlighted version
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.debug(
`[HighlightLayer] Successfully highlighted text "${matchedText}" at index ${actualIndex} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights
}
} else {
// Found exact match (case-insensitive) - use it directly
index = actualIndex;
}
}
// Fallback: try exact match (case-sensitive) if normalized match failed
if (index === -1) {
index = nodeText.indexOf(text);
}
// Fallback: try case-insensitive exact match
if (index === -1) {
const lowerNodeText = nodeText.toLowerCase();
const lowerSearchText = text.toLowerCase();
index = lowerNodeText.indexOf(lowerSearchText);
}
if (index !== -1) {
const parent = textNode.parentNode;
if (!parent) continue;
if (!parent) {
console.warn(`[HighlightLayer] Text node has no parent, skipping`);
continue;
}
// Skip if already highlighted
if (
parent.nodeName === "MARK" ||
(parent instanceof Element && parent.classList?.contains("highlight"))
) {
console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
continue;
}
// Find the actual match - use the original text length
const matchLength = text.length;
const before = nodeText.substring(0, index);
const match = nodeText.substring(index, index + text.length);
const after = nodeText.substring(index + text.length);
const match = nodeText.substring(index, index + matchLength);
const after = nodeText.substring(index + matchLength);
console.debug(`[HighlightLayer] Found match at index ${index}: "${match}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
// Create highlight span
// Create highlight span with visible styling
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.style.color = "inherit"; // Ensure text color is visible
highlightSpan.style.fontWeight = "inherit"; // Preserve font weight
highlightSpan.textContent = match;
// Replace the text node with the highlighted version
@ -361,35 +865,92 @@ @@ -361,35 +865,92 @@
parent.replaceChild(fragment, textNode);
console.debug(
`[HighlightLayer] Successfully highlighted text "${match}" at index ${index} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
);
// Clear retry count on success
if (targetAddress) {
const retryKey = `${targetAddress}:${text}`;
textHighlightRetries.delete(retryKey);
}
return; // Only highlight first occurrence to avoid multiple highlights
}
}
// AI-NOTE: If no match found, log for debugging
console.warn(
`[HighlightLayer] Could not find highlight text "${text}" in section. Searched ${textNodes.length} text nodes.`,
);
if (textNodes.length > 0) {
// Log more text nodes and their full content to help debug
const sampleNodes = textNodes.slice(0, 10);
console.debug(
`[HighlightLayer] Sample text nodes (first 10):`,
sampleNodes.map((n, i) => ({
index: i,
text: n.textContent?.substring(0, 100) || "",
fullLength: n.textContent?.length || 0,
parentTag: n.parentElement?.tagName || "unknown",
})),
);
// Also log the full text content of the search root to see what's actually there
if (searchRoot instanceof HTMLElement) {
const fullText = searchRoot.textContent || "";
console.debug(
`[HighlightLayer] Full text content of search root (first 500 chars): "${fullText.substring(0, 500)}"`,
);
console.debug(
`[HighlightLayer] Full text contains "${text}": ${fullText.toLowerCase().includes(text.toLowerCase())}`,
);
}
}
}
/**
* Render all highlights on the page
*/
function renderHighlights() {
if (!visible || !containerRef) {
if (!visible) {
return;
}
if (highlights.length === 0) {
if (filteredHighlights.length === 0) {
return;
}
// Clear existing highlights
clearHighlights();
// AI-NOTE: When viewing a section directly (leaf), containerRef might not be set
// But we can still highlight by searching the document for section elements
// Only clear highlights if containerRef exists, otherwise clear from document
if (containerRef) {
clearHighlights();
} else {
// Clear highlights from entire document
const highlightElements = document.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
const textNode = document.createTextNode(el.textContent || "");
parent.replaceChild(textNode, el);
parent.normalize();
}
});
}
// Apply each highlight
for (const highlight of highlights) {
// Apply each highlight (only filtered highlights for current view)
for (const highlight of filteredHighlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.5)";
console.log(`[HighlightLayer] Processing highlight: content="${content}", color="${color}"`);
// Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find((tag) => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined;
console.log(`[HighlightLayer] Highlight targetAddress: "${targetAddress}"`);
// Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find((tag) => tag[0] === "offset");
const hasOffset =
@ -412,19 +973,32 @@ @@ -412,19 +973,32 @@
}
// Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight");
const renderedHighlights = containerRef
? containerRef.querySelectorAll("mark.highlight")
: document.querySelectorAll("mark.highlight");
console.log(
`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`,
);
// AI-NOTE: Debug logging to help diagnose highlight visibility issues
if (renderedHighlights.length === 0 && filteredHighlights.length > 0) {
console.warn(`[HighlightLayer] No highlights rendered despite ${filteredHighlights.length} filtered highlights available. Container:`, containerRef, "Visible:", visible, "CurrentView:", currentViewAddress);
// Log highlight details for debugging
filteredHighlights.forEach((h, i) => {
const aTag = h.tags.find((tag) => tag[0] === "a");
const offsetTag = h.tags.find((tag) => tag[0] === "offset");
console.debug(`[HighlightLayer] Highlight ${i}: content="${h.content.substring(0, 50)}", targetAddress="${aTag?.[1]}", hasOffset=${!!offsetTag}`);
});
}
}
/**
* Clear all highlights from the page
* AI-NOTE: If containerRef is not set (e.g., blog entries), clear from document
*/
function clearHighlights() {
if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight");
const queryRoot = containerRef || document;
const highlightElements = queryRoot.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
@ -473,12 +1047,33 @@ @@ -473,12 +1047,33 @@
});
// Watch for visibility AND highlights changes - render when both are ready
// AI-NOTE: Also watch containerRef to ensure it's set before rendering
// AI-NOTE: For blog entries viewed directly, containerRef might not be set, so we render without it
$effect(() => {
// This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length;
// This effect runs when either visible, highlights.length, or containerRef changes
const highlightCount = filteredHighlights.length;
if (visible && highlightCount > 0) {
renderHighlights();
// AI-NOTE: Retry rendering with increasing delays to handle async content loading
// This is especially important when viewing sections directly
let retryCount = 0;
const maxRetries = 5;
const retryDelays = [100, 300, 500, 1000, 2000];
const tryRender = () => {
renderHighlights();
const renderedCount = containerRef?.querySelectorAll("mark.highlight").length || 0;
if (renderedCount === 0 && retryCount < maxRetries) {
console.debug(`[HighlightLayer] No highlights rendered, retrying (attempt ${retryCount + 1}/${maxRetries})...`);
setTimeout(tryRender, retryDelays[retryCount]);
retryCount++;
} else if (renderedCount > 0) {
console.debug(`[HighlightLayer] Successfully rendered ${renderedCount} highlights after ${retryCount} retries`);
}
};
tryRender();
} else if (!visible) {
clearHighlights();
}
@ -631,7 +1226,7 @@ @@ -631,7 +1226,7 @@
</div>
{/if}
{#if visible && highlights.length > 0}
{#if visible && filteredHighlights.length > 0}
<div
class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"
>
@ -643,7 +1238,7 @@ @@ -643,7 +1238,7 @@
{@const isExpanded = expandedAuthors.has(pubkey)}
{@const profile = authorProfiles.get(pubkey)}
{@const displayName = getAuthorDisplayName(profile, pubkey)}
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"}
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.5)"}
{@const sortedHighlights = sortHighlightsByTime(authorHighlights)}
<div class="border-b border-gray-200 dark:border-gray-700 pb-2">

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

@ -58,13 +58,21 @@ @@ -58,13 +58,21 @@
const ndk = getNdkContext();
// AI-NOTE: Default visibility logic:
// - Blogs: comments and highlights ON by default
// - Articles/sections: comments and highlights ON by default
// - Publication indexes (kind 30040): comments and highlights OFF by default (for undisturbed reading)
const isPublicationIndex = publicationType === "publication" && indexEvent.kind === 30040;
const defaultCommentsVisible = !isPublicationIndex;
const defaultHighlightsVisible = !isPublicationIndex;
// Highlight layer state
let highlightsVisible = $state(false);
let highlightsVisible = $state(defaultHighlightsVisible);
let highlightLayerRef: any = null;
let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state
let commentsVisible = $state(false);
let commentsVisible = $state(defaultCommentsVisible);
let comments = $state<NDKEvent[]>([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
@ -108,14 +116,27 @@ @@ -108,14 +116,27 @@
});
// Filter comments for the root publication (kind 30040)
// AI-NOTE: NIP-22: Uppercase A tag points to root scope (publication/section)
// Use uppercase A tag to match comments scoped to the root publication
let articleComments = $derived(
comments.filter((comment) => {
// Check if comment targets the root publication via #a tag
const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === rootAddress;
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = comment.tags.find((t) => t[0] === "A");
return rootATag && rootATag[1] === rootAddress;
}),
);
// Filter comments for the current blog entry
// AI-NOTE: NIP-22: Uppercase A tag points to root scope (blog entry address)
let blogComments = $derived.by(() => {
if (!currentBlog) return [];
return comments.filter((comment) => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = comment.tags.find((t) => t[0] === "A");
return rootATag && rootATag[1] === currentBlog;
});
});
// #region Loading
let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state(false);
@ -650,6 +671,14 @@ @@ -650,6 +671,14 @@
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
// AI-NOTE: Determine current view address for filtering highlights
// - If viewing a blog entry, use the blog address
// - If viewing a section directly (leaf), use the root address
// - Otherwise (publication index), undefined (show all highlights)
const currentViewAddress = $derived(
currentBlog || (isLeaf ? rootAddress : undefined)
);
function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner;
@ -699,15 +728,29 @@ @@ -699,15 +728,29 @@
function toggleComments() {
commentsVisible = !commentsVisible;
// AI-NOTE: When toggling comments on, ensure CommentLayer fetches comments
// The effect in CommentLayer should handle this, but we can also trigger a refresh
if (commentsVisible && commentLayerRef) {
console.debug("[Publication] Comments toggled on, triggering refresh");
// Small delay to ensure addresses are available
setTimeout(() => {
if (commentLayerRef && commentsVisible) {
commentLayerRef.refresh();
}
}, 100);
}
}
function handleCommentPosted() {
// Refresh the comment layer after a short delay to allow relay indexing
// AI-NOTE: Refresh the comment layer after a delay to allow relay indexing
// Increased delay to 3 seconds to give relays more time to index the new comment
setTimeout(() => {
if (commentLayerRef) {
console.debug("[Publication] Refreshing CommentLayer after comment posted");
commentLayerRef.refresh();
}
}, 500);
}, 3000);
}
async function submitArticleComment() {
@ -1301,25 +1344,16 @@ @@ -1301,25 +1344,16 @@
{/if}
</div>
<!-- Mobile article comments - shown below header on smaller screens -->
<div class="xl:hidden mt-4 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- Desktop article comments - positioned on right side on XL+ screens -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- Article comments - shown below header only when viewing full publication (not a section directly) -->
{#if !currentBlog && !isLeaf}
<div class="mt-4 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
{/if}
</div>
@ -1338,6 +1372,7 @@ @@ -1338,6 +1372,7 @@
placeholder="Write your comment on this article..."
rows={4}
disabled={isSubmittingArticleComment}
class="w-full"
/>
{#if articleCommentError}
@ -1407,6 +1442,7 @@ @@ -1407,6 +1442,7 @@
{commentsVisible}
publicationTitle={publicationTitle}
{isFirstSection}
onCommentPosted={handleCommentPosted}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
@ -1476,6 +1512,7 @@ @@ -1476,6 +1512,7 @@
{toc}
allComments={comments}
{commentsVisible}
onCommentPosted={handleCommentPosted}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{:else}
@ -1530,22 +1567,47 @@ @@ -1530,22 +1567,47 @@
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
showActionsMenu={true}
commentsVisible={commentsVisible}
highlightsVisible={highlightsVisible}
onToggleComments={toggleComments}
onToggleHighlights={toggleHighlights}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
</div>
<!-- Article comments in discussion sidebar - show for full publication or blog entry -->
{#if (!currentBlog && !isLeaf) || (currentBlog && currentBlogEvent)}
<div class="flex flex-col w-full space-y-4">
{#if currentBlog && currentBlogEvent}
<!-- Blog entry comments -->
<SectionComments
sectionAddress={currentBlog}
comments={blogComments}
visible={commentsVisible}
/>
{#if blogComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
{:else}
<!-- Publication article comments -->
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p
class="text-sm text-gray-500 dark:text-gray-400 text-center py-4"
>
No comments yet. Be the first to comment!
</p>
{/if}
{/if}
</div>
{/if}
</div>
</SidebarGroup>
</SidebarWrapper>
@ -1608,12 +1670,16 @@ @@ -1608,12 +1670,16 @@
{/if}
<!-- Highlight Layer Component -->
<!-- AI-NOTE: Pass currentViewAddress, rootAddress, and publicationType to filter highlights to current view -->
<HighlightLayer
bind:this={highlightLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
{useMockHighlights}
currentViewAddress={currentViewAddress}
rootAddress={rootAddress}
publicationType={publicationType}
/>
<!-- Comment Layer Component -->
@ -1630,5 +1696,7 @@ @@ -1630,5 +1696,7 @@
<CardActions
event={indexEvent}
bind:detailsModalOpen={detailsModalOpen}
sectionAddress={rootAddress}
onCommentPosted={handleCommentPosted}
/>
</div>

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
<script lang="ts">
import { indexKind } from "$lib/consts";
import { SEARCH_LIMITS } from "$lib/utils/search_constants.ts";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
@ -241,7 +242,7 @@ @@ -241,7 +242,7 @@
ws.send(JSON.stringify([
"REQ",
subId,
{ kinds: [indexKind], limit: 1000 }
{ kinds: [indexKind], limit: SEARCH_LIMITS.PUBLICATION_FEED_LIMIT }
]));
// Set up cleanup
@ -277,6 +278,11 @@ @@ -277,6 +278,11 @@
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// AI-NOTE: Clear publication search cache when new events are loaded to prevent stale results
// This ensures searches will re-run with the updated event set
searchCache.clearType("publication");
console.debug(`[PublicationFeed] Cleared publication search cache after loading ${newEvents.length} new events`);
// Update the view immediately with new events
eventsInView = allIndexEvents.slice(0, publicationsToDisplay);
endOfFeed = allIndexEvents.length <= publicationsToDisplay;

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

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
commentsVisible = true,
publicationTitle,
isFirstSection = false,
onCommentPosted,
}: {
address: string;
rootAddress: string;
@ -39,19 +40,97 @@ @@ -39,19 +40,97 @@
commentsVisible?: boolean;
publicationTitle?: string;
isFirstSection?: boolean;
onCommentPosted?: () => void;
} = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk");
// Filter comments for this section
let sectionComments = $derived(
allComments.filter((comment) => {
// Check if comment targets this section via #a tag
const aTag = comment.tags.find((t) => t[0] === "a");
return aTag && aTag[1] === address;
}),
);
// AI-NOTE: NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication)
// Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to)
// All comments scoped to this section will have uppercase A tag matching section address
let sectionComments = $derived.by(() => {
// Step 1: Find all comments scoped to this section (have uppercase A tag matching section address)
const directComments = allComments.filter((comment) => {
// NIP-22: Look for uppercase A tag (root scope)
const rootATag = comment.tags.find((t) => t[0] === "A");
const matches = rootATag && rootATag[1] === address;
// AI-NOTE: Debug logging to help diagnose comment filtering issues
if (rootATag) {
console.debug("[PublicationSection] Comment filtering:", {
sectionAddress: address,
commentRootATag: rootATag[1],
matches,
commentId: comment.id?.substring(0, 8),
});
}
return matches;
});
// Step 2: Build a set of comment IDs that match this section (for efficient lookup)
const matchingCommentIds = new Set(
directComments.map(c => c.id?.toLowerCase()).filter(Boolean)
);
// Step 3: Recursively find all replies to matching comments
// NIP-22: Replies have lowercase e tag pointing to parent comment ID
// They also have uppercase A tag matching section address (same root scope)
const allMatchingComments = new Set<NDKEvent>(directComments);
let foundNewReplies = true;
// Keep iterating until we find no new replies (handles nested replies)
while (foundNewReplies) {
foundNewReplies = false;
for (const comment of allComments) {
// Skip if already included
if (allMatchingComments.has(comment)) {
continue;
}
// NIP-22: Check if this comment is scoped to this section (uppercase A tag)
const rootATag = comment.tags.find((t) => t[0] === "A");
if (!rootATag || rootATag[1] !== address) {
// Not scoped to this section, skip
continue;
}
// NIP-22: Check if this is a reply (has lowercase e tag pointing to a matching comment)
const lowercaseETags = comment.tags.filter(t => t[0] === "e");
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.toLowerCase();
if (parentId && matchingCommentIds.has(parentId)) {
// This is a reply to a matching comment - include it
allMatchingComments.add(comment);
matchingCommentIds.add(comment.id?.toLowerCase() || "");
foundNewReplies = true;
console.debug(`[PublicationSection] Found reply ${comment.id?.substring(0, 8)} to matching comment ${parentId.substring(0, 8)} (NIP-22)`);
break; // Found a match, no need to check other e tags
}
}
}
}
const filtered = Array.from(allMatchingComments);
console.debug(`[PublicationSection] Filtered ${filtered.length} comments (${directComments.length} direct, ${filtered.length - directComments.length} replies) for section ${address} from ${allComments.length} total comments`);
// AI-NOTE: Debug logging to check for nested replies in filtered comments
const filteredCommentIds = new Set(filtered.map(c => c.id?.toLowerCase()).filter(Boolean));
for (const comment of filtered) {
const lowercaseETags = comment.tags.filter(t => t[0] === "e");
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.toLowerCase();
if (parentId && filteredCommentIds.has(parentId)) {
console.debug(`[PublicationSection] Found nested reply ${comment.id?.substring(0, 8)} to filtered comment ${parentId.substring(0, 8)}`);
}
}
}
return filtered;
});
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
@ -224,10 +303,32 @@ @@ -224,10 +303,32 @@
ref(sectionRef);
});
// Initialize ABC notation blocks after content is rendered
$effect(() => {
if (typeof window === "undefined") return;
// Watch for content changes
leafContent.then(() => {
// Wait for content to be rendered in DOM
const initABC = () => {
if (typeof (window as any).initializeABCBlocks === "function") {
(window as any).initializeABCBlocks();
} else {
// If function not available yet, wait a bit and try again
setTimeout(initABC, 100);
}
};
// Initialize after a short delay to ensure DOM is ready
setTimeout(initABC, 200);
});
});
</script>
<!-- Wrapper for positioning context -->
<div class="relative w-full overflow-x-hidden">
<!-- AI-NOTE: Removed overflow-x-hidden to allow comments panel to be visible when positioned absolutely -->
<div class="relative w-full">
<section
id={address}
bind:this={sectionRef}
@ -257,6 +358,7 @@ @@ -257,6 +358,7 @@
{event}
sectionAddress={address}
onDelete={handleDelete}
onCommentPosted={onCommentPosted}
/>
{/if}
{/await}
@ -271,8 +373,8 @@ @@ -271,8 +373,8 @@
)}
</div>
<!-- Mobile comments - shown below content on smaller screens -->
<div class="xl:hidden mt-8 w-full text-left">
<!-- Comments - shown below content on all screens -->
<div class="mt-8 w-full text-left">
<SectionComments
sectionAddress={address}
comments={sectionComments}
@ -281,18 +383,6 @@ @@ -281,18 +383,6 @@
</div>
{/await}
</section>
<!-- Comments area: positioned below menu, top-center of section -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
>
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
</div>
<style>

179
src/lib/components/publications/SectionComments.svelte

@ -36,42 +36,143 @@ @@ -36,42 +36,143 @@
// Subscribe to userStore
let user = $derived($userStore);
// AI-NOTE: Debug logging to track component rendering and comment reception
$effect(() => {
console.debug(`[SectionComments] Component rendered/re-rendered:`, {
sectionAddress,
commentsCount: comments.length,
visible,
commentIds: comments.map(c => c.id?.substring(0, 8)),
});
});
/**
* Parse comment threading structure
* Root comments have no 'e' tag with 'reply' marker
* Parse comment threading structure according to NIP-22
* NIP-22: Uppercase tags (A, E, I, K, P) = root scope
* Lowercase tags (a, e, i, k, p) = parent item (comment being replied to)
* Root comments have no lowercase e tag (or lowercase e tag pointing to section itself)
* Replies have lowercase e tag pointing to parent comment ID
*/
function buildThreadStructure(allComments: NDKEvent[]) {
const rootComments: NDKEvent[] = [];
const repliesByParent = new Map<string, NDKEvent[]>();
// AI-NOTE: Normalize comment IDs to lowercase for consistent matching
const allCommentIds = new Set(allComments.map(c => c.id?.toLowerCase()).filter(Boolean));
// AI-NOTE: Debug logging to track comment threading
console.debug(`[SectionComments] Building thread structure from ${allComments.length} comments (NIP-22)`);
// NIP-22: First pass - identify replies by looking for lowercase e tags
// Lowercase e tags point to the parent comment ID
// This works for both direct replies and nested replies (replies to replies)
for (const comment of allComments) {
// Check if this is a reply by looking for 'e' tags with 'reply' marker
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply');
const commentId = comment.id?.toLowerCase();
if (!commentId) {
console.warn(`[SectionComments] Comment missing ID, skipping`);
continue;
}
if (replyTag) {
const parentId = replyTag[1];
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
// NIP-22: Look for lowercase e tag (parent item reference)
const lowercaseETags = comment.tags.filter(t => t[0] === 'e');
if (lowercaseETags.length > 0) {
// Check if any lowercase e tag points to a comment in our set
let isReply = false;
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.trim().toLowerCase();
if (!parentId) {
continue;
}
// NIP-22: If lowercase e tag points to a comment in our set, it's a reply
// This works for both direct replies (parent is root comment) and nested replies (parent is another reply)
if (allCommentIds.has(parentId)) {
isReply = true;
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
}
repliesByParent.get(parentId)!.push(comment);
// Check if this is a nested reply (reply to a reply)
const isNestedReply = !rootComments.some(rc => rc.id?.toLowerCase() === parentId);
const replyType = isNestedReply ? "nested reply (reply to reply)" : "direct reply";
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a ${replyType} to ${parentId.substring(0, 8)} (NIP-22 lowercase e tag)`);
break; // Found parent, no need to check other e tags
}
}
if (!isReply) {
// Has lowercase e tag but doesn't reference any comment in our set
// This might be a root comment that references an external event, or malformed
rootComments.push(comment);
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a root comment (lowercase e tag references external event)`);
}
repliesByParent.get(parentId)!.push(comment);
} else {
// This is a root comment (no reply tag)
// No lowercase e tags - this is a root comment
rootComments.push(comment);
console.debug(`[SectionComments] Comment ${commentId.substring(0, 8)} is a root comment (no lowercase e tags)`);
}
}
console.debug(`[SectionComments] Thread structure: ${rootComments.length} root comments, ${repliesByParent.size} reply groups`);
// AI-NOTE: Log reply details for debugging
for (const [parentId, replies] of repliesByParent.entries()) {
console.debug(`[SectionComments] Parent ${parentId.substring(0, 8)} has ${replies.length} replies:`, replies.map(r => r.id?.substring(0, 8)));
// Check if any of these replies themselves have replies (nested replies)
for (const reply of replies) {
const replyId = reply.id?.toLowerCase();
if (replyId && repliesByParent.has(replyId)) {
const nestedReplies = repliesByParent.get(replyId)!;
console.debug(`[SectionComments] → Reply ${replyId.substring(0, 8)} has ${nestedReplies.length} nested replies (replies to replies)`);
}
}
}
return { rootComments, repliesByParent };
}
let threadStructure = $derived(buildThreadStructure(comments));
let threadStructure = $derived.by(() => {
const structure = buildThreadStructure(comments);
// AI-NOTE: Debug logging when structure changes
if (structure.rootComments.length > 0 || comments.length > 0) {
console.debug(`[SectionComments] Thread structure updated:`, {
totalComments: comments.length,
rootComments: structure.rootComments.length,
replyGroups: structure.repliesByParent.size,
visible,
});
// AI-NOTE: Log all parent IDs in the map to help debug nested reply lookup
const allParentIds = Array.from(structure.repliesByParent.keys());
console.debug(`[SectionComments] All parent IDs in repliesByParent map:`, allParentIds.map(id => id.substring(0, 8)));
// Log which replies have nested replies
for (const [parentId, replies] of structure.repliesByParent.entries()) {
for (const reply of replies) {
const replyId = reply.id?.toLowerCase();
if (replyId && structure.repliesByParent.has(replyId)) {
const nested = structure.repliesByParent.get(replyId)!;
console.debug(`[SectionComments] Reply ${replyId.substring(0, 8)} has ${nested.length} nested replies`);
}
}
}
}
return structure;
});
/**
* Count replies for a comment thread
*/
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number {
const directReplies = repliesMap.get(commentId) || [];
// AI-NOTE: Normalize comment ID for lookup (must match how we stored it)
const normalizedCommentId = commentId?.trim().toLowerCase();
const directReplies = repliesMap.get(normalizedCommentId) || [];
let count = directReplies.length;
// AI-NOTE: Debug logging to track reply counting
if (directReplies.length > 0) {
console.debug(`[SectionComments] Found ${directReplies.length} direct replies for comment ${normalizedCommentId.substring(0, 8)}`);
}
// Recursively count nested replies
for (const reply of directReplies) {
count += countReplies(reply.id, repliesMap);
@ -166,7 +267,26 @@ @@ -166,7 +267,26 @@
* Render nested replies recursively
*/
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) {
const replies = repliesMap.get(parentId) || [];
// AI-NOTE: Normalize parent ID for lookup (must match how we stored it)
const normalizedParentId = parentId?.trim().toLowerCase();
const replies = repliesMap.get(normalizedParentId) || [];
// AI-NOTE: Debug logging to track reply rendering
if (replies.length > 0) {
console.debug(`[SectionComments] renderReplies: Found ${replies.length} replies for parent ${normalizedParentId.substring(0, 8)} (level ${level})`);
// Log all parent IDs in the map for debugging
const allParentIds = Array.from(repliesMap.keys());
console.debug(`[SectionComments] renderReplies: All parent IDs in map:`, allParentIds.map(id => id.substring(0, 8)));
} else {
// AI-NOTE: Debug when no replies found - check if map has any entries for similar IDs
const allParentIds = Array.from(repliesMap.keys());
const similarIds = allParentIds.filter(id => id.substring(0, 8) === normalizedParentId.substring(0, 8));
if (similarIds.length > 0) {
console.debug(`[SectionComments] renderReplies: No replies found for ${normalizedParentId.substring(0, 8)}, but found similar IDs:`, similarIds.map(id => id.substring(0, 8)));
} else {
console.debug(`[SectionComments] renderReplies: No replies found for ${normalizedParentId.substring(0, 8)}. Map has ${repliesMap.size} entries.`);
}
}
return replies;
}
@ -339,8 +459,12 @@ @@ -339,8 +459,12 @@
});
</script>
{#if visible && threadStructure.rootComments.length > 0}
<div class="space-y-1">
<!-- AI-NOTE: Debug info for comment display -->
{#if visible}
{console.debug(`[SectionComments] RENDERING CHECK: visible=${visible}, rootComments=${threadStructure.rootComments.length}, totalComments=${comments.length}, repliesByParent.size=${threadStructure.repliesByParent.size}`)}
{#if threadStructure.rootComments.length > 0}
{console.debug(`[SectionComments] RENDERING COMMENTS: visible=${visible}, rootComments=${threadStructure.rootComments.length}, totalComments=${comments.length}`)}
<div class="space-y-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg p-4 shadow-lg">
{#each threadStructure.rootComments as rootComment (rootComment.id)}
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)}
{@const isExpanded = expandedThreads.has(rootComment.id)}
@ -566,7 +690,7 @@ @@ -566,7 +690,7 @@
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
class="w-full mb-2"
/>
{#if replyError}
@ -602,8 +726,17 @@ @@ -602,8 +726,17 @@
<!-- Replies -->
{#if replyCount > 0}
{console.debug(`[SectionComments] Rendering ${replyCount} replies for comment ${rootComment.id?.substring(0, 8)}`)}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
{@const replyId = reply.id?.toLowerCase() || ""}
{@const nestedReplies = replyId ? renderReplies(reply.id, threadStructure.repliesByParent) : []}
{console.debug(`[SectionComments] Processing reply ${reply.id?.substring(0, 8)}, nestedReplies.length=${nestedReplies.length}, replyId=${replyId.substring(0, 8)}, checking map for: ${replyId.substring(0, 8)}`)}
{#if nestedReplies.length > 0}
{console.debug(`[SectionComments] ✓ Found ${nestedReplies.length} nested replies for reply ${reply.id?.substring(0, 8)}, rendering them now`)}
{:else}
{console.debug(`[SectionComments] ✗ No nested replies found for reply ${reply.id?.substring(0, 8)}. Map has ${threadStructure.repliesByParent.size} entries.`)}
{/if}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">
<div class="flex items-center gap-2 mb-2">
<button
@ -700,7 +833,7 @@ @@ -700,7 +833,7 @@
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
class="w-full mb-2"
/>
{#if replyError}
@ -734,8 +867,10 @@ @@ -734,8 +867,10 @@
</div>
{/if}
<!-- Nested replies (one level deep) -->
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)}
<!-- Nested replies (replies to replies) -->
{#if nestedReplies.length > 0}
{console.debug(`[SectionComments] Rendering ${nestedReplies.length} nested replies for reply ${reply.id?.substring(0, 8)}`)}
{#each nestedReplies as nestedReply (nestedReply.id)}
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2">
<div class="flex items-center gap-2 mb-1">
<button
@ -832,7 +967,7 @@ @@ -832,7 +967,7 @@
placeholder="Write your reply..."
rows={2}
disabled={isSubmittingReply}
class="mb-2 text-xs"
class="w-full mb-2 text-xs"
/>
{#if replyError}
@ -867,6 +1002,7 @@ @@ -867,6 +1002,7 @@
{/if}
</div>
{/each}
{/if}
</div>
{/each}
</div>
@ -877,6 +1013,9 @@ @@ -877,6 +1013,9 @@
</div>
{/each}
</div>
{:else}
{console.debug(`[SectionComments] NOT RENDERING: visible=${visible} but no root comments (totalComments=${comments.length})`)}
{/if}
{/if}
<!-- Details Modal -->

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

@ -30,12 +30,14 @@ @@ -30,12 +30,14 @@
event,
onDelete,
sectionAddress,
detailsModalOpen = $bindable(false)
detailsModalOpen = $bindable(false),
onCommentPosted
} = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
detailsModalOpen?: boolean; // Bindable prop to control modal from outside
onCommentPosted?: () => void; // Callback when a comment is successfully posted
}>();
const ndk = getNdkContext();
@ -385,6 +387,16 @@ @@ -385,6 +387,16 @@
}
commentSuccess = true;
// AI-NOTE: Trigger callback to refresh comments after successful publish
// This allows parent components to refresh their comment displays
if (onCommentPosted) {
// Delay callback slightly to allow relay indexing
setTimeout(() => {
onCommentPosted();
}, 1000);
}
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;

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

@ -298,6 +298,8 @@ @@ -298,6 +298,8 @@
isLoadingExtension = false;
try {
const ndk = new NDK();
// AI-NOTE: relay.nsec.app is the specific relay for Amber/NIP-46 signer service
// This is service-specific and not a general-purpose relay, so it remains hard-coded
const relay = "wss://relay.nsec.app";
const localNsec =
localStorage.getItem("amber/nsec") ??

4
src/lib/consts.ts

@ -11,12 +11,11 @@ export const communityRelays = [ @@ -11,12 +11,11 @@ export const communityRelays = [
];
export const searchRelays = [
"wss://profiles.nostr1.com",
"wss://thecitadel.nostr1.com",
"wss://aggr.nostr.land",
"wss://relay.noswhere.com",
"wss://nostr.wine",
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://freelay.sovbit.host",
];
@ -46,6 +45,7 @@ export const localRelays: string[] = [ @@ -46,6 +45,7 @@ export const localRelays: string[] = [
"ws://localhost:8080",
"ws://localhost:4869",
"ws://localhost:3334",
"ws://localhost:7777"
];
export enum FeedType {

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

@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder"; @@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder";
* - PlantUML diagrams
* - BPMN diagrams
* - TikZ diagrams
* - ABC notation (music)
*/
export async function postProcessAdvancedAsciidoctorHtml(
html: string,
@ -25,6 +26,8 @@ export async function postProcessAdvancedAsciidoctorHtml( @@ -25,6 +26,8 @@ export async function postProcessAdvancedAsciidoctorHtml(
processedHtml = processBPMNBlocks(processedHtml);
// Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml);
// Process ABC notation blocks
processedHtml = processABCBlocks(processedHtml);
// After all processing, apply highlight.js if available
if (
typeof globalThis !== "undefined" &&
@ -366,6 +369,147 @@ function processTikZBlocks(html: string): string { @@ -366,6 +369,147 @@ function processTikZBlocks(html: string): string {
return html;
}
/**
* Processes ABC notation blocks in HTML content
* Uses data attributes to mark blocks for rendering, which will be processed by a global function
*/
function processABCBlocks(html: string): string {
// Match code blocks with class 'language-abc' or 'abc'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-abc|abc)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
try {
const rawContent = decodeHTMLEntities(content);
const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
// Escape the ABC content for data attribute
const escapedContent = escapeHtml(rawContent).replace(/"/g, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC 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 ABC block:", error);
return match;
}
},
);
// Fallback: match <pre> blocks whose content starts with X: (ABC notation header)
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");
// ABC notation typically starts with X: (tune number) or contains ABC-specific patterns
if (
lines.some((line: string) =>
line.trim().startsWith("X:") ||
line.trim().startsWith("T:") ||
line.trim().startsWith("M:") ||
line.trim().startsWith("K:")
)
) {
try {
const rawContent = decodeHTMLEntities(content);
const blockId = `abc-${Math.random().toString(36).substring(2, 9)}`;
const escapedContent = escapeHtml(rawContent).replace(/"/g, "&quot;");
return `<div class="abc-block my-4">
<div id="${blockId}"
class="abc-diagram bg-white dark:bg-gray-800 px-6 py-4 rounded-lg border border-gray-300 dark:border-gray-600 shadow-lg"
data-abc-content="${escapedContent}"></div>
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400">
Show ABC 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 ABC fallback block:", error);
return match;
}
}
return match;
},
);
return html;
}
/**
* Initializes ABC notation rendering for all blocks marked with data-abc-content
* This function is called after HTML is inserted into the DOM
*/
function initializeABCBlocks(): void {
if (typeof window === "undefined") return;
const abcBlocks = document.querySelectorAll('[data-abc-content]');
if (abcBlocks.length === 0) return;
// Load abcjs from CDN if not already loaded
if (typeof (window as any).ABCJS === "undefined") {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/abcjs@6.2.0/dist/abcjs-basic.min.js";
script.onload = () => {
renderAllABCBlocks();
};
script.onerror = () => {
console.warn("Failed to load abcjs library");
};
document.head.appendChild(script);
} else {
renderAllABCBlocks();
}
function renderAllABCBlocks(): void {
const abcjs = (window as any).ABCJS;
if (!abcjs) return;
abcBlocks.forEach((block) => {
const container = block as HTMLElement;
const abcContent = container.getAttribute("data-abc-content");
if (!abcContent) return;
// Decode HTML entities
const textarea = document.createElement("textarea");
textarea.innerHTML = abcContent;
const decodedContent = textarea.value;
try {
abcjs.renderAbc(container.id || container, decodedContent, {
responsive: "resize",
staffwidth: 740,
scale: 1.0,
paddingleft: 20,
paddingright: 20,
paddingtop: 20,
paddingbottom: 20,
});
// Remove data attribute after rendering to avoid re-rendering
container.removeAttribute("data-abc-content");
} catch (error) {
console.warn("Failed to render ABC notation:", error);
container.innerHTML = '<p class="text-red-600 dark:text-red-400">Error rendering ABC notation. Please check the source.</p>';
}
});
}
}
// Make initializeABCBlocks available globally so it can be called from Svelte components
if (typeof window !== "undefined") {
(window as any).initializeABCBlocks = initializeABCBlocks;
}
/**
* Escapes HTML characters for safe display
*/

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

@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any { @@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any {
registerDiagramBlock("plantuml");
registerDiagramBlock("tikz");
registerDiagramBlock("bpmn");
registerDiagramBlock("abc");
// --- END NEW ---
return extensions;

15
src/lib/utils/mockCommentData.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { secondaryRelays } from "../consts.ts";
/**
* Generate mock comment data for testing comment UI and threading
@ -49,18 +50,20 @@ function createMockComment( @@ -49,18 +50,20 @@ function createMockComment(
replyToId?: string,
replyToAuthor?: string,
): any {
// Use first search relay from constants instead of hard-coded relay
const relayUrl = secondaryRelays[0] || "wss://thecitadel.nostr1.com";
const tags: string[][] = [
["A", targetAddress, "wss://relay.damus.io", pubkey],
["A", targetAddress, relayUrl, pubkey],
["K", "30041"],
["P", pubkey, "wss://relay.damus.io"],
["a", targetAddress, "wss://relay.damus.io"],
["P", pubkey, relayUrl],
["a", targetAddress, relayUrl],
["k", "30041"],
["p", pubkey, "wss://relay.damus.io"],
["p", pubkey, relayUrl],
];
if (replyToId && replyToAuthor) {
tags.push(["e", replyToId, "wss://relay.damus.io", "reply"]);
tags.push(["p", replyToAuthor, "wss://relay.damus.io"]);
tags.push(["e", replyToId, relayUrl, "reply"]);
tags.push(["p", replyToAuthor, relayUrl]);
}
// Return a plain object that matches NDKEvent structure

8
src/lib/utils/mockHighlightData.ts

@ -1,3 +1,5 @@ @@ -1,3 +1,5 @@
import { secondaryRelays } from "../consts.ts";
/**
* Generate mock highlight data (kind 9802) for testing highlight UI
* Creates realistic highlight events with context and optional annotations
@ -76,10 +78,12 @@ function createMockHighlight( @@ -76,10 +78,12 @@ function createMockHighlight(
offsetStart?: number,
offsetEnd?: number,
): any {
// Use first search relay from constants instead of hard-coded relay
const relayUrl = secondaryRelays[0] || "wss://thecitadel.nostr1.com";
const tags: string[][] = [
["a", targetAddress, "wss://relay.damus.io"],
["a", targetAddress, relayUrl],
["context", context],
["p", authorPubkey, "wss://relay.damus.io", "author"],
["p", authorPubkey, relayUrl, "author"],
];
// Add optional annotation

9
src/lib/utils/network_detection.ts

@ -25,8 +25,9 @@ const NETWORK_ENDPOINTS = [ @@ -25,8 +25,9 @@ const NETWORK_ENDPOINTS = [
export async function isNetworkOnline(): Promise<boolean> {
for (const endpoint of NETWORK_ENDPOINTS) {
try {
// Use a simple fetch without HEAD method to avoid CORS issues
await fetch(endpoint, {
// Use window.fetch explicitly for client-side network detection
// This is intentionally client-side code, not a SvelteKit load function
await window.fetch(endpoint, {
method: "GET",
cache: "no-cache",
signal: AbortSignal.timeout(3000),
@ -56,7 +57,9 @@ export async function testNetworkSpeed(): Promise<number> { @@ -56,7 +57,9 @@ export async function testNetworkSpeed(): Promise<number> {
for (const endpoint of NETWORK_ENDPOINTS) {
try {
await fetch(endpoint, {
// Use window.fetch explicitly for client-side network detection
// This is intentionally client-side code, not a SvelteKit load function
await window.fetch(endpoint, {
method: "GET",
cache: "no-cache",
signal: AbortSignal.timeout(5000),

23
src/lib/utils/nostrUtils.ts

@ -646,11 +646,17 @@ export function pubkeyToHue(pubkey: string): number { @@ -646,11 +646,17 @@ export function pubkeyToHue(pubkey: string): number {
* if they are not already prefixed and are not part of a hyperlink
*/
export function prefixNostrAddresses(content: string): string {
// Early exit: if content already has double-prefixed addresses, return as-is to prevent further damage
if (content.includes("nostr:nostr:")) {
return content;
}
// 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
// Use negative lookbehind to prevent matching if "nostr:" is immediately before
const nostrAddressPattern =
/\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
/(?<!nostr:)\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)
@ -682,13 +688,24 @@ export function prefixNostrAddresses(content: string): string { @@ -682,13 +688,24 @@ export function prefixNostrAddresses(content: string): string {
}
// Check if it's already prefixed with "nostr:"
// First check: is "nostr:" immediately before the match (last 6 characters)?
const textBeforeMatch = beforeMatch.slice(-6);
if (textBeforeMatch === "nostr:") {
return match; // Already prefixed with "nostr:", don't add another prefix
}
// Second check: is there "nostr:" anywhere before with no whitespace between it and the match?
const beforeNostr = beforeMatch.lastIndexOf("nostr:");
if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(" ")) {
return match; // Already prefixed
// If there's no whitespace or newline between "nostr:" and the match, it's already prefixed
if (!/[\s\n\r\t]/.test(textAfterNostr)) {
return match; // Already prefixed, don't add another prefix
}
}
// Third check: does the match itself start with "nostr:"? (shouldn't happen, but safety check)
if (match.startsWith("nostr:")) {
return match; // Already prefixed, don't add another prefix
}
// Additional check: ensure it's actually a valid Nostr address format
// The part after the prefix should be a valid bech32 string

13
src/lib/utils/searchCache.ts

@ -98,6 +98,19 @@ class SearchCache { @@ -98,6 +98,19 @@ class SearchCache {
size(): number {
return this.cache.size;
}
/**
* Clear cache entries for a specific search type
*/
clearType(searchType: string): void {
const keysToDelete: string[] = [];
for (const [key] of this.cache.entries()) {
if (key.startsWith(`${searchType}:`)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => this.cache.delete(key));
}
}
export const searchCache = new SearchCache();

3
src/lib/utils/search_constants.ts

@ -66,6 +66,9 @@ export const SEARCH_LIMITS = { @@ -66,6 +66,9 @@ export const SEARCH_LIMITS = {
/** Maximum events to fetch before processing in subscription search */
SUBSCRIPTION_FETCH_LIMIT: 1000,
/** Maximum index events to fetch per relay for publication feed */
PUBLICATION_FEED_LIMIT: 10000,
} as const;
// Nostr event kind ranges

21
src/lib/utils/subscription_search.ts

@ -339,6 +339,18 @@ export async function searchBySubscription( @@ -339,6 +339,18 @@ export async function searchBySubscription(
ndk,
);
console.log("subscription_search: Created search filter:", searchFilter);
// AI-NOTE: Validate filter to prevent "No filters to merge!" error from NDK
if (!searchFilter.filter || Object.keys(searchFilter.filter).length === 0) {
console.error(
"subscription_search: Invalid or empty filter created:",
searchFilter,
);
throw new Error(
`Failed to create valid filter for search type: ${searchType}`,
);
}
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk);
console.log(
"subscription_search: Created primary relay set with",
@ -1076,6 +1088,15 @@ function searchOtherRelaysInBackground( @@ -1076,6 +1088,15 @@ function searchOtherRelaysInBackground(
Array.from(ndk.pool.relays.values()).map((r: any) => r.url),
);
// AI-NOTE: Validate filter before subscribing to prevent "No filters to merge!" error
if (!searchFilter.filter || Object.keys(searchFilter.filter).length === 0) {
console.warn(
"subscription_search: Invalid or empty filter, skipping background search",
searchFilter,
);
return Promise.resolve(createSearchResult(searchState, searchType, ""));
}
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,

5
src/lib/utils/websocket_utils.ts

@ -92,8 +92,9 @@ export async function fetchNostrEvent( @@ -92,8 +92,9 @@ export async function fetchNostrEvent(
const { searchRelays, secondaryRelays } = await import("../consts.ts");
availableRelays = [...searchRelays, ...secondaryRelays];
if (availableRelays.length === 0) {
availableRelays = ["wss://thecitadel.nostr1.com"];
// Final fallback: use first secondary relay if all else fails
if (availableRelays.length === 0 && secondaryRelays.length > 0) {
availableRelays = [secondaryRelays[1]];
}
}

4
src/routes/about/+page.svelte

@ -27,9 +27,9 @@ @@ -27,9 +27,9 @@
<P class="mb-3">
Alexandria is a reader and writer for <A
href="./publication/d/gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
href="./publication/naddr/naddr1qvzqqqrcvgpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyfhwue69uhkcmmrv9kxsmmnwsarwdehxuq3jamnwvaz7tmxwfjk2mrp0yh8xmmkvf5hgtngdaehgqgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszxnhwden5te0w35x2en0wfjhxapwdehhxarjxyhxxmmdqyg8wumn8ghj7mn0wd68ytnvv9hxgqgswaehxw309ahx7um5wgh8w6twv5q3wamnwvaz7tmwdaehgu3wwdhhvcnfwshxsmmnwsq3zamnwvaz7tmwdaehgu3jxyhxxmmdqqyxu6mzd9cz6vp3cvtwu9"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
> (in Asciidoc), wiki pages (Asciidoc), and long-form
articles (markup). It is produced by the <A
href="./publication/d/gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A

10
src/routes/contact/+page.svelte

@ -35,7 +35,7 @@ @@ -35,7 +35,7 @@
let submissionSuccess = $state(false);
let submissionError = $state("");
let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state("");
let issueLink = $state("https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria/issues/");
let successfulRelays = $state<string[]>([]);
// Store form data when user needs to login
@ -48,10 +48,6 @@ @@ -48,10 +48,6 @@
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// Repository event address from the task
const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Use the new relay management system with anonymous relays as fallbacks
const allRelays = [
...$activeInboxRelays,
@ -198,7 +194,7 @@ @@ -198,7 +194,7 @@
// Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
issueLink = `${issueLink}${noteId}`;
// Clear form and show success message
clearForm();
@ -279,7 +275,7 @@ @@ -279,7 +275,7 @@
"GitCitadel",
ndk,
)} or you can view submitted issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
href="https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria/issues"
target="_blank">Alexandria repo page.</A
>
</P>

93
src/routes/start/+page.svelte

@ -18,11 +18,11 @@ @@ -18,11 +18,11 @@
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
> or add in their own relays, and scroll/search the publications.
>, where the user can see the publications found on their own relays and the app's default relays.
The user can also search the publications by title, author, or content.
The relays can be viewed on the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/about/relay-stats")}>Relay Status</button> page.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
@ -32,20 +32,8 @@ @@ -32,20 +32,8 @@
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="image-border rounded-lg"
width="400"
/>
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all
@ -58,8 +46,7 @@ @@ -58,8 +46,7 @@
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.)
a blog post.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
@ -77,6 +64,23 @@ @@ -77,6 +64,23 @@
/>
</div>
<P class="mb-3">
There is the ability to view and navigate the publications as a diagram, if you
click on <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/visualize")}>Visualize</button>.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/Visualization.png"
alt="Visualization page"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
@ -107,23 +111,12 @@ @@ -107,23 +111,12 @@
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX
Alexandria displays research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="mb-3">
An example of a research paper is <a
href="/publication/d/less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
@ -177,4 +170,40 @@ @@ -177,4 +170,40 @@
to other wiki pages, creating a web of knowledge that can be navigated and
explored.
</P>
</div>
<Heading tag="h3" class="h-leather mb-3">For long-form articles</Heading>
<P class="mb-3">
We also display long-form articles (kind 30023), which are stand-alone documents. An example is
the article that launched this project's concept, <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/publication/naddr/naddr1qvzqqqr4gupzphtxf40yq9jr82xdd8cqtts5szqyx5tcndvaukhsvfmduetr85ceqyw8wumn8ghj7cmgwf5hxarsd9kxctnwdaehgu339e3k7mf0qyf8wumn8ghj7mn0wd68yv339e3k7mf0qqxnzde38yerqdpexsmnyvek0wj7rv")}> Project Alexandria</button>.
Long-form articles utilize simple Markdown formatting.
</P>
<Heading tag="h3" class="h-leather mb-3">Additional features</Heading>
<P class="mb-3">
The app also has a powerful search interface, a composition form, and a universal publisher.
It comes along wtih two other apps, Jumble and Wikistr. <a href="https://jumble.imwald.eu/notes/naddr1qvzqqqr4tqpzq4ekxjmysc6vhtgs7fz3wasgn63ppyxegplhzh5rc4mmgcg6umkuqyw8wumn8ghj7argv43kjarpv3jkctnwdaehgu339e3k7mf0qpg9g6r9942xzmr994hkvt2sv46x2u3d2fskycnfwskkzmny948hg6r9wfej6cne94px2ct5wf5hst2sda68getj94mz6st4v35k7cn0da4hxttxwfhk6t2vd938yetkdauqrhxcs8" target="_blank">Jumble</a> is a meant as a daily driver, but also supports
basic Alexandria features. <a href="https://wikistr.imwald.eu/jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition*fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" target="_blank">Wikistr</a> is
a more advanced app, with a focus on wiki pages and collaboration. It contains
the exporting function, utilizing an Asciidoctor server to download publications as Asciidoc, PDFs, EPUB, or HTML files.
</P>
<P class="mb-3">
Thank you for your time. Feel free to explore the app and see how it works, and make sure to <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/about")}>save our contact information</button>!
</P>
<Img src="https://raw.githubusercontent.com/ShadowySupercode/gitcitadel/master/logos/GitCitadel_Logo.png" alt="GitCitadel Logo" class="image-border rounded-lg" width="200" />
<P class="mb-3">
If you have any questions or feedback, please feel free to <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/contact")}>contact us</button>.
</P>
</div>

BIN
static/screenshots/JaneEyre.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 296 KiB

BIN
static/screenshots/LandingPage.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 798 KiB

BIN
static/screenshots/ToC_blog.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 590 KiB

BIN
static/screenshots/ToC_normal.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 284 KiB

BIN
static/screenshots/Visualization.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
static/screenshots/YourRelays.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Loading…
Cancel
Save