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} @@ -7,6 +7,12 @@ ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER}
ARG 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
# 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- @@ -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`.
## 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
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 @@ @@ -10,12 +10,23 @@
"license": "MIT",
"dependencies": {
"@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/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0",
"@lezer/markdown": "^1.6.3",
"@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
@ -53,6 +64,7 @@ @@ -53,6 +64,7 @@
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-asciidoc": "^2.0.1",
"dataloader": "^2.2.3",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0",
@ -1797,6 +1809,169 @@ @@ -1797,6 +1809,169 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@ -3217,6 +3392,84 @@ @@ -3217,6 +3392,84 @@
"@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": {
"version": "0.3.4-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.4-alpha.tgz",
@ -3272,6 +3525,12 @@ @@ -3272,6 +3525,12 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@ -7775,6 +8034,12 @@ @@ -7775,6 +8034,12 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -14258,6 +14523,12 @@ @@ -14258,6 +14523,12 @@
"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": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",

12
package.json

@ -33,12 +33,23 @@ @@ -33,12 +33,23 @@
},
"dependencies": {
"@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/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0",
"@lezer/markdown": "^1.6.3",
"@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
@ -76,6 +87,7 @@ @@ -76,6 +87,7 @@
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-asciidoc": "^2.0.1",
"dataloader": "^2.2.3",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0",

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

@ -8,6 +8,8 @@ @@ -8,6 +8,8 @@
# 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).
# 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
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -23,11 +25,15 @@ IMAGE_MONITOR="silberengel/imwald-jumble-nip66-monitor" @@ -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
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}"
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 \
--build-arg "VITE_PROXY_SERVER=$PROXY_ORIGIN" \
--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" .
echo "Building NIP-66 monitor (version: $VERSION)"

392
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -0,0 +1,392 @@ @@ -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 @@ @@ -1,6 +1,8 @@
import { Skeleton } from '@/components/ui/skeleton'
import ExternalLink from '@/components/ExternalLink'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS, liveActivityKindsEnabledInPicker } from '@/lib/live-activities'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url'
@ -8,6 +10,7 @@ import { cn } from '@/lib/utils' @@ -8,6 +10,7 @@ import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { Event, nip19 } from 'nostr-tools'
@ -162,6 +165,24 @@ function EmbeddedNoteInvalid({ @@ -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({
noteId,
className,
@ -173,7 +194,22 @@ function EmbeddedNoteContent({ @@ -173,7 +194,22 @@ function EmbeddedNoteContent({
containingEvent?: Event
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 [isRetrying, setIsRetrying] = useState(false)
const [retryCount, setRetryCount] = useState(0)
@ -181,6 +217,7 @@ function EmbeddedNoteContent({ @@ -181,6 +217,7 @@ function EmbeddedNoteContent({
// If the first fetch fails, try a force retry (max 3 attempts)
useEffect(() => {
if (skipLiveActivityFetch) return
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) {
setIsRetrying(true)
setRetryCount(prev => prev + 1)
@ -203,7 +240,11 @@ function EmbeddedNoteContent({ @@ -203,7 +240,11 @@ function EmbeddedNoteContent({
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 finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
@ -216,6 +257,13 @@ function EmbeddedNoteContent({ @@ -216,6 +257,13 @@ function EmbeddedNoteContent({
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)
const bookMetadata = extractBookMetadata(finalEvent)
const hasBookstrTags = !!bookMetadata.book

1
src/components/NoteList/index.tsx

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

58
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -42,10 +42,13 @@ import { useNostr } from '@/providers/NostrProvider' @@ -42,10 +42,13 @@ import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service'
import type { TDraftEvent } from '@/types'
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 { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
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 {
const trimmed = row.map((c) => c.trim())
@ -128,13 +131,15 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -128,13 +131,15 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const isCreate = mode === 'create'
const sourceEvent = !isCreate ? props.sourceEvent : null
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(() => sourceEvent?.content ?? '')
const [createKindInput, setCreateKindInput] = useState('1')
const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const [advancedLabInitial, setAdvancedLabInitial] = useState<AdvancedEventLabSlice | null>(null)
const prevOpenRef = useRef(false)
const parsedCreateKind = useMemo(
@ -346,7 +351,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -346,7 +351,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
? t('Clone or fork this 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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
@ -360,10 +379,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -360,10 +379,21 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
<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">
<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="preview">{t('Preview')}</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>
<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 @@ -546,5 +576,27 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
</DialogFooter>
</DialogContent>
</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 { @@ -35,6 +35,7 @@ import {
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent,
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto
} from '@/lib/draft-event'
import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants'
@ -71,7 +72,8 @@ import { @@ -71,7 +72,8 @@ import {
Music,
Video,
Film,
Laugh
Laugh,
Code2
} from 'lucide-react'
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta'
@ -119,6 +121,9 @@ import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAnd @@ -119,6 +121,9 @@ import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAnd
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
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({
defaultContent = '',
@ -142,7 +147,7 @@ export default function PostContent({ @@ -142,7 +147,7 @@ export default function PostContent({
/** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */
discussionDynamicTopics?: TDiscussionDynamicTopics | null
}) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const { userGroups } = useGroupList()
const { feedInfo } = useFeed()
@ -203,6 +208,9 @@ export default function PostContent({ @@ -203,6 +208,9 @@ export default function PostContent({
)
const [text, setText] = useState('')
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 [posting, setPosting] = useState(false)
const [uploadProgresses, setUploadProgresses] = useState<
@ -1074,6 +1082,14 @@ export default function PostContent({ @@ -1074,6 +1082,14 @@ export default function PostContent({
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
const getDraftEventJson = useCallback(async (): Promise<string> => {
if (!pubkey) {
@ -1083,13 +1099,35 @@ export default function PostContent({ @@ -1083,13 +1099,35 @@ export default function PostContent({
try {
// Clean tracking parameters from URLs in the post content
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)
} catch (error) {
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) => {
e?.stopPropagation()
@ -1162,6 +1200,7 @@ export default function PostContent({ @@ -1162,6 +1200,7 @@ export default function PostContent({
// Create draft event using shared function
draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
const publishSuccessMessage = parentEvent
? t('Reply published')
@ -2855,8 +2894,7 @@ export default function PostContent({ @@ -2855,8 +2894,7 @@ export default function PostContent({
addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
headerActions={
!parentEvent ? (() => {
headerActions={(() => {
const ActiveIcon =
isLongFormArticle ? FileText :
isWikiArticle ? FileText :
@ -2886,6 +2924,19 @@ export default function PostContent({ @@ -2886,6 +2924,19 @@ export default function PostContent({
t('Short Note')
return (
<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
type="button"
variant="outline"
@ -3055,9 +3106,11 @@ export default function PostContent({ @@ -3055,9 +3106,11 @@ export default function PostContent({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
</div>
)
})() : undefined
})()
}
/>
{isDiscussionThread && !parentEvent && (
@ -3482,6 +3535,22 @@ export default function PostContent({ @@ -3482,6 +3535,22 @@ export default function PostContent({
</div>
</DialogContent>
</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
open={createCustomEventOpen}
onOpenChange={setCreateCustomEventOpen}

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
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 customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service'
@ -11,7 +11,7 @@ import Paragraph from '@tiptap/extension-paragraph' @@ -11,7 +11,7 @@ import Paragraph from '@tiptap/extension-paragraph'
import Placeholder from '@tiptap/extension-placeholder'
import Text from '@tiptap/extension-text'
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 {
Dispatch,
@ -42,6 +42,8 @@ export type TPostTextareaHandle = { @@ -42,6 +42,8 @@ export type TPostTextareaHandle = {
insertEmoji: (emoji: string | TEmoji) => void
clear: () => void
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<
@ -121,6 +123,7 @@ const PostTextarea = forwardRef< @@ -121,6 +123,7 @@ const PostTextarea = forwardRef<
const [isLoadingJson, setIsLoadingJson] = useState(false)
/** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */
const jsonPanelFetchSeq = useRef(0)
const editorRef = useRef<Editor | null>(null)
const kindDescription = useMemo(() => getKindDescription(kind), [kind])
@ -237,10 +240,13 @@ const PostTextarea = forwardRef< @@ -237,10 +240,13 @@ const PostTextarea = forwardRef<
}
})
editorRef.current = editor
useImperativeHandle(ref, () => ({
appendText: (text: string, addNewline = false) => {
if (editor) {
let chain = editor
const ed = editorRef.current
if (ed) {
let chain = ed
.chain()
.focus()
.command(({ tr, dispatch }) => {
@ -260,11 +266,13 @@ const PostTextarea = forwardRef< @@ -260,11 +266,13 @@ const PostTextarea = forwardRef<
}
},
insertText: (text: string) => {
const editor = editorRef.current
if (editor) {
editor.chain().focus().insertContent(text).run()
}
},
insertEmoji: (emoji: string | TEmoji) => {
const editor = editorRef.current
if (editor) {
if (typeof emoji === 'string') {
editor.chain().insertContent(emoji).run()
@ -277,6 +285,7 @@ const PostTextarea = forwardRef< @@ -277,6 +285,7 @@ const PostTextarea = forwardRef<
}
},
clear: () => {
const editor = editorRef.current
if (editor) {
// Clear the editor content and reset to empty document
editor.chain().clearContent().run()
@ -286,10 +295,19 @@ const PostTextarea = forwardRef< @@ -286,10 +295,19 @@ const PostTextarea = forwardRef<
}
},
getText: () => {
const editor = editorRef.current
if (editor) {
return editor.getText()
}
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 @@ @@ -1,7 +1,9 @@
import { EmbeddedNote } from '@/components/Embedded/EmbeddedNote'
import ExternalLink from '@/components/ExternalLink'
import { liveActivityKindsEnabledInPicker } from '@/lib/live-activities'
import { naddrFromZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { cn } from '@/lib/utils'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import type { Event } from 'nostr-tools'
/** 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({ @@ -16,6 +18,10 @@ export default function ZapStreamLiveEventEmbed({
containingEvent?: Event
showFull?: boolean
}) {
const { showKinds, feedKindFilterBypass } = useKindFilterOrDefaults()
if (!liveActivityKindsEnabledInPicker(showKinds, feedKindFilterBypass)) {
return <ExternalLink url={url} className={cn('not-prose', className)} />
}
const naddr = naddrFromZapStreamWatchUrl(url)
if (!naddr) {
return <ExternalLink url={url} className={cn('not-prose', className)} />

13
src/constants.ts

@ -27,6 +27,19 @@ export const GITREPUBLIC_WEB_BASE_URL = ( @@ -27,6 +27,19 @@ export const GITREPUBLIC_WEB_BASE_URL = (
export const READ_ALOUD_TTS_URL =
(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. */
export const HIVETALK_BASE_URL =
(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 { @@ -500,6 +500,8 @@ export default {
'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.',
'Open in browser': 'Im Browser öffnen',
'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':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'liveEvent.hideFromCarousel': 'Im Karussell ausblenden',
@ -952,6 +954,21 @@ export default { @@ -952,6 +954,21 @@ export default {
'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.',
'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',
Reset: 'Zurücksetzen',
'Share something on this Relay': 'Teile etwas auf diesem Relay',

17
src/i18n/locales/en.ts

@ -497,6 +497,8 @@ export default { @@ -497,6 +497,8 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.',
'Open in browser': 'Open in browser',
'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':
'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',
@ -953,6 +955,21 @@ export default { @@ -953,6 +955,21 @@ export default {
'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.',
'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',
Reset: 'Reset',
'Share something on this Relay': 'Share something on this Relay',

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

@ -0,0 +1,6 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 = ( @@ -36,6 +36,15 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
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).
* Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly

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

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

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

@ -0,0 +1,26 @@ @@ -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' @@ -4,6 +4,20 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { JSONContent } from '@tiptap/react'
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) {
const rawJoined = _parseEditorJsonToText(node)
let text = rawJoined.trim()

67
src/lib/translate-client.ts

@ -0,0 +1,67 @@ @@ -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 }) => { @@ -108,6 +108,16 @@ export default defineConfig(({ mode }) => {
target: 'http://127.0.0.1:9876',
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': {
target: 'http://127.0.0.1:8090',
changeOrigin: true

Loading…
Cancel
Save