Browse Source

implement advanced codemirror editor, with grammar check and translation

imwald
Silberengel 2 weeks ago
parent
commit
00ee5bc391
  1. 6
      Dockerfile
  2. 26
      PROXY_SETUP.md
  3. 271
      package-lock.json
  4. 12
      package.json
  5. 8
      scripts/build-and-push-prod.sh
  6. 392
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  7. 52
      src/components/Embedded/EmbeddedNote.tsx
  8. 1
      src/components/NoteList/index.tsx
  9. 58
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  10. 85
      src/components/PostEditor/PostContent.tsx
  11. 26
      src/components/PostEditor/PostTextarea/index.tsx
  12. 6
      src/components/ZapStreamLiveEventEmbed/index.tsx
  13. 13
      src/constants.ts
  14. 17
      src/i18n/locales/de.ts
  15. 17
      src/i18n/locales/en.ts
  16. 6
      src/lib/advanced-event-lab-kinds.ts
  17. 15
      src/lib/advanced-event-lab-slice.test.ts
  18. 50
      src/lib/advanced-event-lab-slice.ts
  19. 54
      src/lib/languagetool-client.ts
  20. 62
      src/lib/languagetool-cm-linter.ts
  21. 12
      src/lib/languagetool-language-order.test.ts
  22. 132
      src/lib/languagetool-language-order.ts
  23. 9
      src/lib/live-activities.ts
  24. 18
      src/lib/read-aloud-translation-override.ts
  25. 6
      src/lib/read-aloud.ts
  26. 26
      src/lib/tiptap-plaintext.test.ts
  27. 14
      src/lib/tiptap.ts
  28. 67
      src/lib/translate-client.ts
  29. 10
      vite.config.ts

6
Dockerfile

@ -7,6 +7,12 @@ ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER}
ARG VITE_READ_ALOUD_TTS_URL ARG VITE_READ_ALOUD_TTS_URL
ENV VITE_READ_ALOUD_TTS_URL=${VITE_READ_ALOUD_TTS_URL} ENV VITE_READ_ALOUD_TTS_URL=${VITE_READ_ALOUD_TTS_URL}
ARG VITE_LANGUAGE_TOOL_URL
ENV VITE_LANGUAGE_TOOL_URL=${VITE_LANGUAGE_TOOL_URL}
ARG VITE_TRANSLATE_URL
ENV VITE_TRANSLATE_URL=${VITE_TRANSLATE_URL}
WORKDIR /app WORKDIR /app
# Copy package files first # Copy package files first

26
PROXY_SETUP.md

@ -119,6 +119,32 @@ Expect **200** and a WAV file. **Local dev:** `npm run dev` proxies `/api/piper-
Rebuild the Imwald image after changing `VITE_READ_ALOUD_TTS_URL`; `Dockerfile` passes `ARG`/`ENV` `VITE_READ_ALOUD_TTS_URL` into `npm run build`. Rebuild the Imwald image after changing `VITE_READ_ALOUD_TTS_URL`; `Dockerfile` passes `ARG`/`ENV` `VITE_READ_ALOUD_TTS_URL` into `npm run build`.
## LanguageTool (same-origin `/api/languagetool`)
The advanced event lab can call **`POST /v2/check`** on a self-hosted [LanguageTool](https://github.com/languagetool-org/languagetool) server. Set **`VITE_LANGUAGE_TOOL_URL=/api/languagetool`** at build time and proxy to your LT HTTP port (default **8010**).
Apache (before the catch-all `ProxyPass /`):
```apache
ProxyPass /api/languagetool http://127.0.0.1:8010
ProxyPassReverse /api/languagetool http://127.0.0.1:8010
```
**Local dev:** `vite.config.ts` proxies `/api/languagetool``http://127.0.0.1:8010` with path rewrite so `/api/languagetool/v2/check` reaches LT’s `/v2/check`.
If `VITE_LANGUAGE_TOOL_URL` is empty, grammar hints in the lab are disabled.
## LibreTranslate (same-origin `/api/translate`)
Optional **`VITE_TRANSLATE_URL=/api/translate`** for `POST /translate` (LibreTranslate-compatible). Example Apache:
```apache
ProxyPass /api/translate http://127.0.0.1:5000
ProxyPassReverse /api/translate http://127.0.0.1:5000
```
**Local dev:** `vite.config.ts` proxies `/api/translate``http://127.0.0.1:5000` with path rewrite.
## Update Proxy Server's ALLOW_ORIGIN ## Update Proxy Server's ALLOW_ORIGIN
Since users access via `https://jumble.imwald.eu`, you need to update the proxy server's `ALLOW_ORIGIN`: Since users access via `https://jumble.imwald.eu`, you need to update the proxy server's `ALLOW_ORIGIN`:

271
package-lock.json generated

@ -10,12 +10,23 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@lezer/markdown": "^1.6.3",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@ -53,6 +64,7 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror-asciidoc": "^2.0.1",
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
@ -1797,6 +1809,169 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@ -3217,6 +3392,84 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lightninglabs/lnc-core": { "node_modules/@lightninglabs/lnc-core": {
"version": "0.3.4-alpha", "version": "0.3.4-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.4-alpha.tgz", "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.4-alpha.tgz",
@ -3272,6 +3525,12 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@noble/ciphers": { "node_modules/@noble/ciphers": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@ -7775,6 +8034,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codemirror-asciidoc": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/codemirror-asciidoc/-/codemirror-asciidoc-2.0.1.tgz",
"integrity": "sha512-h6Xhj+ZsWh/DTNE3xMfRv9edufchsVVwPED7wSGMeEdoYk/UtCZmwRGH0ZZQkr43aNVF3tWGLZJGT+cAeYgUIg==",
"license": "BSD"
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -14258,6 +14523,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",

12
package.json

@ -33,12 +33,23 @@
}, },
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@lezer/markdown": "^1.6.3",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@ -76,6 +87,7 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror-asciidoc": "^2.0.1",
"dataloader": "^2.2.3", "dataloader": "^2.2.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",

8
scripts/build-and-push-prod.sh

@ -8,6 +8,8 @@
# Alias: JUMBLE_PROXY_SERVER_URL (deprecated). Must match the public origin where Apache serves the app. # Alias: JUMBLE_PROXY_SERVER_URL (deprecated). Must match the public origin where Apache serves the app.
# READ_ALOUD_TTS_URL — build-arg VITE_READ_ALOUD_TTS_URL (default /api/piper-tts). # READ_ALOUD_TTS_URL — build-arg VITE_READ_ALOUD_TTS_URL (default /api/piper-tts).
# Same-origin: Apache proxies /api/piper-tts → aitherboard (e.g. :9876). Override only if you use CORS on another host. # Same-origin: Apache proxies /api/piper-tts → aitherboard (e.g. :9876). Override only if you use CORS on another host.
# LANGUAGE_TOOL_URL — build-arg VITE_LANGUAGE_TOOL_URL (default empty). Example: /api/languagetool with Apache → LanguageTool :8010.
# TRANSLATE_URL — build-arg VITE_TRANSLATE_URL (default empty). Example: /api/translate with Apache → LibreTranslate :5000.
set -e set -e
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -23,11 +25,15 @@ IMAGE_MONITOR="silberengel/imwald-jumble-nip66-monitor"
# Override: IMWALD_PROXY_SERVER_URL=https://other.example ./scripts/build-and-push-prod.sh # Override: IMWALD_PROXY_SERVER_URL=https://other.example ./scripts/build-and-push-prod.sh
PROXY_ORIGIN="${IMWALD_PROXY_SERVER_URL:-${JUMBLE_PROXY_SERVER_URL:-https://jumble.imwald.eu}}" PROXY_ORIGIN="${IMWALD_PROXY_SERVER_URL:-${JUMBLE_PROXY_SERVER_URL:-https://jumble.imwald.eu}}"
READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-/api/piper-tts}" READ_ALOUD_TTS_URL="${READ_ALOUD_TTS_URL:-/api/piper-tts}"
LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-}"
TRANSLATE_URL="${TRANSLATE_URL:-}"
echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL)" echo "Building main app (version: $VERSION, VITE_PROXY_SERVER=$PROXY_ORIGIN, VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL, VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL, VITE_TRANSLATE_URL=$TRANSLATE_URL)"
docker build \ docker build \
--build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \ --build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \
--build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \ --build-arg "VITE_READ_ALOUD_TTS_URL=$READ_ALOUD_TTS_URL" \
--build-arg "VITE_LANGUAGE_TOOL_URL=$LANGUAGE_TOOL_URL" \
--build-arg "VITE_TRANSLATE_URL=$TRANSLATE_URL" \
-t "$IMAGE_APP:latest" -t "$IMAGE_APP:$VERSION" . -t "$IMAGE_APP:latest" -t "$IMAGE_APP:$VERSION" .
echo "Building NIP-66 monitor (version: $VERSION)" echo "Building NIP-66 monitor (version: $VERSION)"

392
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -0,0 +1,392 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension } from '@/lib/languagetool-cm-linter'
import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order'
import {
parseLabSlice,
serializeLabSlice,
type AdvancedEventLabSlice
} from '@/lib/advanced-event-lab-slice'
import { isTranslateConfigured, translatePlainText } from '@/lib/translate-client'
import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { json } from '@codemirror/lang-json'
import { StreamLanguage } from '@codemirror/language'
import { asciidoc } from 'codemirror-asciidoc'
import { EditorState, type Extension } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import {
EditorView,
keymap,
lineNumbers,
placeholder as cmPlaceholder
} from '@codemirror/view'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export type AdvancedEventLabDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Snapshot when opening; parent should memoize. */
initial: AdvancedEventLabSlice | null
/** When false, `kind` in JSON is shown but Apply forces `initial.kind`. */
kindEditable?: boolean
markupMode: 'markdown' | 'asciidoc'
/** `i18n.language` for LanguageTool default ordering. */
i18nLanguage?: string
/** When set, user can store translation for read-aloud for this event id. */
contextEventId?: string | null
onApply: (payload: AdvancedEventLabSlice) => void
}
function useDarkModeFlag(): boolean {
const [dark, setDark] = useState(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: false
)
useEffect(() => {
const el = document.documentElement
const obs = new MutationObserver(() => {
setDark(el.classList.contains('dark'))
})
obs.observe(el, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return dark
}
export default function AdvancedEventLabDialog({
open,
onOpenChange,
initial,
kindEditable = true,
markupMode,
i18nLanguage,
contextEventId,
onApply
}: AdvancedEventLabDialogProps) {
const { t, i18n } = useTranslation()
const dark = useDarkModeFlag()
const markupHost = useRef<HTMLDivElement>(null)
const jsonHost = useRef<HTMLDivElement>(null)
const markupView = useRef<EditorView | null>(null)
const jsonView = useRef<EditorView | null>(null)
const sliceRef = useRef<AdvancedEventLabSlice | null>(null)
const syncing = useRef(false)
const ltList = useMemo(
() => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language),
[i18nLanguage, i18n.language]
)
const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US')
const [jsonError, setJsonError] = useState<string | null>(null)
const [translateTarget, setTranslateTarget] = useState('en')
useEffect(() => {
if (open) {
setLtLang(ltList[0] ?? 'en-US')
}
}, [open, ltList])
const destroyEditors = useCallback(() => {
markupView.current?.destroy()
jsonView.current?.destroy()
markupView.current = null
jsonView.current = null
}, [])
useEffect(() => {
if (!open || !initial) {
destroyEditors()
return
}
const mkEl = markupHost.current
const jsEl = jsonHost.current
if (!mkEl || !jsEl) return
destroyEditors()
const baseSlice: AdvancedEventLabSlice = {
kind: initial.kind,
content: initial.content,
tags: initial.tags.map((row) => [...row])
}
sliceRef.current = baseSlice
const markupLang: Extension =
markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown()
const mkExtensions: Extension[] = [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
cmPlaceholder(t('Advanced lab markup placeholder')),
markupLang,
EditorView.theme({
'&': { maxHeight: '100%' },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged || syncing.current) return
const content = update.state.doc.toString()
const s = sliceRef.current
if (!s) return
s.content = content
const jv = jsonView.current
if (!jv) return
const nextJson = serializeLabSlice({
kind: kindEditable ? s.kind : (initial?.kind ?? s.kind),
content: s.content,
tags: s.tags
})
if (jv.state.doc.toString() === nextJson) return
syncing.current = true
jv.dispatch({
changes: { from: 0, to: jv.state.doc.length, insert: nextJson },
selection: { anchor: 0 }
})
syncing.current = false
})
]
if (isLanguageToolConfigured()) {
mkExtensions.push(languageToolLintExtension(ltLang, 450))
}
if (dark) mkExtensions.push(oneDark)
const jsonExtensions: Extension[] = [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
json(),
cmPlaceholder(t('Advanced lab json placeholder')),
EditorView.theme({
'&': { maxHeight: '100%' },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged || syncing.current) return
const parsed = parseLabSlice(update.state.doc.toString())
if (!parsed.ok) {
setJsonError(parsed.error)
return
}
setJsonError(null)
const fixedKind = kindEditable ? parsed.value.kind : (initial.kind ?? parsed.value.kind)
const next: AdvancedEventLabSlice = {
kind: fixedKind,
content: parsed.value.content,
tags: parsed.value.tags
}
sliceRef.current = next
const mv = markupView.current
if (mv && mv.state.doc.toString() !== next.content) {
syncing.current = true
mv.dispatch({
changes: { from: 0, to: mv.state.doc.length, insert: next.content },
selection: { anchor: Math.min(mv.state.selection.main.anchor, next.content.length) }
})
syncing.current = false
}
})
]
if (dark) jsonExtensions.push(oneDark)
const mkState = EditorState.create({
doc: baseSlice.content,
extensions: mkExtensions
})
const jsState = EditorState.create({
doc: serializeLabSlice({
kind: baseSlice.kind,
content: baseSlice.content,
tags: baseSlice.tags
}),
extensions: jsonExtensions
})
markupView.current = new EditorView({ state: mkState, parent: mkEl })
jsonView.current = new EditorView({ state: jsState, parent: jsEl })
return destroyEditors
}, [
open,
initial,
markupMode,
ltLang,
kindEditable,
dark,
destroyEditors,
t
])
const handleApply = () => {
const raw = jsonView.current?.state.doc.toString() ?? ''
const parsed = parseLabSlice(raw)
if (!parsed.ok) {
toast.error(parsed.error)
return
}
const kind = kindEditable ? parsed.value.kind : (initial?.kind ?? parsed.value.kind)
const payload: AdvancedEventLabSlice = {
kind,
content: parsed.value.content,
tags: parsed.value.tags
}
onApply(payload)
onOpenChange(false)
}
const handleTranslate = async () => {
if (!isTranslateConfigured()) {
toast.message(t('Advanced lab translate not configured'))
return
}
const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? ''
if (!text.trim()) return
try {
const out = await translatePlainText(text, translateTarget.trim() || 'en')
if (!markupView.current) return
syncing.current = true
markupView.current.dispatch({
changes: { from: 0, to: markupView.current.state.doc.length, insert: out }
})
syncing.current = false
const s = sliceRef.current
if (s) {
s.content = out
const jv = jsonView.current
if (jv) {
const nextJson = serializeLabSlice({
kind: kindEditable ? s.kind : (initial?.kind ?? s.kind),
content: out,
tags: s.tags
})
syncing.current = true
jv.dispatch({ changes: { from: 0, to: jv.state.doc.length, insert: nextJson } })
syncing.current = false
}
}
toast.success(t('Advanced lab translate done'))
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
}
}
const handleReadAloudBuffer = () => {
if (!contextEventId) return
const text = markupView.current?.state.doc.toString().trim() ?? ''
if (!text) return
setReadAloudTranslationForEvent(contextEventId, text)
toast.success(t('Advanced lab read aloud buffer set'))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="z-[250] max-h-[92vh] w-[min(96vw,72rem)] flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b">
<DialogTitle>{t('Advanced event lab')}</DialogTitle>
<DialogDescription className="text-left">
{t('Advanced lab hint')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 px-4 py-2 border-b shrink-0 flex-wrap">
<div className="flex flex-wrap items-end gap-3">
{isLanguageToolConfigured() ? (
<div className="space-y-1 min-w-[10rem]">
<Label htmlFor="lt-lang">{t('Advanced lab grammar language')}</Label>
<Select value={ltLang} onValueChange={setLtLang}>
<SelectTrigger id="lt-lang" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
{ltList.map((code) => (
<SelectItem key={code} value={code}>
{code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{isTranslateConfigured() ? (
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label>
<Input
id="tr-tgt"
className="w-24 font-mono text-sm"
value={translateTarget}
onChange={(e) => setTranslateTarget(e.target.value)}
placeholder="en"
/>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}>
{t('Advanced lab translate')}
</Button>
</div>
) : null}
{contextEventId && isTranslateConfigured() ? (
<Button type="button" variant="outline" size="sm" onClick={handleReadAloudBuffer}>
{t('Advanced lab use translation read aloud')}
</Button>
) : null}
</div>
{jsonError ? (
<p className="text-sm text-destructive" role="alert">
{jsonError}
</p>
) : null}
</div>
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-2 px-4 py-2 overflow-hidden">
<div className="flex flex-col min-h-0 gap-1">
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab markup')}</span>
<div
ref={markupHost}
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20"
/>
</div>
<div className="flex flex-col min-h-0 gap-1">
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab tags JSON')}</span>
<div
ref={jsonHost}
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20"
/>
</div>
</div>
<DialogFooter className="shrink-0 px-4 py-3 border-t gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button type="button" onClick={handleApply} disabled={Boolean(jsonError)}>
{t('Apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

52
src/components/Embedded/EmbeddedNote.tsx

@ -1,6 +1,8 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import ExternalLink from '@/components/ExternalLink'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants' import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS, liveActivityKindsEnabledInPicker } from '@/lib/live-activities'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -8,6 +10,7 @@ import { cn } from '@/lib/utils'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Event, nip19 } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
@ -162,6 +165,24 @@ function EmbeddedNoteInvalid({
) )
} }
function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
const trimmed = noteId.trim()
const njump = `https://njump.me/${trimmed}`
return (
<div
className={cn('not-prose max-w-full rounded-lg border p-3 text-left', className)}
onClick={(e) => e.stopPropagation()}
data-live-embed-suppressed
>
<p className="mb-2 text-xs text-muted-foreground">{t('liveStreamEmbedSuppressed')}</p>
<ExternalLink url={njump} className="text-sm break-all" />
<ClientSelect className="mt-2 w-full" originalNoteId={trimmed || undefined} />
</div>
)
}
function EmbeddedNoteContent({ function EmbeddedNoteContent({
noteId, noteId,
className, className,
@ -173,7 +194,22 @@ function EmbeddedNoteContent({
containingEvent?: Event containingEvent?: Event
showFull?: boolean showFull?: boolean
}) { }) {
const { event, isFetching } = useFetchEvent(noteId) const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults()
const allowLiveEmbeds = liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)
const naddrTargetsLiveActivityOnly = useMemo(() => {
try {
const dec = nip19.decode(noteId.trim())
if (dec.type !== 'naddr') return false
return LIVE_ACTIVITY_KINDS.includes(dec.data.kind as (typeof LIVE_ACTIVITY_KINDS)[number])
} catch {
return false
}
}, [noteId])
const skipLiveActivityFetch = naddrTargetsLiveActivityOnly && !allowLiveEmbeds
const { event, isFetching } = useFetchEvent(skipLiveActivityFetch ? undefined : noteId)
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined) const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined)
const [isRetrying, setIsRetrying] = useState(false) const [isRetrying, setIsRetrying] = useState(false)
const [retryCount, setRetryCount] = useState(0) const [retryCount, setRetryCount] = useState(0)
@ -181,6 +217,7 @@ function EmbeddedNoteContent({
// If the first fetch fails, try a force retry (max 3 attempts) // If the first fetch fails, try a force retry (max 3 attempts)
useEffect(() => { useEffect(() => {
if (skipLiveActivityFetch) return
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) { if (!isFetching && !event && !isRetrying && retryCount < maxRetries) {
setIsRetrying(true) setIsRetrying(true)
setRetryCount(prev => prev + 1) setRetryCount(prev => prev + 1)
@ -203,7 +240,11 @@ function EmbeddedNoteContent({
setIsRetrying(false) setIsRetrying(false)
}) })
} }
}, [isFetching, event, noteId, isRetrying, retryCount]) }, [isFetching, event, noteId, isRetrying, retryCount, skipLiveActivityFetch])
if (skipLiveActivityFetch) {
return <SuppressedLiveStreamEmbed noteId={noteId} className={className} />
}
const finalEvent = event || retryEvent const finalEvent = event || retryEvent
const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries) const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
@ -216,6 +257,13 @@ function EmbeddedNoteContent({
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} containingEvent={containingEvent} /> return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} containingEvent={containingEvent} />
} }
if (
!allowLiveEmbeds &&
LIVE_ACTIVITY_KINDS.includes(finalEvent.kind as (typeof LIVE_ACTIVITY_KINDS)[number])
) {
return <SuppressedLiveStreamEmbed noteId={noteId} className={className} />
}
// Check if this event has bookstr tags (at least "book" tag) // Check if this event has bookstr tags (at least "book" tag)
const bookMetadata = extractBookMetadata(finalEvent) const bookMetadata = extractBookMetadata(finalEvent)
const hasBookstrTags = !!bookMetadata.book const hasBookstrTags = !!bookMetadata.book

1
src/components/NoteList/index.tsx

@ -1002,6 +1002,7 @@ const NoteList = forwardRef(
showCount, showCount,
shouldHideEvent, shouldHideEvent,
showKinds, showKinds,
effectiveShowKinds,
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111, showKind1111,

58
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -42,10 +42,13 @@ import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import type { TDraftEvent } from '@/types' import type { TDraftEvent } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { AlertTriangle, Plus, Trash2 } from 'lucide-react' import { AlertTriangle, Code2, Plus, Trash2 } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds'
function normalizeTagRow(row: string[]): string[] | null { function normalizeTagRow(row: string[]): string[] | null {
const trimmed = row.map((c) => c.trim()) const trimmed = row.map((c) => c.trim())
@ -128,13 +131,15 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const isCreate = mode === 'create' const isCreate = mode === 'create'
const sourceEvent = !isCreate ? props.sourceEvent : null const sourceEvent = !isCreate ? props.sourceEvent : null
const { t } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(() => sourceEvent?.content ?? '') const [content, setContent] = useState(() => sourceEvent?.content ?? '')
const [createKindInput, setCreateKindInput] = useState('1') const [createKindInput, setCreateKindInput] = useState('1')
const [tagRows, setTagRows] = useState<string[][]>([['', '']]) const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit') const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false) const [publishing, setPublishing] = useState(false)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const [advancedLabInitial, setAdvancedLabInitial] = useState<AdvancedEventLabSlice | null>(null)
const prevOpenRef = useRef(false) const prevOpenRef = useRef(false)
const parsedCreateKind = useMemo( const parsedCreateKind = useMemo(
@ -346,7 +351,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
? t('Clone or fork this event') ? t('Clone or fork this event')
: t('Create custom event') : t('Create custom event')
const openAdvancedLab = useCallback(() => {
if (isCreate && parsedCreateKind === null) return
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
setAdvancedLabInitial({
kind: k,
content,
tags: normalizedTags.map((row) => [...row])
})
setAdvancedLabOpen(true)
}, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags])
const labKind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent?.kind ?? 0
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] max-w-3xl flex flex-col gap-0 p-0 overflow-hidden"> <DialogContent className="max-h-[90vh] w-[95vw] max-w-3xl flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14"> <DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14">
@ -360,10 +379,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
<div className="flex-1 min-h-0 flex flex-col px-6"> <div className="flex-1 min-h-0 flex flex-col px-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0 gap-2"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0 gap-2">
<TabsList className="w-auto justify-start shrink-0"> <TabsList className="w-auto justify-start shrink-0 flex flex-wrap gap-1">
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger> <TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger> <TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger> <TabsTrigger value="json">{t('Json')}</TabsTrigger>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 ml-1"
onClick={openAdvancedLab}
title={t('Advanced event lab')}
>
<Code2 className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">{t('Advanced event lab')}</span>
</Button>
</TabsList> </TabsList>
<TabsContent value="edit" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"> <TabsContent value="edit" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
@ -546,5 +576,27 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AdvancedEventLabDialog
open={advancedLabOpen}
onOpenChange={(o) => {
setAdvancedLabOpen(o)
if (!o) setAdvancedLabInitial(null)
}}
initial={advancedLabInitial}
kindEditable={isCreate}
markupMode={isAsciidocMarkupKind(labKind) ? 'asciidoc' : 'markdown'}
i18nLanguage={i18n.language}
contextEventId={!isCreate && sourceEvent ? sourceEvent.id : null}
onApply={(payload) => {
setContent(payload.content)
setTagRows(payload.tags.length > 0 ? payload.tags.map((r) => [...r]) : [['', '']])
if (isCreate) {
setCreateKindInput(String(payload.kind))
}
setAdvancedLabOpen(false)
setAdvancedLabInitial(null)
}}
/>
</>
) )
} }

85
src/components/PostEditor/PostContent.tsx

@ -35,6 +35,7 @@ import {
createCitationHardcopyDraftEvent, createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent, createCitationPromptDraftEvent,
applyImwaldAttributionTags, applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto mergeUploadImetaTagsInto
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants' import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants'
@ -71,7 +72,8 @@ import {
Music, Music,
Video, Video,
Film, Film,
Laugh Laugh,
Code2
} from 'lucide-react' } from 'lucide-react'
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta' import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta'
@ -119,6 +121,9 @@ import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAnd
import Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog'
import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds'
export default function PostContent({ export default function PostContent({
defaultContent = '', defaultContent = '',
@ -142,7 +147,7 @@ export default function PostContent({
/** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */ /** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */
discussionDynamicTopics?: TDiscussionDynamicTopics | null discussionDynamicTopics?: TDiscussionDynamicTopics | null
}) { }) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { userGroups } = useGroupList() const { userGroups } = useGroupList()
const { feedInfo } = useFeed() const { feedInfo } = useFeed()
@ -203,6 +208,9 @@ export default function PostContent({
) )
const [text, setText] = useState('') const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null) const textareaRef = useRef<TPostTextareaHandle>(null)
const labTagOverrideRef = useRef<string[][] | null>(null)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const [advancedLabInitial, setAdvancedLabInitial] = useState<AdvancedEventLabSlice | null>(null)
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null) const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null)
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
const [uploadProgresses, setUploadProgresses] = useState< const [uploadProgresses, setUploadProgresses] = useState<
@ -1074,6 +1082,14 @@ export default function PostContent({
t t
]) ])
const applyLabTagOverrideToDraft = useCallback((draft: TDraftEvent): TDraftEvent => {
if (!labTagOverrideRef.current) return draft
const tags = labTagOverrideRef.current.map((r) => [...r])
labTagOverrideRef.current = null
mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(draft.content))
return { ...draft, tags }
}, [])
// Function to generate draft event JSON for preview // Function to generate draft event JSON for preview
const getDraftEventJson = useCallback(async (): Promise<string> => { const getDraftEventJson = useCallback(async (): Promise<string> => {
if (!pubkey) { if (!pubkey) {
@ -1083,13 +1099,35 @@ export default function PostContent({
try { try {
// Clean tracking parameters from URLs in the post content // Clean tracking parameters from URLs in the post content
const cleanedText = rewritePlainTextHttpUrls(text) const cleanedText = rewritePlainTextHttpUrls(text)
const draftEvent = await createDraftEvent(cleanedText) let draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2) return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2)
} catch (error) { } catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
} }
}, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag]) }, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag, applyLabTagOverrideToDraft])
const handleOpenAdvancedLab = useCallback(async () => {
await checkLogin(async () => {
if (!pubkey) {
toast.error(t('Log in to publish'))
return
}
try {
const cleanedText = rewritePlainTextHttpUrls(text)
const d = await createDraftEvent(cleanedText)
setAdvancedLabInitial({
kind: d.kind,
content: d.content,
tags: (d.tags ?? []).map((row: string[]) => [...row])
})
setAdvancedLabOpen(true)
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
}
})
}, [checkLogin, pubkey, text, createDraftEvent, t])
const post = async (e?: React.MouseEvent) => { const post = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
@ -1162,6 +1200,7 @@ export default function PostContent({
// Create draft event using shared function // Create draft event using shared function
draftEvent = await createDraftEvent(cleanedText) draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
const publishSuccessMessage = parentEvent const publishSuccessMessage = parentEvent
? t('Reply published') ? t('Reply published')
@ -2855,8 +2894,7 @@ export default function PostContent({
addClientTag={addClientTag} addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
headerActions={ headerActions={(() => {
!parentEvent ? (() => {
const ActiveIcon = const ActiveIcon =
isLongFormArticle ? FileText : isLongFormArticle ? FileText :
isWikiArticle ? FileText : isWikiArticle ? FileText :
@ -2886,6 +2924,19 @@ export default function PostContent({
t('Short Note') t('Short Note')
return ( return (
<div className="flex flex-wrap items-center justify-end gap-1.5"> <div className="flex flex-wrap items-center justify-end gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
onClick={() => void handleOpenAdvancedLab()}
title={t('Advanced event lab')}
>
<Code2 className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[9rem] truncate">{t('Advanced event lab')}</span>
</Button>
{!parentEvent ? (
<>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -3055,9 +3106,11 @@ export default function PostContent({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
) : null}
</div> </div>
) )
})() : undefined })()
} }
/> />
{isDiscussionThread && !parentEvent && ( {isDiscussionThread && !parentEvent && (
@ -3482,6 +3535,22 @@ export default function PostContent({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AdvancedEventLabDialog
open={advancedLabOpen}
onOpenChange={(o) => {
setAdvancedLabOpen(o)
if (!o) setAdvancedLabInitial(null)
}}
initial={advancedLabInitial}
kindEditable={false}
markupMode={isAsciidocMarkupKind(getDeterminedKind) ? 'asciidoc' : 'markdown'}
i18nLanguage={i18n.language}
contextEventId={parentEvent?.id ?? null}
onApply={(payload) => {
labTagOverrideRef.current = payload.tags.map((r) => [...r])
textareaRef.current?.setDocumentFromPlainText(payload.content)
}}
/>
<EditOrCloneEventDialog <EditOrCloneEventDialog
open={createCustomEventOpen} open={createCustomEventOpen}
onOpenChange={setCreateCustomEventOpen} onOpenChange={setCreateCustomEventOpen}

26
src/components/PostEditor/PostTextarea/index.tsx

@ -1,5 +1,5 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText } from '@/lib/tiptap' import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
@ -11,7 +11,7 @@ import Paragraph from '@tiptap/extension-paragraph'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import Text from '@tiptap/extension-text' import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state' import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/react' import { Editor, EditorContent, useEditor } from '@tiptap/react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { import {
Dispatch, Dispatch,
@ -42,6 +42,8 @@ export type TPostTextareaHandle = {
insertEmoji: (emoji: string | TEmoji) => void insertEmoji: (emoji: string | TEmoji) => void
clear: () => void clear: () => void
getText: () => string getText: () => string
/** Replace editor from plain `content` (e.g. advanced lab). Syncs TipTap JSON cache and parent `text`. */
setDocumentFromPlainText: (plain: string) => void
} }
const PostTextarea = forwardRef< const PostTextarea = forwardRef<
@ -121,6 +123,7 @@ const PostTextarea = forwardRef<
const [isLoadingJson, setIsLoadingJson] = useState(false) const [isLoadingJson, setIsLoadingJson] = useState(false)
/** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */ /** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */
const jsonPanelFetchSeq = useRef(0) const jsonPanelFetchSeq = useRef(0)
const editorRef = useRef<Editor | null>(null)
const kindDescription = useMemo(() => getKindDescription(kind), [kind]) const kindDescription = useMemo(() => getKindDescription(kind), [kind])
@ -237,10 +240,13 @@ const PostTextarea = forwardRef<
} }
}) })
editorRef.current = editor
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendText: (text: string, addNewline = false) => { appendText: (text: string, addNewline = false) => {
if (editor) { const ed = editorRef.current
let chain = editor if (ed) {
let chain = ed
.chain() .chain()
.focus() .focus()
.command(({ tr, dispatch }) => { .command(({ tr, dispatch }) => {
@ -260,11 +266,13 @@ const PostTextarea = forwardRef<
} }
}, },
insertText: (text: string) => { insertText: (text: string) => {
const editor = editorRef.current
if (editor) { if (editor) {
editor.chain().focus().insertContent(text).run() editor.chain().focus().insertContent(text).run()
} }
}, },
insertEmoji: (emoji: string | TEmoji) => { insertEmoji: (emoji: string | TEmoji) => {
const editor = editorRef.current
if (editor) { if (editor) {
if (typeof emoji === 'string') { if (typeof emoji === 'string') {
editor.chain().insertContent(emoji).run() editor.chain().insertContent(emoji).run()
@ -277,6 +285,7 @@ const PostTextarea = forwardRef<
} }
}, },
clear: () => { clear: () => {
const editor = editorRef.current
if (editor) { if (editor) {
// Clear the editor content and reset to empty document // Clear the editor content and reset to empty document
editor.chain().clearContent().run() editor.chain().clearContent().run()
@ -286,10 +295,19 @@ const PostTextarea = forwardRef<
} }
}, },
getText: () => { getText: () => {
const editor = editorRef.current
if (editor) { if (editor) {
return editor.getText() return editor.getText()
} }
return '' return ''
},
setDocumentFromPlainText: (plain: string) => {
const editor = editorRef.current
if (!editor) return
const json = plainTextToTipTapDoc(plain)
editor.chain().setContent(json).run()
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON())
setText(parseEditorJsonToText(editor.getJSON()))
} }
})) }))

6
src/components/ZapStreamLiveEventEmbed/index.tsx

@ -1,7 +1,9 @@
import { EmbeddedNote } from '@/components/Embedded/EmbeddedNote' import { EmbeddedNote } from '@/components/Embedded/EmbeddedNote'
import ExternalLink from '@/components/ExternalLink' import ExternalLink from '@/components/ExternalLink'
import { liveActivityKindsEnabledInPicker } from '@/lib/live-activities'
import { naddrFromZapStreamWatchUrl } from '@/lib/zap-stream-url' import { naddrFromZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
/** zap.stream `/naddr1…` → fetch kind 30311 and render as embedded note (LiveEvent), not a full-site iframe. */ /** zap.stream `/naddr1…` → fetch kind 30311 and render as embedded note (LiveEvent), not a full-site iframe. */
@ -16,6 +18,10 @@ export default function ZapStreamLiveEventEmbed({
containingEvent?: Event containingEvent?: Event
showFull?: boolean showFull?: boolean
}) { }) {
const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults()
if (!liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)) {
return <ExternalLink url={url} className={cn('not-prose', className)} />
}
const naddr = naddrFromZapStreamWatchUrl(url) const naddr = naddrFromZapStreamWatchUrl(url)
if (!naddr) { if (!naddr) {
return <ExternalLink url={url} className={cn('not-prose', className)} /> return <ExternalLink url={url} className={cn('not-prose', className)} />

13
src/constants.ts

@ -27,6 +27,19 @@ export const GITREPUBLIC_WEB_BASE_URL = (
export const READ_ALOUD_TTS_URL = export const READ_ALOUD_TTS_URL =
(import.meta.env.VITE_READ_ALOUD_TTS_URL as string | undefined)?.trim() || '' (import.meta.env.VITE_READ_ALOUD_TTS_URL as string | undefined)?.trim() || ''
/**
* Self-hosted LanguageTool HTTP API (same-origin proxy recommended; path is base URL without `/v2/check`).
* Example: `/api/languagetool` proxied to `http://127.0.0.1:8010`. Empty disables grammar hints in the advanced lab.
*/
export const LANGUAGE_TOOL_URL =
(import.meta.env.VITE_LANGUAGE_TOOL_URL as string | undefined)?.trim() || ''
/**
* LibreTranslate-compatible `POST /translate` base (no trailing slash). Empty disables translate actions in the lab.
*/
export const TRANSLATE_URL =
(import.meta.env.VITE_TRANSLATE_URL as string | undefined)?.trim() || ''
/** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */ /** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */
export const HIVETALK_BASE_URL = export const HIVETALK_BASE_URL =
(import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://vanilla.hivetalk.org' (import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://vanilla.hivetalk.org'

17
src/i18n/locales/de.ts

@ -500,6 +500,8 @@ export default {
'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.', 'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.',
'Open in browser': 'Im Browser öffnen', 'Open in browser': 'Im Browser öffnen',
'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)', 'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)',
'liveStreamEmbedSuppressed':
'Der eingebettete Livestream ist ausgeblendet, weil dein Kind-Filter NIP-53-Streams ausschließt. Unten per njump oder anderem Client öffnen.',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.', 'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'liveEvent.hideFromCarousel': 'Im Karussell ausblenden', 'liveEvent.hideFromCarousel': 'Im Karussell ausblenden',
@ -952,6 +954,21 @@ export default {
'See all events hint': 'See all events hint':
'Feed-Anfragen ohne Kind-Filter; alle Event-Arten werden angezeigt (Relay-Limits und andere Regeln gelten weiter). Zum Testen neuer Event-Kinds.', 'Feed-Anfragen ohne Kind-Filter; alle Event-Arten werden angezeigt (Relay-Limits und andere Regeln gelten weiter). Zum Testen neuer Event-Kinds.',
'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.', 'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.',
'Advanced event lab': 'Erweiterter Editor',
'Advanced lab hint':
'Markup und JSON (kind, content, tags) bearbeiten. id, pubkey, sig und created_at werden beim Veröffentlichen gesetzt.',
'Advanced lab markup': 'Markup',
'Advanced lab markup placeholder': 'Notiztext (Markdown oder AsciiDoc)',
'Advanced lab tags JSON': 'Kind, Inhalt und Tags (JSON)',
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Sprache für Grammatikprüfung',
'Advanced lab translate': 'Text übersetzen',
'Advanced lab translation target': 'Zielsprache',
'Advanced lab translate not configured': 'Übersetzungs-URL ist nicht gesetzt (VITE_TRANSLATE_URL).',
'Advanced lab translate done': 'Übersetzung wurde in den Editor eingefügt.',
'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)',
'Advanced lab read aloud buffer set':
'Das nächste Vorlesen dieser Notiz nutzt den aktuellen Text (ggf. nach Übersetzung).',
Apply: 'Anwenden', Apply: 'Anwenden',
Reset: 'Zurücksetzen', Reset: 'Zurücksetzen',
'Share something on this Relay': 'Teile etwas auf diesem Relay', 'Share something on this Relay': 'Teile etwas auf diesem Relay',

17
src/i18n/locales/en.ts

@ -497,6 +497,8 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.', 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.',
'Open in browser': 'Open in browser', 'Open in browser': 'Open in browser',
'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)', 'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)',
'liveStreamEmbedSuppressed':
'Inline live stream is hidden because your kind filter excludes NIP-53 streams. Open via njump or another client below.',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.', 'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.',
'liveEvent.hideFromCarousel': 'Hide from carousel', 'liveEvent.hideFromCarousel': 'Hide from carousel',
@ -953,6 +955,21 @@ export default {
'See all events hint': 'See all events hint':
'Feed requests omit kind filters and every kind is shown (still subject to relay limits and other feed rules). For testing new event kinds.', 'Feed requests omit kind filters and every kind is shown (still subject to relay limits and other feed rules). For testing new event kinds.',
'Use filter hint': 'Only the kinds you select below are requested and shown.', 'Use filter hint': 'Only the kinds you select below are requested and shown.',
'Advanced event lab': 'Advanced editor',
'Advanced lab hint':
'Edit markup and JSON (kind, content, tags). id, pubkey, sig, and created_at are assigned when you publish.',
'Advanced lab markup': 'Markup',
'Advanced lab markup placeholder': 'Note body (Markdown or AsciiDoc)',
'Advanced lab tags JSON': 'Kind, content, and tags (JSON)',
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Grammar check language',
'Advanced lab translate': 'Translate body',
'Advanced lab translation target': 'Target language',
'Advanced lab translate not configured': 'Translation URL is not set (VITE_TRANSLATE_URL).',
'Advanced lab translate done': 'Translation inserted into the editor.',
'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)',
'Advanced lab read aloud buffer set':
'The next read-aloud for this note will use the current body text (translated if you translated first).',
Apply: 'Apply', Apply: 'Apply',
Reset: 'Reset', Reset: 'Reset',
'Share something on this Relay': 'Share something on this Relay', 'Share something on this Relay': 'Share something on this Relay',

6
src/lib/advanced-event-lab-kinds.ts

@ -0,0 +1,6 @@
import { ExtendedKind } from '@/constants'
/** Kinds whose body is AsciiDoc in Imwald (wiki article, publication content). */
export function isAsciidocMarkupKind(kind: number): boolean {
return kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.PUBLICATION_CONTENT
}

15
src/lib/advanced-event-lab-slice.test.ts

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { parseLabSlice, serializeLabSlice } from '@/lib/advanced-event-lab-slice'
describe('parseLabSlice', () => {
it('round-trips', () => {
const v = { kind: 1, content: 'hello', tags: [['e', 'abc'], ['p', 'def']] }
const s = serializeLabSlice(v)
const p = parseLabSlice(s)
expect(p).toEqual({ ok: true, value: v })
})
it('rejects bad kind', () => {
expect(parseLabSlice('{"kind":"x","content":"","tags":[]}').ok).toBe(false)
})
})

50
src/lib/advanced-event-lab-slice.ts

@ -0,0 +1,50 @@
export type AdvancedEventLabSlice = {
kind: number
content: string
tags: string[][]
}
export function serializeLabSlice(slice: AdvancedEventLabSlice): string {
return JSON.stringify(
{
kind: slice.kind,
content: slice.content,
tags: slice.tags
},
null,
2
)
}
export function parseLabSlice(
raw: string
): { ok: true; value: AdvancedEventLabSlice } | { ok: false; error: string } {
let o: unknown
try {
o = JSON.parse(raw)
} catch {
return { ok: false, error: 'Invalid JSON' }
}
if (!o || typeof o !== 'object' || Array.isArray(o)) {
return { ok: false, error: 'Root must be an object' }
}
const rec = o as Record<string, unknown>
if (typeof rec.kind !== 'number' || !Number.isFinite(rec.kind) || !Number.isInteger(rec.kind)) {
return { ok: false, error: '`kind` must be an integer' }
}
if (typeof rec.content !== 'string') {
return { ok: false, error: '`content` must be a string' }
}
if (!Array.isArray(rec.tags)) {
return { ok: false, error: '`tags` must be an array' }
}
const tags: string[][] = []
for (let i = 0; i < rec.tags.length; i++) {
const row = rec.tags[i]
if (!Array.isArray(row) || !row.every((c) => typeof c === 'string')) {
return { ok: false, error: `tags[${i}] must be an array of strings` }
}
tags.push([...row])
}
return { ok: true, value: { kind: rec.kind, content: rec.content, tags } }
}

54
src/lib/languagetool-client.ts

@ -0,0 +1,54 @@
import { LANGUAGE_TOOL_URL } from '@/constants'
import logger from '@/lib/logger'
export type LanguageToolMatch = {
offset: number
length: number
message: string
replacements?: Array<{ value: string }>
rule?: { id?: string; description?: string }
}
export type LanguageToolCheckResponse = {
matches?: LanguageToolMatch[]
software?: { name?: string; version?: string }
language?: { name?: string; code?: string }
}
function checkUrl(): string | null {
const base = LANGUAGE_TOOL_URL.trim().replace(/\/$/u, '')
if (!base) return null
return `${base}/v2/check`
}
export async function languageToolCheck(
text: string,
language: string,
signal?: AbortSignal
): Promise<LanguageToolCheckResponse> {
const url = checkUrl()
if (!url) {
return { matches: [] }
}
const body = new URLSearchParams()
body.set('text', text)
body.set('language', language)
body.set('enabledOnly', 'false')
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal
})
if (!res.ok) {
const errText = await res.text().catch(() => '')
logger.warn('[LanguageTool] HTTP error', { status: res.status, errText: errText.slice(0, 200) })
throw new Error(`LanguageTool: ${res.status}`)
}
return (await res.json()) as LanguageToolCheckResponse
}
export function isLanguageToolConfigured(): boolean {
return Boolean(checkUrl())
}

62
src/lib/languagetool-cm-linter.ts

@ -0,0 +1,62 @@
import { linter, type Diagnostic } from '@codemirror/lint'
import type { Extension } from '@codemirror/state'
import { languageToolCheck, type LanguageToolMatch } from '@/lib/languagetool-client'
function matchToDiagnostic(docLen: number, m: LanguageToolMatch): Diagnostic | null {
const from = Math.max(0, Math.min(m.offset, docLen))
const to = Math.max(from, Math.min(m.offset + m.length, docLen))
if (to <= from) return null
const fix = m.replacements?.[0]?.value
return {
from,
to,
severity: 'info',
message: m.message + (m.rule?.id ? ` (${m.rule.id})` : ''),
actions: fix
? [
{
name: 'Apply',
apply(view) {
view.dispatch({ changes: { from, to, insert: fix } })
}
}
]
: undefined
}
}
/**
* Async grammar/style lint for CodeMirror using LanguageTool `/v2/check`.
*/
export function languageToolLintExtension(
language: string,
debounceMs: number
): Extension {
return linter((view) => {
return new Promise<Diagnostic[]>((resolve) => {
const text = view.state.doc.toString()
if (text.length < 3) {
resolve([])
return
}
const seq = ++requestSeq
window.setTimeout(() => {
if (seq !== requestSeq) return
void languageToolCheck(text, language)
.then((res) => {
if (seq !== requestSeq) return
const docLen = view.state.doc.length
const out: Diagnostic[] = []
for (const m of res.matches ?? []) {
const d = matchToDiagnostic(docLen, m)
if (d) out.push(d)
}
resolve(out)
})
.catch(() => resolve([]))
}, debounceMs)
})
})
}
let requestSeq = 0

12
src/lib/languagetool-language-order.test.ts

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order'
describe('buildLanguageToolPreferenceList', () => {
it('puts client language first then en-US then de-DE', () => {
const list = buildLanguageToolPreferenceList('de')
expect(list[0]).toBe('de-DE')
expect(list[1]).toBe('en-US')
expect(list.includes('de-DE')).toBe(true)
expect(list.indexOf('en-US')).toBe(1)
})
})

132
src/lib/languagetool-language-order.ts

@ -0,0 +1,132 @@
/**
* Build LanguageTool `language` codes with UI language first, then English, then German, then others.
* @see https://api.languagetool.org/v2/languages
*/
const LT_ALIASES: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
es: 'es',
it: 'it',
pt: 'pt-BR',
'pt-BR': 'pt-BR',
'pt-PT': 'pt-PT',
pl: 'pl-PL',
ru: 'ru-RU',
uk: 'uk-UA',
nl: 'nl',
sv: 'sv',
da: 'da-DK',
no: 'no',
nb: 'no',
nn: 'no',
fi: 'fi',
el: 'el-GR',
tr: 'tr',
ar: 'ar',
he: 'he',
hi: 'hi',
ja: 'ja-JP',
ko: 'ko',
zh: 'zh-CN',
fa: 'fa',
th: 'th-TH',
vi: 'vi-VN',
ro: 'ro-RO',
cs: 'cs-CZ',
sk: 'sk-SK',
hu: 'hu',
sl: 'sl-SI',
hr: 'hr-HR',
sr: 'sr',
bg: 'bg-BG',
lt: 'lt-LT',
lv: 'lv-LV',
et: 'et-EE',
ca: 'ca-ES',
gl: 'gl-ES',
tl: 'tl-PH',
id: 'id',
ms: 'ms-MY',
ta: 'ta-IN',
te: 'te-IN',
mr: 'mr-IN',
bn: 'bn-BD',
gu: 'gu-IN',
kn: 'kn-IN',
ml: 'ml-IN',
pa: 'pa-IN',
or: 'or-IN',
as: 'as-IN',
ne: 'ne-NP',
si: 'si-LK',
lo: 'lo-LA',
km: 'km-KH',
my: 'my-MM',
ka: 'ka-GE',
hy: 'hy-AM',
az: 'az',
kk: 'kk-KZ',
mn: 'mn-MN',
af: 'af',
sw: 'sw',
zu: 'zu',
xh: 'xh',
yo: 'yo',
ig: 'ig',
ha: 'ha',
so: 'so-SO',
am: 'am',
ti: 'ti',
om: 'om-ET',
sn: 'sn-ZW',
rw: 'rw',
mg: 'mg-MG',
ny: 'ny-MW',
eo: 'eo',
lb: 'lb',
br: 'br-FR',
cy: 'cy-GB',
ga: 'ga-IE',
gd: 'gd-GB',
mt: 'mt-MT',
is: 'is-IS',
fo: 'fo-FO',
tk: 'tk-TM',
uz: 'uz-UZ',
ky: 'ky-KG',
tg: 'tg-TJ',
ps: 'ps-AF',
sd: 'sd-IN',
ur: 'ur-PK',
ckb: 'ckb-IQ',
ku: 'kmr-Latn',
yi: 'yi-001',
jv: 'jv-Latn',
su: 'su-Latn'
}
function mapI18nToLt(i18nLanguage: string): string {
const base = i18nLanguage.split(/[-_]/u)[0]?.toLowerCase() ?? 'en'
const full = i18nLanguage.replace('_', '-')
if (LT_ALIASES[full]) return LT_ALIASES[full]!
if (LT_ALIASES[base]) return LT_ALIASES[base]!
return 'en-US'
}
export function buildLanguageToolPreferenceList(i18nLanguage: string | undefined): string[] {
const primary = mapI18nToLt((i18nLanguage ?? 'en').trim() || 'en')
const ordered: string[] = []
const push = (c: string) => {
if (!ordered.includes(c)) ordered.push(c)
}
push(primary)
push('en-US')
push('de-DE')
const extras = Object.values(LT_ALIASES)
extras.sort()
for (const c of extras) {
push(c)
}
return ordered
}

9
src/lib/live-activities.ts

@ -36,6 +36,15 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ /** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const
/** True when the home kind picker (or “see all events”) allows NIP-53 live activity rows and inline embeds. */
export function liveActivityKindsEnabledInPicker(
showKinds: readonly number[],
feedKindFilterBypass: boolean
): boolean {
if (feedKindFilterBypass) return true
return LIVE_ACTIVITY_KINDS.some((k) => showKinds.includes(k))
}
/** /**
* Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list). * Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list).
* Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly * Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly

18
src/lib/read-aloud-translation-override.ts

@ -0,0 +1,18 @@
/**
* Optional plain text used for the next read-aloud instead of deriving text from the event
* (e.g. after translating in the advanced lab). One-shot per event id.
*/
const overrides = new Map<string, string>()
export function setReadAloudTranslationForEvent(eventId: string, plainText: string): void {
overrides.set(eventId, plainText)
}
export function takeReadAloudTranslationForEvent(eventId: string): string | undefined {
const v = overrides.get(eventId)
if (v !== undefined) {
overrides.delete(eventId)
return v
}
return undefined
}

6
src/lib/read-aloud.ts

@ -1,4 +1,5 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import { import {
buildPiperTtsCacheKey, buildPiperTtsCacheKey,
getPiperTtsCacheBudget, getPiperTtsCacheBudget,
@ -671,7 +672,10 @@ export async function speakNoteReadAloud(event: Event): Promise<ReadAloudResult>
return 'unsupported' return 'unsupported'
} }
const text = buildReadAloudPlainText(event) const translationOverride = takeReadAloudTranslationForEvent(event.id)
const text = translationOverride
? stripMarkupForReadAloud(translationOverride)
: buildReadAloudPlainText(event)
if (!text) { if (!text) {
return 'empty' return 'empty'
} }

26
src/lib/tiptap-plaintext.test.ts

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
describe('plainTextToTipTapDoc', () => {
it('round-trips simple lines', () => {
const plain = 'hello\nworld'
const doc = plainTextToTipTapDoc(plain)
expect(parseEditorJsonToText(doc).trim()).toBe(plain)
})
it('handles empty string', () => {
const doc = plainTextToTipTapDoc('')
expect(parseEditorJsonToText(doc).trim()).toBe('')
})
it('handles blank line between paragraphs', () => {
const plain = 'a\n\nb'
const doc = plainTextToTipTapDoc(plain)
expect(parseEditorJsonToText(doc).trim()).toBe(plain)
})
it('normalizes CRLF', () => {
const doc = plainTextToTipTapDoc('x\r\ny')
expect(parseEditorJsonToText(doc).trim()).toBe('x\ny')
})
})

14
src/lib/tiptap.ts

@ -4,6 +4,20 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { JSONContent } from '@tiptap/react' import { JSONContent } from '@tiptap/react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
/**
* Build a minimal TipTap document from plain text (one paragraph per `\n`).
* Used when applying edits from the advanced CodeMirror lab so `parseEditorJsonToText` stays consistent.
*/
export function plainTextToTipTapDoc(plain: string): JSONContent {
const normalized = plain.replace(/\r\n/g, '\n')
const lines = normalized.length === 0 ? [''] : normalized.split('\n')
const paragraphs: JSONContent[] = lines.map((line) => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : []
}))
return { type: 'doc', content: paragraphs }
}
export function parseEditorJsonToText(node?: JSONContent) { export function parseEditorJsonToText(node?: JSONContent) {
const rawJoined = _parseEditorJsonToText(node) const rawJoined = _parseEditorJsonToText(node)
let text = rawJoined.trim() let text = rawJoined.trim()

67
src/lib/translate-client.ts

@ -0,0 +1,67 @@
import { TRANSLATE_URL } from '@/constants'
import logger from '@/lib/logger'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
const memoryCache = new Map<string, { text: string; at: number }>()
const MAX_MEMORY = 80
const CACHE_TTL_MS = 1000 * 60 * 60 * 24
function cacheKey(source: string, sourceLang: string, targetLang: string): string {
const h = bytesToHex(sha256(new TextEncoder().encode(`${sourceLang}|${targetLang}|${source}`)))
return h
}
function pruneMemory(): void {
const now = Date.now()
for (const [k, v] of memoryCache) {
if (now - v.at > CACHE_TTL_MS) memoryCache.delete(k)
}
while (memoryCache.size > MAX_MEMORY) {
const first = memoryCache.keys().next().value
if (first) memoryCache.delete(first)
else break
}
}
export function isTranslateConfigured(): boolean {
return Boolean(TRANSLATE_URL.trim())
}
export async function translatePlainText(
text: string,
targetLang: string,
sourceLang: string = 'auto'
): Promise<string> {
const base = TRANSLATE_URL.trim().replace(/\/$/u, '')
if (!base) {
throw new Error('Translation URL not configured')
}
const key = cacheKey(text, sourceLang, targetLang)
const hit = memoryCache.get(key)
if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
return hit.text
}
const url = `${base}/translate`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
source: sourceLang,
target: targetLang,
format: 'text'
})
})
if (!res.ok) {
const err = await res.text().catch(() => '')
logger.warn('[Translate] HTTP error', { status: res.status, err: err.slice(0, 200) })
throw new Error(`Translate: ${res.status}`)
}
const data = (await res.json()) as { translatedText?: string }
const out = data.translatedText ?? ''
pruneMemory()
memoryCache.set(key, { text: out, at: Date.now() })
return out
}

10
vite.config.ts

@ -108,6 +108,16 @@ export default defineConfig(({ mode }) => {
target: 'http://127.0.0.1:9876', target: 'http://127.0.0.1:9876',
changeOrigin: true changeOrigin: true
}, },
'/api/languagetool': {
target: 'http://127.0.0.1:8010',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/languagetool/u, '') || '/'
},
'/api/translate': {
target: 'http://127.0.0.1:5000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/translate/u, '') || '/'
},
'/sites': { '/sites': {
target: 'http://127.0.0.1:8090', target: 'http://127.0.0.1:8090',
changeOrigin: true changeOrigin: true

Loading…
Cancel
Save