Browse Source

refactor

master
Silberengel 1 month ago
parent
commit
5a2a9a2cd2
  1. 2
      httpd.conf.template
  2. 334
      package-lock.json
  3. 8
      package.json
  4. 4
      public/healthz.json
  5. 114
      src/lib/components/content/MarkdownRenderer.svelte
  6. 26
      src/lib/components/layout/Header.svelte
  7. 194
      src/lib/components/modals/KeyboardShortcutsModal.svelte
  8. 370
      src/lib/components/modals/SearchModal.svelte
  9. 148
      src/lib/modules/feed/CreateFeedForm.svelte
  10. 99
      src/lib/modules/feed/FeedPage.svelte
  11. 141
      src/lib/modules/feed/ReplyToKind1Form.svelte
  12. 6
      src/lib/modules/reactions/FeedReactionButtons.svelte
  13. 6
      src/lib/modules/reactions/ReactionButtons.svelte
  14. 184
      src/lib/modules/threads/CreateThreadForm.svelte
  15. 23
      src/lib/services/cache/event-cache.ts
  16. 46
      src/lib/services/cache/search-index.ts
  17. 8
      src/lib/services/event-filter.ts
  18. 126
      src/lib/services/keyboard-shortcuts.ts
  19. 50
      src/lib/services/security/bech32-utils.ts
  20. 55
      src/lib/services/security/event-validator.ts
  21. 20
      src/routes/+layout.svelte
  22. 28
      src/routes/+page.svelte
  23. 2
      svelte.config.js

2
httpd.conf.template

@ -19,7 +19,7 @@ RewriteRule ^healthz$ /healthz.json [L] @@ -19,7 +19,7 @@ RewriteRule ^healthz$ /healthz.json [L]
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
RewriteRule . /200.html [L]
<IfModule mod_headers.c>
Header set Service-Worker-Allowed "/"

334
package-lock.json generated

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"dompurify": "^3.0.6",
"idb": "^8.0.0",
"marked": "^11.1.1",
@ -32,7 +32,7 @@ @@ -32,7 +32,7 @@
"prettier-plugin-svelte": "^3.2.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
"vite": "^5.4.21"
}
},
"node_modules/@alloc/quick-lru": {
@ -49,9 +49,9 @@ @@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
@ -61,13 +61,13 @@ @@ -61,13 +61,13 @@
"aix"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
@ -77,13 +77,13 @@ @@ -77,13 +77,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
@ -93,13 +93,13 @@ @@ -93,13 +93,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
@ -109,13 +109,13 @@ @@ -109,13 +109,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
@ -125,13 +125,13 @@ @@ -125,13 +125,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
@ -141,13 +141,13 @@ @@ -141,13 +141,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
@ -157,13 +157,13 @@ @@ -157,13 +157,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
@ -173,13 +173,13 @@ @@ -173,13 +173,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
@ -189,13 +189,13 @@ @@ -189,13 +189,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
@ -205,13 +205,13 @@ @@ -205,13 +205,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
@ -221,13 +221,13 @@ @@ -221,13 +221,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
@ -237,13 +237,13 @@ @@ -237,13 +237,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
@ -253,13 +253,13 @@ @@ -253,13 +253,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
@ -269,13 +269,13 @@ @@ -269,13 +269,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
@ -285,13 +285,13 @@ @@ -285,13 +285,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
@ -301,13 +301,13 @@ @@ -301,13 +301,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
@ -317,13 +317,29 @@ @@ -317,13 +317,29 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
@ -333,13 +349,29 @@ @@ -333,13 +349,29 @@
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
@ -349,13 +381,13 @@ @@ -349,13 +381,13 @@
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
@ -365,13 +397,13 @@ @@ -365,13 +397,13 @@
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
@ -381,13 +413,13 @@ @@ -381,13 +413,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
@ -397,13 +429,13 @@ @@ -397,13 +429,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
@ -413,7 +445,7 @@ @@ -413,7 +445,7 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@ -1122,56 +1154,43 @@ @@ -1122,56 +1154,43 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
"integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
"debug": "^4.3.4",
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
"debug": "^4.3.7",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.10",
"svelte-hmr": "^0.16.0",
"vitefu": "^0.2.5"
"magic-string": "^0.30.12",
"vitefu": "^1.0.3"
},
"engines": {
"node": "^18.0.0 || >=20"
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
"debug": "^4.3.7"
},
"engines": {
"node": "^18.0.0 || >=20"
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
"svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte/node_modules/svelte-hmr": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
"integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
"license": "ISC",
"engines": {
"node": "^12.20 || ^14.13.1 || >= 16"
},
"peerDependencies": {
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -1969,41 +1988,43 @@ @@ -1969,41 +1988,43 @@
"license": "ISC"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/escalade": {
@ -4222,12 +4243,17 @@ @@ -4222,12 +4243,17 @@
}
},
"node_modules/vitefu": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"vite": {

8
package.json

@ -24,7 +24,7 @@ @@ -24,7 +24,7 @@
},
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"dompurify": "^3.0.6",
"idb": "^8.0.0",
"marked": "^11.1.1",
@ -46,6 +46,10 @@ @@ -46,6 +46,10 @@
"prettier-plugin-svelte": "^3.2.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
"vite": "^5.4.21"
},
"overrides": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"esbuild": "^0.24.2"
}
}

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-03T06:34:16.073Z",
"buildTime": "2026-02-03T07:39:15.977Z",
"gitCommit": "unknown",
"timestamp": 1770100456074
"timestamp": 1770104355977
}

114
src/lib/components/content/MarkdownRenderer.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from 'marked';
import * as marked from 'marked';
import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
@ -45,38 +45,27 @@ @@ -45,38 +45,27 @@
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
if (parsed.type === 'npub') {
// Render as profile link
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
}
} catch {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
try {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = String(decoded.data);
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (parsed.type === 'note' || parsed.type === 'nevent') {
// Render as event link
try {
const decoded = nip19.decode(parsed.data);
let eventId = '';
if (decoded.type === 'note') {
eventId = decoded.data;
} else if (decoded.type === 'nevent') {
eventId = decoded.data.id;
}
if (eventId) {
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub') {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else {
// Generic link
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement);
@ -91,38 +80,27 @@ @@ -91,38 +80,27 @@
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
if (parsed.type === 'npub') {
// Render as profile link
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
}
} catch {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
try {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = String(decoded.data);
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (parsed.type === 'note' || parsed.type === 'nevent') {
// Render as event link
try {
const decoded = nip19.decode(parsed.data);
let eventId = '';
if (decoded.type === 'note') {
eventId = decoded.data;
} else if (decoded.type === 'nevent') {
eventId = decoded.data.id;
}
if (eventId) {
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-note">${uri}</span>`;
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub') {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else {
// Generic link
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
finalHtml = finalHtml.replace(new RegExp(placeholder, 'g'), replacement);
@ -154,7 +132,7 @@ @@ -154,7 +132,7 @@
text-decoration: underline;
}
.dark .markdown-content :global(a) {
:global(.dark) .markdown-content :global(a) {
color: #94a3b8;
}
@ -162,7 +140,7 @@ @@ -162,7 +140,7 @@
color: #475569;
}
.dark .markdown-content :global(a:hover) {
:global(.dark) .markdown-content :global(a:hover) {
color: #cbd5e1;
}
@ -192,7 +170,7 @@ @@ -192,7 +170,7 @@
color: #475569;
}
.dark .markdown-content :global(code) {
:global(.dark) .markdown-content :global(code) {
background: #475569;
color: #cbd5e1;
}
@ -205,7 +183,7 @@ @@ -205,7 +183,7 @@
border: 1px solid #cbd5e1;
}
.dark .markdown-content :global(pre) {
:global(.dark) .markdown-content :global(pre) {
background: #475569;
border-color: #64748b;
}
@ -216,7 +194,7 @@ @@ -216,7 +194,7 @@
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
}
.dark .markdown-content :global(img) {
:global(.dark) .markdown-content :global(img) {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
}
@ -227,8 +205,8 @@ @@ -227,8 +205,8 @@
display: inline-block;
}
.dark .markdown-content :global(span[role="img"]),
.dark .markdown-content :global(.emoji) {
:global(.dark) .markdown-content :global(span[role="img"]),
:global(.dark) .markdown-content :global(.emoji) {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
}
</style>

26
src/lib/components/layout/Header.svelte

@ -3,11 +3,6 @@ @@ -3,11 +3,6 @@
import ThemeToggle from '../preferences/ThemeToggle.svelte';
import UserPreferences from '../preferences/UserPreferences.svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import KeyboardShortcutsModal from '../modals/KeyboardShortcutsModal.svelte';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { onMount } from 'svelte';
let showShortcuts = $state(false);
let currentSession = $state<UserSession | null>(sessionManager.session.value);
let isLoggedIn = $derived(currentSession !== null);
@ -21,17 +16,6 @@ @@ -21,17 +16,6 @@
return unsubscribe;
});
onMount(() => {
// Register ? shortcut for keyboard shortcuts help
const unregister = keyboardShortcuts.register({
key: '?',
handler: () => {
showShortcuts = true;
},
description: 'Show keyboard shortcuts'
});
return unregister;
});
</script>
<header class="relative border-b border-fog-border dark:border-fog-dark-border">
@ -67,14 +51,6 @@ @@ -67,14 +51,6 @@
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Login</a>
{/if}
<button
onclick={() => (showShortcuts = true)}
class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors"
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
>
?
</button>
<UserPreferences />
<ThemeToggle />
</div>
@ -82,8 +58,6 @@ @@ -82,8 +58,6 @@
</nav>
</header>
<KeyboardShortcutsModal open={showShortcuts} onClose={() => (showShortcuts = false)} />
<style>
header {
max-width: 100%;

194
src/lib/components/modals/KeyboardShortcutsModal.svelte

@ -1,194 +0,0 @@ @@ -1,194 +0,0 @@
<script lang="ts">
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const shortcuts = [
{ key: '/', description: 'Open search' },
{ key: '?', description: 'Show keyboard shortcuts' },
{ key: 'j', description: 'Next post/thread' },
{ key: 'k', description: 'Previous post/thread' },
{ key: 'r', description: 'Reply to selected post' },
{ key: 'z', description: 'Zap selected post' },
{ key: 'Esc', description: 'Close modal' }
];
</script>
{#if open}
<div
class="modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="modal">
<div class="modal-header">
<h2 id="shortcuts-title" class="text-xl font-bold">Keyboard Shortcuts</h2>
<button
onclick={onClose}
class="close-button"
aria-label="Close"
>
×
</button>
</div>
<div class="shortcuts-list">
{#each shortcuts as shortcut}
<div class="shortcut-item">
<kbd class="shortcut-key">{shortcut.key}</kbd>
<span class="shortcut-description">{shortcut.description}</span>
</div>
{/each}
</div>
<div class="modal-footer">
<button
onclick={onClose}
class="close-footer-button"
>
Close
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.modal {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 500px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .modal {
background: var(--fog-dark-post, #1f2937);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text, #1f2937);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb);
}
.close-button:hover {
opacity: 0.7;
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .shortcut-item {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.shortcut-key {
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .shortcut-key {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.shortcut-description {
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .shortcut-description {
color: var(--fog-dark-text, #f9fafb);
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
.close-footer-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.close-footer-button:hover {
opacity: 0.9;
}
</style>

370
src/lib/components/modals/SearchModal.svelte

@ -1,370 +0,0 @@ @@ -1,370 +0,0 @@
<script lang="ts">
import { searchEvents } from '../../services/cache/search-index.js';
import { getEvent } from '../../services/cache/event-cache.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let query = $state('');
let results = $state<NostrEvent[]>([]);
let loading = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
$effect(() => {
if (open && searchInput) {
// Focus input when modal opens
setTimeout(() => searchInput?.focus(), 100);
}
});
async function handleSearch() {
if (!query.trim()) {
results = [];
return;
}
loading = true;
try {
// Search in index
const eventIds = await searchEvents(query.trim(), 20);
// Fetch events
const events: NostrEvent[] = [];
for (const id of eventIds) {
try {
const cached = await getEvent(id);
if (cached) {
events.push(cached.event);
} else {
// Try to fetch from relays
const relays = relayManager.getThreadReadRelays();
const event = await nostrClient.getEventById(id, relays);
if (event) {
events.push(event);
}
}
} catch {
// Skip if event not found
}
}
results = events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error searching:', error);
results = [];
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleResultClick(event: NostrEvent) {
// Navigate to thread if kind 11, or show event
if (event.kind === 11) {
goto(`/thread/${event.id}`);
} else if (event.kind === 1) {
// Could navigate to feed and highlight, or show in modal
goto(`/feed`);
}
onClose();
}
function getEventPreview(event: NostrEvent): string {
const content = event.content || '';
const preview = content.slice(0, 150);
return preview + (content.length > 150 ? '...' : '');
}
function getEventType(event: NostrEvent): string {
switch (event.kind) {
case 1:
return 'Post';
case 11:
return 'Thread';
case 1111:
return 'Comment';
default:
return 'Event';
}
}
</script>
{#if open}
<div
class="search-modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="search-title"
tabindex="-1"
>
<div class="search-modal">
<div class="search-header">
<h2 id="search-title" class="text-xl font-bold mb-4">Search</h2>
<button
onclick={onClose}
class="close-button"
aria-label="Close search"
>
×
</button>
</div>
<div class="search-input-container">
<input
bind:this={searchInput}
type="text"
bind:value={query}
onkeydown={handleKeydown}
placeholder="Search posts, threads, comments..."
class="search-input"
aria-label="Search query"
/>
<button
onclick={handleSearch}
class="search-button"
disabled={loading || !query.trim()}
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
{#if loading}
<div class="search-results">
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light py-4">
Searching...
</p>
</div>
{:else if results.length > 0}
<div class="search-results">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light mb-2">
Found {results.length} {results.length === 1 ? 'result' : 'results'}
</p>
<div class="results-list">
{#each results as event (event.id)}
<button
onclick={() => handleResultClick(event)}
class="result-item"
>
<div class="result-header">
<span class="result-type">{getEventType(event)}</span>
<span class="result-time">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
<div class="result-content">
{getEventPreview(event)}
</div>
</button>
{/each}
</div>
</div>
{:else if query.trim() && !loading}
<div class="search-results">
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light py-4">
No results found
</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
.search-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem;
z-index: 1000;
overflow-y: auto;
}
.search-modal {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 600px;
margin-top: 5vh;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .search-modal {
background: var(--fog-dark-post, #1f2937);
}
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text, #1f2937);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb);
}
.close-button:hover {
opacity: 0.7;
}
.search-input-container {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 1rem;
}
:global(.dark) .search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
}
.search-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-results {
max-height: 60vh;
overflow-y: auto;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
text-align: left;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
cursor: pointer;
transition: background 0.2s;
}
:global(.dark) .result-item {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.result-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .result-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.result-type {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-accent, #64748b);
text-transform: uppercase;
}
.result-time {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .result-time {
color: var(--fog-dark-text-light, #9ca3af);
}
.result-content {
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
line-height: 1.5;
}
:global(.dark) .result-content {
color: var(--fog-dark-text, #f9fafb);
}
</style>

148
src/lib/modules/feed/CreateFeedForm.svelte

@ -1,148 +0,0 @@ @@ -1,148 +0,0 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
parentEvent?: NostrEvent; // If replying to a post
onPublished?: () => void;
onCancel?: () => void;
}
let { parentEvent, onPublished, onCancel }: Props = $props();
let content = $state('');
let publishing = $state(false);
let includeClientTag = $state(true);
async function publish() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to post');
return;
}
if (!content.trim()) {
alert('Post cannot be empty');
return;
}
publishing = true;
try {
const tags: string[][] = [];
// If replying, add NIP-10 threading tags
if (parentEvent) {
const rootTag = parentEvent.tags.find((t) => t[0] === 'root');
const rootId = rootTag?.[1] || parentEvent.id;
tags.push(['e', parentEvent.id, '', 'reply']);
tags.push(['p', parentEvent.pubkey]);
tags.push(['root', rootId]);
}
if (includeClientTag) {
tags.push(['client', 'Aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 1,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
};
// Get target inbox if replying
let targetInbox: string[] | undefined;
if (parentEvent) {
// Try to get target's inbox from their relay list
try {
const { fetchRelayLists } = await import('../../services/auth/relay-list-fetcher.js');
const { inbox } = await fetchRelayLists(parentEvent.pubkey);
targetInbox = inbox;
} catch {
// Ignore errors, just use default relays
}
}
const relays = relayManager.getFeedPublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
content = '';
onPublished?.();
} else {
alert('Failed to publish post');
}
} catch (error) {
console.error('Error publishing post:', error);
alert('Error publishing post');
} finally {
publishing = false;
}
}
</script>
<div class="create-Feed-form">
{#if parentEvent}
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm">
Replying to: {parentEvent.content.slice(0, 100)}...
</div>
{/if}
<textarea
bind:value={content}
placeholder={parentEvent ? 'Write a reply...' : 'What\'s on your mind?'}
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
rows="6"
disabled={publishing}
></textarea>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text">
<input
type="checkbox"
bind:checked={includeClientTag}
class="rounded"
/>
Include client tag
</label>
<div class="flex gap-2">
{#if onCancel}
<button
onclick={onCancel}
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight"
disabled={publishing}
>
Cancel
</button>
{/if}
<button
onclick={publish}
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50"
disabled={publishing || !content.trim()}
>
{publishing ? 'Publishing...' : parentEvent ? 'Reply' : 'Post'}
</button>
</div>
</div>
</div>
<style>
.create-Feed-form {
margin-bottom: 1rem;
}
textarea {
resize: vertical;
min-height: 120px;
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
</style>

99
src/lib/modules/feed/FeedPage.svelte

@ -1,10 +1,8 @@ @@ -1,10 +1,8 @@
<script lang="ts">
import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import CreateFeedForm from './CreateFeedForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
@ -12,13 +10,10 @@ @@ -12,13 +10,10 @@
let posts = $state<NostrEvent[]>([]);
let replaceableEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
let showNewPostForm = $state(false);
let hasMore = $state(true);
let loadingMore = $state(false);
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
let selectedIndex = $state<number>(-1);
let showOPsOnly = $state(false);
onMount(() => {
@ -29,75 +24,11 @@ @@ -29,75 +24,11 @@
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
// Register keyboard shortcuts
const unregisterJ = keyboardShortcuts.register({
key: 'j',
handler: () => {
if (posts.length > 0 && !showNewPostForm) {
selectedIndex = Math.min(selectedIndex + 1, posts.length - 1);
scrollToPost(selectedIndex);
}
},
description: 'Next post'
});
const unregisterK = keyboardShortcuts.register({
key: 'k',
handler: () => {
if (posts.length > 0 && !showNewPostForm) {
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToPost(selectedIndex);
}
},
description: 'Previous post'
});
const unregisterR = keyboardShortcuts.register({
key: 'r',
handler: () => {
if (selectedIndex >= 0 && selectedIndex < posts.length && !showNewPostForm) {
handleReply(posts[selectedIndex]);
}
},
description: 'Reply to selected post'
});
const unregisterZ = keyboardShortcuts.register({
key: 'z',
handler: () => {
if (selectedIndex >= 0 && selectedIndex < posts.length && !showNewPostForm) {
// Trigger zap button click
const postElement = document.querySelector(`[data-post-id="${posts[selectedIndex].id}"]`);
const zapButton = postElement?.querySelector('[data-zap-button]') as HTMLElement;
zapButton?.click();
}
},
description: 'Zap selected post'
});
return () => {
window.removeEventListener('scroll', handleScroll);
unregisterJ();
unregisterK();
unregisterR();
unregisterZ();
};
});
function scrollToPost(index: number) {
if (index < 0 || index >= posts.length) return;
const postElement = document.querySelector(`[data-post-id="${posts[index].id}"]`);
if (postElement) {
postElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight briefly
postElement.classList.add('keyboard-selected');
setTimeout(() => {
postElement.classList.remove('keyboard-selected');
}, 1000);
}
}
async function loadFeed(reset = true) {
if (reset) {
loading = true;
@ -367,16 +298,7 @@ @@ -367,16 +298,7 @@
return result;
}
function handleReply(post: NostrEvent) {
replyingTo = post;
showNewPostForm = true;
}
function handlePostPublished() {
replyingTo = null;
showNewPostForm = false;
loadFeed();
}
function handleShowNewPosts() {
// Scroll to top and reset new posts count
@ -438,28 +360,9 @@ @@ -438,28 +360,9 @@
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span>
</label>
<button
onclick={() => (showNewPostForm = !showNewPostForm)}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
>
{showNewPostForm ? 'Cancel' : 'New Post'}
</button>
</div>
</div>
{#if showNewPostForm}
<div class="new-post-form mb-4">
<CreateFeedForm
parentEvent={replyingTo || undefined}
onPublished={handlePostPublished}
onCancel={() => {
showNewPostForm = false;
replyingTo = null;
}}
/>
</div>
{/if}
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p>
{:else if posts.length === 0 && replaceableEvents.length === 0}
@ -481,7 +384,7 @@ @@ -481,7 +384,7 @@
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined}
<div data-post-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<FeedPost post={item.event} parentEvent={parentEvent} onReply={handleReply} />
<FeedPost post={item.event} parentEvent={parentEvent} />
</div>
{:else if item.type === 'replaceable'}
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>

141
src/lib/modules/feed/ReplyToKind1Form.svelte

@ -1,141 +0,0 @@ @@ -1,141 +0,0 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
parentEvent: NostrEvent; // The event to reply to
onPublished?: () => void;
onCancel?: () => void;
}
let { parentEvent, onPublished, onCancel }: Props = $props();
let content = $state('');
let publishing = $state(false);
let includeClientTag = $state(true);
async function publish() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to reply');
return;
}
if (!content.trim()) {
alert('Reply cannot be empty');
return;
}
publishing = true;
try {
const tags: string[][] = [];
// Add NIP-10 threading tags for reply
const rootTag = parentEvent.tags.find((t) => t[0] === 'root');
const rootId = rootTag?.[1] || parentEvent.id;
tags.push(['e', parentEvent.id, '', 'reply']);
tags.push(['p', parentEvent.pubkey]);
tags.push(['root', rootId]);
if (includeClientTag) {
tags.push(['client', 'Aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 1,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content: content.trim()
};
// Get target inbox if replying
let targetInbox: string[] | undefined;
try {
const { fetchRelayLists } = await import('../../services/user-data.js');
const { inbox } = await fetchRelayLists(parentEvent.pubkey);
targetInbox = inbox;
} catch {
// Ignore errors, just use default relays
}
const relays = relayManager.getFeedPublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
content = '';
onPublished?.();
} else {
alert('Failed to publish reply');
}
} catch (error) {
console.error('Error publishing reply:', error);
alert('Error publishing reply');
} finally {
publishing = false;
}
}
</script>
<div class="reply-to-Feed-form">
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm">
<span class="font-semibold">Replying to:</span> {parentEvent.content.slice(0, 100)}...
</div>
<textarea
bind:value={content}
placeholder="Write a reply..."
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
rows="6"
disabled={publishing}
></textarea>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text">
<input
type="checkbox"
bind:checked={includeClientTag}
class="rounded"
/>
Include client tag
</label>
<div class="flex gap-2">
{#if onCancel}
<button
onclick={onCancel}
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight"
disabled={publishing}
>
Cancel
</button>
{/if}
<button
onclick={publish}
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50"
disabled={publishing || !content.trim()}
>
{publishing ? 'Publishing...' : 'Reply'}
</button>
</div>
</div>
</div>
<style>
.reply-to-Feed-form {
margin-bottom: 1rem;
}
textarea {
resize: vertical;
min-height: 120px;
}
textarea:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
</style>

6
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -116,10 +116,12 @@ @@ -116,10 +116,12 @@
await signAndPublish(reactionEvent, [...config.defaultRelays]);
userReaction = content;
const currentPubkey = sessionManager.getCurrentPubkey()!;
if (!reactions.has(content)) {
reactions.set(content, { content, pubkeys: new Set() });
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) });
} else {
reactions.get(content)!.pubkeys.add(currentPubkey);
}
reactions.get(content)!.pubkeys.add(sessionManager.getCurrentPubkey()!);
} catch (error) {
console.error('Error publishing reaction:', error);
alert('Error publishing reaction');

6
src/lib/modules/reactions/ReactionButtons.svelte

@ -137,10 +137,12 @@ @@ -137,10 +137,12 @@
// Update local state
userReaction = content;
const currentPubkey = sessionManager.getCurrentPubkey()!;
if (!reactions.has(content)) {
reactions.set(content, { content, pubkeys: new Set() });
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) });
} else {
reactions.get(content)!.pubkeys.add(currentPubkey);
}
reactions.get(content)!.pubkeys.add(sessionManager.getCurrentPubkey()!);
} catch (error) {
console.error('Error publishing reaction:', error);
alert('Error publishing reaction');

184
src/lib/modules/threads/CreateThreadForm.svelte

@ -1,184 +0,0 @@ @@ -1,184 +0,0 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import type { NostrEvent } from '../../types/nostr.js';
let title = $state('');
let content = $state('');
let topics = $state<string[]>([]);
let topicInput = $state('');
let includeClientTag = $state(true);
let publishing = $state(false);
let showPublicationModal = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let selectedRelays = $state<Set<string>>(new Set());
$effect(() => {
// Initialize selected relays with thread publish relays
const defaultRelays = relayManager.getThreadPublishRelays();
selectedRelays = new Set(defaultRelays);
});
function addTopic() {
if (topicInput.trim() && topics.length < 3) {
topics = [...topics, topicInput.trim()];
topicInput = '';
}
}
function removeTopic(index: number) {
topics = topics.filter((_, i) => i !== index);
}
async function publish() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to create a thread');
return;
}
if (!title.trim() || !content.trim()) {
alert('Title and content are required');
return;
}
publishing = true;
try {
const tags: string[][] = [['title', title]];
topics.forEach((topic) => tags.push(['t', topic]));
if (includeClientTag) {
tags.push(['client', 'Aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 11,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const signed = await sessionManager.signEvent(event);
const result = await nostrClient.publish(signed, {
relays: Array.from(selectedRelays)
});
// Show publication status modal
publicationResults = result;
showPublicationModal = true;
if (result.success.length > 0) {
// Reset form on success
title = '';
content = '';
topics = [];
}
} catch (error) {
console.error('Error publishing thread:', error);
alert('Error publishing thread');
} finally {
publishing = false;
}
}
</script>
<form onsubmit={(e) => { e.preventDefault(); publish(); }} class="create-thread-form">
<div class="mb-4">
<label for="title" class="block mb-2 text-fog-text dark:text-fog-dark-text">Title</label>
<input
id="title"
type="text"
bind:value={title}
class="w-full p-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
required
/>
</div>
<div class="mb-4">
<label for="content" class="block mb-2 text-fog-text dark:text-fog-dark-text">Content</label>
<textarea
id="content"
bind:value={content}
class="w-full p-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
rows="10"
required
></textarea>
</div>
<div class="mb-4">
<label for="topics" class="block mb-2 text-fog-text dark:text-fog-dark-text">Topics (max 3)</label>
<div class="flex gap-2 mb-2">
<input
id="topics"
type="text"
bind:value={topicInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTopic())}
class="flex-1 p-2 border border-fog-border bg-fog-post text-fog-text"
disabled={topics.length >= 3}
/>
<button type="button" onclick={addTopic} disabled={topics.length >= 3}>
Add
</button>
</div>
<div class="flex gap-2 flex-wrap">
{#each topics as topic, i}
<span class="bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">
{topic}
<button type="button" onclick={() => removeTopic(i)} class="ml-2">×</button>
</span>
{/each}
</div>
</div>
<div class="mb-4">
<label>
<input type="checkbox" bind:checked={includeClientTag} />
Include client tag
</label>
</div>
<div class="mb-4">
<h3 class="block mb-2 text-fog-text dark:text-fog-dark-text font-semibold">Target Relays</h3>
<div
class="border border-fog-border dark:border-fog-dark-border rounded p-3 bg-fog-post dark:bg-fog-dark-post max-h-48 overflow-y-auto"
role="group"
aria-label="Target Relays"
>
{#each Array.from(selectedRelays) as relay}
<label class="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={true}
onchange={(e) => {
if (!e.currentTarget.checked) {
const newSet = new Set(selectedRelays);
newSet.delete(relay);
selectedRelays = newSet;
}
}}
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">{relay}</span>
</label>
{/each}
{#if selectedRelays.size === 0}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">No relays selected. At least one relay is required.</p>
{/if}
</div>
</div>
<button type="submit" disabled={publishing || selectedRelays.size === 0} class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white hover:opacity-90 disabled:opacity-50 transition-colors rounded">
{publishing ? 'Publishing...' : 'Create Thread'}
</button>
</form>
<PublicationStatusModal bind:open={showPublicationModal} bind:results={publicationResults} />
<style>
.create-thread-form {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
</style>

23
src/lib/services/cache/event-cache.ts vendored

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
*/
import { getDB } from './indexeddb-store.js';
import { indexEvent } from './search-index.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface CachedEvent extends NostrEvent {
@ -20,16 +19,6 @@ export async function cacheEvent(event: NostrEvent): Promise<void> { @@ -20,16 +19,6 @@ export async function cacheEvent(event: NostrEvent): Promise<void> {
cached_at: Date.now()
};
await db.put('events', cached);
// Index for search (only for events with content)
if (event.content) {
try {
await indexEvent(event.id, event.content);
} catch (error) {
// Don't fail caching if indexing fails
console.error('Error indexing event for search:', error);
}
}
}
/**
@ -46,18 +35,6 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> { @@ -46,18 +35,6 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
await tx.store.put(cached);
}
await tx.done;
// Index events for search (in background)
for (const event of events) {
if (event.content) {
try {
await indexEvent(event.id, event.content);
} catch (error) {
// Don't fail caching if indexing fails
console.error('Error indexing event for search:', error);
}
}
}
}
/**

46
src/lib/services/cache/search-index.ts vendored

@ -1,46 +0,0 @@ @@ -1,46 +0,0 @@
/**
* Full-text search index (deferred implementation)
*/
import { getDB } from './indexeddb-store.js';
/**
* Index event content for search
*/
export async function indexEvent(eventId: string, content: string): Promise<void> {
// Placeholder - full implementation would:
// 1. Tokenize content
// 2. Create inverted index
// 3. Store in IndexedDB
const db = await getDB();
await db.put('search', {
id: eventId,
content: content.toLowerCase()
});
}
/**
* Search events by query
*/
export async function searchEvents(query: string, limit: number = 50): Promise<string[]> {
// Placeholder - full implementation would:
// 1. Tokenize query
// 2. Look up in inverted index
// 3. Rank results
// 4. Return event IDs
const db = await getDB();
const results: string[] = [];
const lowerQuery = query.toLowerCase();
const tx = db.transaction('search', 'readonly');
for await (const cursor of tx.store.iterate()) {
if (results.length >= limit) break;
const content = (cursor.value as { content: string }).content;
if (content.includes(lowerQuery)) {
results.push(cursor.key as string);
}
}
await tx.done;
return results;
}

8
src/lib/services/event-filter.ts

@ -3,24 +3,24 @@ @@ -3,24 +3,24 @@
* Handles content filtering, mute lists, and NSFW detection
*/
import type { NostrEvent } from '../../types/nostr.js';
import type { NostrEvent } from '../types/nostr.js';
import { getMuteList } from './nostr/auth-handler.js';
/**
* Check if event should be hidden (content filtering + mute list)
*/
export function shouldHideEvent(event: NostrEvent): boolean {
// Check mute list
// Check mute list
const muteList = getMuteList();
if (muteList.has(event.pubkey)) return true;
// Check for content-warning or sensitive tags
const hasContentWarning = event.tags.some((t) => t[0] === 'content-warning' || t[0] === 'sensitive');
const hasContentWarning = event.tags.some((t: string[]) => t[0] === 'content-warning' || t[0] === 'sensitive');
if (hasContentWarning) return true;
// Check for #NSFW in content or tags
const content = event.content.toLowerCase();
const hasNSFW = content.includes('#nsfw') || event.tags.some((t) => t[1]?.toLowerCase() === 'nsfw');
const hasNSFW = content.includes('#nsfw') || event.tags.some((t: string[]) => t[1]?.toLowerCase() === 'nsfw');
if (hasNSFW) return true;
return false;

126
src/lib/services/keyboard-shortcuts.ts

@ -1,126 +0,0 @@ @@ -1,126 +0,0 @@
/**
* Global keyboard shortcuts handler
* Handles j/k navigation, r reply, z zap, / search, etc.
*/
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
handler: (e: KeyboardEvent) => void;
description?: string;
}
class KeyboardShortcutsManager {
private shortcuts: Map<string, KeyboardShortcut> = new Map();
private enabled = true;
/**
* Register a keyboard shortcut
*/
register(shortcut: KeyboardShortcut): () => void {
const key = this.getKeyString(shortcut);
this.shortcuts.set(key, shortcut);
// Return unregister function
return () => {
this.shortcuts.delete(key);
};
}
/**
* Unregister a keyboard shortcut
*/
unregister(key: string, modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }): void {
const keyString = this.getKeyString({ key, ...modifiers, handler: () => {} });
this.shortcuts.delete(keyString);
}
/**
* Enable/disable shortcuts
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
/**
* Check if shortcuts are enabled
*/
isEnabled(): boolean {
return this.enabled;
}
/**
* Handle keyboard event
*/
handleKeydown(e: KeyboardEvent): void {
if (!this.enabled) return;
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
// Allow / for search even in inputs
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
// Let it through
} else {
return;
}
}
const keyString = this.getKeyString({
key: e.key.toLowerCase(),
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
handler: () => {}
});
const shortcut = this.shortcuts.get(keyString);
if (shortcut) {
e.preventDefault();
e.stopPropagation();
shortcut.handler(e);
}
}
/**
* Get key string for shortcut lookup
*/
private getKeyString(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.ctrl || shortcut.meta) parts.push('ctrl');
if (shortcut.shift) parts.push('shift');
if (shortcut.alt) parts.push('alt');
parts.push(shortcut.key.toLowerCase());
return parts.join('+');
}
/**
* Initialize global keyboard handler
*/
initialize(): void {
if (typeof window === 'undefined') return;
const handler = (e: KeyboardEvent) => this.handleKeydown(e);
window.addEventListener('keydown', handler);
// Return cleanup function
return () => {
window.removeEventListener('keydown', handler);
};
}
}
export const keyboardShortcuts = new KeyboardShortcutsManager();
// Initialize on module load (browser only)
if (typeof window !== 'undefined') {
keyboardShortcuts.initialize();
}

50
src/lib/services/security/bech32-utils.ts

@ -1,50 +0,0 @@ @@ -1,50 +0,0 @@
/**
* Bech32 utilities for NIP-19 encoding/decoding
*/
export interface DecodedBech32 {
type: 'npub' | 'nsec' | 'note' | 'nevent' | 'naddr' | 'nprofile';
data: Uint8Array;
relay?: string;
}
/**
* Decode a bech32 string (simplified - full implementation would use bech32 library)
* This is a placeholder - in production, use a proper bech32 library
*/
export function decodeBech32(bech32: string): DecodedBech32 | null {
try {
const prefix = bech32.split('1')[0];
if (!prefix) return null;
// Basic validation - full implementation needed
if (prefix === 'npub' || prefix === 'nsec' || prefix === 'note') {
return {
type: prefix as 'npub' | 'nsec' | 'note',
data: new Uint8Array(32) // Placeholder
};
}
return null;
} catch {
return null;
}
}
/**
* Encode data to bech32 format
*/
export function encodeBech32(type: string, data: Uint8Array, relay?: string): string {
// Placeholder - full implementation needed with bech32 library
// For now, return hex representation
return `${type}1${Array.from(data)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')}`;
}
/**
* Validate bech32 string format
*/
export function isValidBech32(bech32: string): boolean {
return /^(npub|nsec|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32);
}

55
src/lib/services/security/event-validator.ts

@ -1,55 +0,0 @@ @@ -1,55 +0,0 @@
/**
* Event validation utilities
*/
import type { NostrEvent } from '../../types/nostr.js';
/**
* Validate event structure
*/
export function isValidEvent(event: unknown): event is NostrEvent {
if (!event || typeof event !== 'object') return false;
const e = event as Record<string, unknown>;
return (
typeof e.kind === 'number' &&
typeof e.pubkey === 'string' &&
typeof e.created_at === 'number' &&
typeof e.content === 'string' &&
typeof e.id === 'string' &&
typeof e.sig === 'string' &&
Array.isArray(e.tags) &&
e.pubkey.length === 64 &&
e.id.length === 64 &&
e.sig.length === 128
);
}
/**
* Check if event has required tags for a kind
*/
export function hasRequiredTags(event: NostrEvent, kind: number): boolean {
switch (kind) {
case 0:
// Kind 0 can have tags or JSON content
return true;
case 11:
// Thread - should have title tag
return true;
case 1111:
// Comment - should have K and E tags
return event.tags.some((t) => t[0] === 'K' || t[0] === 'E');
default:
return true;
}
}
/**
* Validate event signature (placeholder - would need crypto library)
*/
export function isValidSignature(event: NostrEvent): boolean {
// Placeholder - full implementation would verify signature
// using secp256k1 cryptography
return event.sig.length === 128;
}

20
src/routes/+layout.svelte

@ -1,12 +1,8 @@ @@ -1,12 +1,8 @@
<script lang="ts">
import '../app.css';
import { sessionManager } from '../lib/services/auth/session-manager.js';
import { keyboardShortcuts } from '../lib/services/keyboard-shortcuts.js';
import SearchModal from '../lib/components/modals/SearchModal.svelte';
import { onMount } from 'svelte';
let showSearch = $state(false);
// Restore session on app load
onMount(async () => {
try {
@ -14,23 +10,7 @@ @@ -14,23 +10,7 @@
} catch (error) {
console.error('Failed to restore session:', error);
}
// Register search shortcut
keyboardShortcuts.register({
key: '/',
handler: (e) => {
// Don't open if already in an input
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
showSearch = true;
},
description: 'Open search'
});
});
</script>
<slot />
<SearchModal open={showSearch} onClose={() => (showSearch = false)} />

28
src/routes/+page.svelte

@ -1,23 +1,9 @@ @@ -1,23 +1,9 @@
<script lang="ts">
import Header from '../lib/components/layout/Header.svelte';
import ThreadList from '../lib/modules/threads/ThreadList.svelte';
import CreateThreadForm from '../lib/modules/threads/CreateThreadForm.svelte';
import { sessionManager, type UserSession } from '../lib/services/auth/session-manager.js';
import { nostrClient } from '../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
let showCreateForm = $state(false);
let currentSession = $state<UserSession | null>(sessionManager.session.value);
let isLoggedIn = $derived(currentSession !== null);
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session: UserSession | null) => {
currentSession = session;
});
return unsubscribe;
});
onMount(async () => {
await nostrClient.initialize();
});
@ -31,22 +17,8 @@ @@ -31,22 +17,8 @@
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p>
</div>
{#if isLoggedIn}
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
>
{showCreateForm ? 'Cancel' : 'Create Thread'}
</button>
{/if}
</div>
{#if showCreateForm}
<div class="create-thread-form mb-4">
<CreateThreadForm />
</div>
{/if}
<ThreadList />
</main>

2
svelte.config.js

@ -8,7 +8,7 @@ const config = { @@ -8,7 +8,7 @@ const config = {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
fallback: '200.html',
precompress: false,
strict: true
}),

Loading…
Cancel
Save