diff --git a/package-lock.json b/package-lock.json index a53bf307..0e2ae19b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.1.0", + "version": "23.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.1.0", + "version": "23.1.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 10046fbc..f429c82a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.1.0", + "version": "23.1.1", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/scripts/download-piper-extra-voices.sh b/scripts/download-piper-extra-voices.sh index f2b1e3e5..07eb307d 100644 --- a/scripts/download-piper-extra-voices.sh +++ b/scripts/download-piper-extra-voices.sh @@ -1,6 +1,18 @@ #!/usr/bin/env bash -# Download Piper ONNX voices — delegates to ensure-libretranslate-dirs.sh (single source of truth). -# Usage: bash scripts/download-piper-extra-voices.sh [DEST_DIR] +# Download Piper ONNX voices (read-aloud / Wyoming) — delegates to ensure-libretranslate-dirs.sh. +# Does not touch LibreTranslate dirs, Docker, or scripts/libretranslate-lt.default.env. +# +# Usage: +# bash scripts/download-piper-extra-voices.sh [DEST_DIR] +# Default DEST: $PIPER_DOWNLOAD_DIR if set, else /.local-piper-data +# Optional: HF_BASE (see ensure-libretranslate-dirs.sh) for a Hugging Face mirror. +# +# Voice list lives only in ensure-libretranslate-dirs.sh (download_piper_voices_to). set -euo pipefail __dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -exec bash "$__dir/ensure-libretranslate-dirs.sh" --download-piper-only "$@" +_ensure="${__dir}/ensure-libretranslate-dirs.sh" +[[ -f "$_ensure" ]] || { + echo "[download-piper-extra-voices] Missing ${_ensure} (keep this script next to ensure-libretranslate-dirs.sh)." >&2 + exit 1 +} +exec bash "$_ensure" --download-piper-only "$@" diff --git a/scripts/ensure-libretranslate-dirs.sh b/scripts/ensure-libretranslate-dirs.sh index 83a2340a..1dbeffda 100755 --- a/scripts/ensure-libretranslate-dirs.sh +++ b/scripts/ensure-libretranslate-dirs.sh @@ -10,7 +10,9 @@ # as docker-compose `env_file` for libretranslate). # # Piper download logic lives in this file so you can copy **only** this script to a server and run it -# from the repo root (still need curl, docker; full clone is easier: bash scripts/ensure-libretranslate-dirs.sh). +# from the repo root (still need curl, docker). If `scripts/libretranslate-lt.default.env` is missing, +# a built-in LT_LOAD_ONLY list is used (see load_stack_lt_load_only). Full clone is easiest: +# bash scripts/ensure-libretranslate-dirs.sh # # Internal entry: bash ensure-libretranslate-dirs.sh --download-piper-only [DEST] # (used by scripts/download-piper-extra-voices.sh — keep Piper relpaths in sync with trinity-languages.ts) @@ -30,21 +32,28 @@ _resolve_root() { fi } +# Keep identical to scripts/libretranslate-lt.default.env in the repo (fallback when that file is absent). +DEFAULT_LT_LOAD_ONLY='en,de,es,fr,it,pt,ru,zh,ar,nl,pl,cs,tr' + load_stack_lt_load_only() { local f="${ROOT}/scripts/libretranslate-lt.default.env" - [[ -f "$f" ]] || { - echo "[ensure] Missing ${f}" >&2 - exit 1 - } - STACK_LT_LOAD_ONLY="$(grep -E '^[[:space:]]*LT_LOAD_ONLY=' "$f" | head -1 | sed 's/^[[:space:]]*LT_LOAD_ONLY=//')" + local f_alt="${ROOT}/libretranslate-lt.default.env" + if [[ -f "$f" ]]; then + STACK_LT_LOAD_ONLY="$(grep -E '^[[:space:]]*LT_LOAD_ONLY=' "$f" | head -1 | sed 's/^[[:space:]]*LT_LOAD_ONLY=//')" + elif [[ -f "$f_alt" ]]; then + STACK_LT_LOAD_ONLY="$(grep -E '^[[:space:]]*LT_LOAD_ONLY=' "$f_alt" | head -1 | sed 's/^[[:space:]]*LT_LOAD_ONLY=//')" + else + echo "[ensure] warn: missing ${f} (and ${f_alt}) — using built-in LT_LOAD_ONLY. Copy scripts/libretranslate-lt.default.env from the repo to customize." >&2 + STACK_LT_LOAD_ONLY="$DEFAULT_LT_LOAD_ONLY" + fi [[ -n "$STACK_LT_LOAD_ONLY" ]] || { - echo "[ensure] LT_LOAD_ONLY empty in ${f}" >&2 + echo "[ensure] LT_LOAD_ONLY empty (check ${f} or set LT_LOAD_ONLY before running)" >&2 exit 1 } } # Keep in sync with src/lib/trinity-languages.ts (TRINITY_PIPER_VOICE + EXTRA_READ_ALOUD_PIPER_VOICE) -# and services/piper-tts-proxy/server.ts getVoiceForLanguage voiceMap. +# and services/piper-tts-proxy/server.ts getVoiceForLanguage voiceMap (14 voices: 10 trinity + en-gb, ar, it, pt). download_piper_voices_to() { local dest="${1:?destination directory}" local hf="${HF_BASE:-https://huggingface.co/rhasspy/piper-voices/resolve/main}" @@ -74,8 +83,11 @@ download_piper_voices_to() { continue fi echo "Fetching ${base_name} …" - curl -fsSL -o "$onnx" "${hf}/${relpath}.onnx" - curl -fsSL -o "$json" "${hf}/${relpath}.onnx.json" + # HuggingFace can be slow or reset mid-transfer; retries + caps avoid hung deploys. + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 30 --max-time 600 \ + -o "$onnx" "${hf}/${relpath}.onnx" + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 30 --max-time 600 \ + -o "$json" "${hf}/${relpath}.onnx.json" done echo "Piper ONNX done → ${dest}" } diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index 0eefc7ed..8854319c 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -225,6 +225,9 @@ export default function AdvancedEventLabDialog({ previewEmojiTags }: AdvancedEventLabDialogProps) { const { t, i18n } = useTranslation() + /** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */ + const labTRef = useRef(t) + labTRef.current = t const dark = useDarkModeFlag() const markupHost = useRef(null) const markupView = useRef(null) @@ -243,6 +246,10 @@ export default function AdvancedEventLabDialog({ const [previewDoc, setPreviewDoc] = useState('') + /** Stable while payload matches; avoids remounting the editor when the parent passes a new `initial` object reference. */ + const labEditorMountFingerprint = + initial != null ? `${initial.kind}\0${initial.content}\0${JSON.stringify(initial.tags)}` : '' + const mergedLabPreviewEmojiTags = useMemo(() => { if (!open || !initial) return [] const fromInitial = initial.tags.filter(([n]) => n === 'emoji').map((r) => [...r]) @@ -470,7 +477,7 @@ export default function AdvancedEventLabDialog({ clearInterval(pushId) clearInterval(uiId) } - }, [open, initial, pushLabCheckpoint, bumpUndoUi]) + }, [open, labEditorMountFingerprint, pushLabCheckpoint, bumpUndoUi]) const [translateLangs, setTranslateLangs] = useState([]) const ltList = useMemo( @@ -610,7 +617,7 @@ export default function AdvancedEventLabDialog({ keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), cmPlaceholder( - t( + labTRef.current( markupMode === 'asciidoc' ? 'Advanced lab markup placeholder asciidoc' : 'Advanced lab markup placeholder markdown' @@ -618,10 +625,11 @@ export default function AdvancedEventLabDialog({ ), markupLang, EditorView.theme({ - '&': { maxHeight: '100%' }, - '.cm-scroller': { overflow: 'auto' }, + '&': { height: '100%', maxHeight: '100%', minHeight: 0 }, + '.cm-scroller': { overflow: 'auto', minHeight: 0 }, + // Large dvh mins fight stacked flex/grid rows and overflow onto the preview; host + row cap height instead. '.cm-content': { - minHeight: 'min(22dvh, 11rem)', + minHeight: '11rem', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } }), @@ -722,11 +730,10 @@ export default function AdvancedEventLabDialog({ } }, [ open, - initial, + labEditorMountFingerprint, markupMode, dark, destroyEditors, - t, bodyApiRef, scheduleLabDraftPersist, flushLabDraftNow, @@ -838,7 +845,10 @@ export default function AdvancedEventLabDialog({ - + e.preventDefault()} + >
e.stopPropagation()} @@ -893,7 +903,10 @@ export default function AdvancedEventLabDialog({ - + e.preventDefault()} + >
e.stopPropagation()} @@ -942,7 +955,10 @@ export default function AdvancedEventLabDialog({ - + e.preventDefault()} + >
e.stopPropagation()} @@ -1002,34 +1018,36 @@ export default function AdvancedEventLabDialog({
- - -
-
- - {t( - markupMode === 'asciidoc' - ? 'Advanced lab markup label asciidoc' - : 'Advanced lab markup label markdown' - )} - -
-
-
- - {t('Advanced lab preview')} - -
- + + +
+
+

+ {t( + markupMode === 'asciidoc' + ? 'Advanced lab markup label asciidoc' + : 'Advanced lab markup label markdown' + )} +

+
+
+

+ {t('Advanced lab preview')} +

+
+ +
+
@@ -1053,7 +1071,7 @@ export default function AdvancedEventLabDialog({ /** Responsive shell: ~5× prior max width cap and ~3× vertical use of viewport (still clamped). */ function cnDialogShell(): string { return [ - 'z-[250] max-w-none flex flex-col gap-0 p-0 overflow-hidden', + 'z-[250] max-w-none flex min-h-0 flex-col gap-0 overflow-hidden p-0', 'w-[min(98vw,calc(72rem*5))]', 'h-[min(94vh,calc(28rem*3))]', 'max-h-[min(96vh,90dvh)]' diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx index c727ae2a..82abb4a2 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -41,6 +41,7 @@ import { import type { MutableRefObject } from 'react' import { Fragment, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' import { labInsertRaw, labInsertRawWithOptionalBlockLeadNl, @@ -123,6 +124,8 @@ export function AdvancedEventLabMarkupToolbar({ const { t } = useTranslation() const [codeFilter, setCodeFilter] = useState('') const [langFilter, setLangFilter] = useState('') + /** Code-fence / source-block picker uses plain Buttons; close explicitly after insert (Radix). */ + const [codeLangMenuOpen, setCodeLangMenuOpen] = useState(false) const [citationPickerOpen, setCitationPickerOpen] = useState(false) const [citationDisplayType, setCitationDisplayType] = useState('inline') @@ -154,19 +157,29 @@ export function AdvancedEventLabMarkupToolbar({ /> ) + const outlineTbClass = + 'h-8 shrink-0 gap-1 text-xs max-md:w-9 max-md:min-w-9 max-md:justify-center max-md:gap-0 max-md:px-0' + const citationDropdown = ( - {t('Advanced lab tb citationsHint')} {LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => ( - openCitationPicker(type)}> + openCitationPicker(type)}> {t(labelKey)} ))} @@ -206,17 +219,24 @@ export function AdvancedEventLabMarkupToolbar({ if (markupMode === 'markdown') { return ( -
- +
+ {t('Advanced lab tb markup tools')} - @@ -235,7 +255,7 @@ export function AdvancedEventLabMarkupToolbar({ return ( + onSelect={() => run((v) => labInsertRaw( v, @@ -251,7 +271,7 @@ export function AdvancedEventLabMarkupToolbar({ })} + onSelect={() => run((v) => labInsertRaw( v, @@ -264,7 +284,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb setextH1')} + onSelect={() => run((v) => labInsertRaw( v, @@ -281,34 +301,41 @@ export function AdvancedEventLabMarkupToolbar({ - - run((v) => labWrapOrSnippet(v, sliceRef, '**', 'bold'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '**', 'bold'))}> {t('Advanced lab tb bold')} - run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}> {t('Advanced lab tb italic')} - run((v) => labWrapOrSnippet(v, sliceRef, '__', 'bold'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '__', 'bold'))}> {t('Advanced lab tb boldUnderscore')} - run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> {t('Advanced lab tb italicUnderscore')} - run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}> {t('Advanced lab tb strike')} - run((v) => labWrapOrSnippet(v, sliceRef, '`', 'code'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '`', 'code'))}> {t('Advanced lab tb inlineCode')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '[', 'link text', '](https://example.com)') ) @@ -318,7 +345,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb link')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '![', 'alt text', '](https://example.com/image.png)') ) @@ -329,7 +356,7 @@ export function AdvancedEventLabMarkupToolbar({ + onSelect={() => run((v) => labInsertSnippet( v, @@ -345,7 +372,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb linkTitled')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -361,7 +388,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb imageTitled')} - run((v) => labInsertRaw(v, sliceRef, ' \n'))}> + run((v) => labInsertRaw(v, sliceRef, ' \n'))}> {t('Advanced lab tb hardBreak')} @@ -372,27 +399,34 @@ export function AdvancedEventLabMarkupToolbar({ - - run((v) => labInsertRaw(v, sliceRef, '\n- item one\n- item two\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n- item one\n- item two\n'))}> {t('Advanced lab tb bulletList')} - run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> {t('Advanced lab tb bulletListStar')} - run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}> {t('Advanced lab tb orderedList')} + onSelect={() => run((v) => labInsertRaw( v, @@ -406,7 +440,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb orderedListStart')} + onSelect={() => run((v) => labInsertRaw( v, @@ -424,19 +458,26 @@ export function AdvancedEventLabMarkupToolbar({ - - run((v) => labInsertRaw(v, sliceRef, '\n> quoted line\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n> quoted line\n'))}> {t('Advanced lab tb blockquote')} + onSelect={() => run((v) => labInsertRaw( v, @@ -450,11 +491,11 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb pipeTable')} - run((v) => labInsertRaw(v, sliceRef, '\n[^1]\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n[^1]\n'))}> {t('Advanced lab tb footnoteRef')} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '\n[^1]: Footnote text goes here.\n') ) @@ -465,12 +506,25 @@ export function AdvancedEventLabMarkupToolbar({ - !o && setCodeFilter('')}> + { + setCodeLangMenuOpen(o) + if (!o) setCodeFilter('') + }} + > - @@ -493,6 +547,7 @@ export function AdvancedEventLabMarkupToolbar({ onClick={() => { mdFence(lang) setCodeFilter('') + setCodeLangMenuOpen(false) }} > {lang} @@ -505,10 +560,17 @@ export function AdvancedEventLabMarkupToolbar({ - @@ -516,7 +578,7 @@ export function AdvancedEventLabMarkupToolbar({ + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '$', 'x^2 + y^2 = r^2', '$') ) @@ -525,7 +587,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb mathInline')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n$$\n', 'E = mc^2', '\n$$\n') ) @@ -537,25 +599,25 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb mathCommon')} run((v) => labInsertRaw(v, sliceRef, '$\\frac{numerator}{denominator}$'))} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '$\\frac{numerator}{denominator}$'))} > {t('Advanced lab tb katexFrac')} - run((v) => labInsertRaw(v, sliceRef, '$\\sqrt{x}$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\sqrt{x}$'))}> {t('Advanced lab tb katexSqrt')} run((v) => labInsertRaw(v, sliceRef, '$\\sum_{i=1}^{n} i$'))} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '$\\sum_{i=1}^{n} i$'))} > {t('Advanced lab tb katexSum')} run((v) => labInsertRaw(v, sliceRef, '$\\int_{a}^{b} f(x)\\,dx$'))} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '$\\int_{a}^{b} f(x)\\,dx$'))} > {t('Advanced lab tb katexInt')} + onSelect={() => run((v) => labInsertRaw( v, @@ -568,7 +630,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb katexMatrix')} + onSelect={() => run((v) => labInsertRaw( v, @@ -582,34 +644,34 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb mathGreek')} - run((v) => labInsertRaw(v, sliceRef, '$\\alpha$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\alpha$'))}> {'\\alpha'} {t('Advanced lab tb greekAlpha')} - run((v) => labInsertRaw(v, sliceRef, '$\\beta$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\beta$'))}> {'\\beta'} {t('Advanced lab tb greekBeta')} - run((v) => labInsertRaw(v, sliceRef, '$\\gamma$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\gamma$'))}> {'\\gamma'} {t('Advanced lab tb greekGamma')} - run((v) => labInsertRaw(v, sliceRef, '$\\delta$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\delta$'))}> {'\\delta'} {t('Advanced lab tb greekDelta')} - run((v) => labInsertRaw(v, sliceRef, '$\\pi$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\pi$'))}> {'\\pi'} {t('Advanced lab tb greekPi')} - run((v) => labInsertRaw(v, sliceRef, '$\\theta$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\theta$'))}> {'\\theta'} {t('Advanced lab tb greekTheta')} - run((v) => labInsertRaw(v, sliceRef, '$\\lambda$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\lambda$'))}> {'\\lambda'} {t('Advanced lab tb greekLambda')} - run((v) => labInsertRaw(v, sliceRef, '$\\sigma$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\sigma$'))}> {'\\sigma'} {t('Advanced lab tb greekSigma')} - run((v) => labInsertRaw(v, sliceRef, '$\\omega$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\omega$'))}> {'\\omega'} {t('Advanced lab tb greekOmega')} - run((v) => labInsertRaw(v, sliceRef, '$\\infty$'))}> + run((v) => labInsertRaw(v, sliceRef, '$\\infty$'))}> {'\\infty'} {t('Advanced lab tb greekInfty')} @@ -621,22 +683,23 @@ export function AdvancedEventLabMarkupToolbar({ type="button" variant="ghost" size="sm" - className="h-8 gap-1 text-xs shrink-0" + className="h-8 max-md:w-9 max-md:min-w-9 max-md:justify-center max-md:px-0 gap-1 text-xs shrink-0" + aria-label={t('Advanced lab tb horizontalRules')} title={t('Advanced lab tb horizontalRules')} > - - + + {t('Advanced lab tb horizontalRules')} - run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}> {t('Advanced lab tb hrDashes')} - run((v) => labInsertRaw(v, sliceRef, '\n***\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n***\n'))}> {t('Advanced lab tb hrAsterisks')} - run((v) => labInsertRaw(v, sliceRef, '\n___\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n___\n'))}> {t('Advanced lab tb hrUnderscores')} @@ -650,23 +713,30 @@ export function AdvancedEventLabMarkupToolbar({ /* AsciiDoc */ return ( -
- +
+ {t('Advanced lab tb markup tools')} - {t('Advanced lab tb adocTitlesHint')} + onSelect={() => run((v) => labInsertRawWithOptionalBlockLeadNl(v, sliceRef, `= ${t('Advanced lab tb documentTitle')}\n`) ) @@ -674,7 +744,7 @@ export function AdvancedEventLabMarkupToolbar({ > {t('Advanced lab tb adocLevel0')} - adocInsertFullHeader(`= ${t('Advanced lab tb documentTitle')}`)}> + adocInsertFullHeader(`= ${t('Advanced lab tb documentTitle')}`)}> {t('Advanced lab tb adocLevel0WithHeader')} @@ -691,7 +761,7 @@ export function AdvancedEventLabMarkupToolbar({ return ( + onSelect={() => run((v) => labInsertRaw( v, @@ -710,25 +780,32 @@ export function AdvancedEventLabMarkupToolbar({ - - run((v) => labWrapOrSnippet(v, sliceRef, '*', 'bold'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '*', 'bold'))}> {t('Advanced lab tb adocBold')} - run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> {t('Advanced lab tb adocItalic')} - run((v) => labWrapOrSnippet(v, sliceRef, '`', 'mono'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '`', 'mono'))}> {t('Advanced lab tb adocMono')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'link:https://example.com[', 'link text', ']') ) @@ -738,7 +815,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocLink')} + onSelect={() => run((v) => labInsertRaw( v, @@ -752,21 +829,21 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocImage')} - run((v) => labInsertRaw(v, sliceRef, ' +\n'))}> + run((v) => labInsertRaw(v, sliceRef, ' +\n'))}> {t('Advanced lab tb adocLineBreak')} - run((v) => labWrapOrSnippet(v, sliceRef, '^', 'sup'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '^', 'sup'))}> {t('Advanced lab tb adocSuperscript')} - run((v) => labWrapOrSnippet(v, sliceRef, '~', 'sub'))}> + run((v) => labWrapOrSnippet(v, sliceRef, '~', 'sub'))}> {t('Advanced lab tb adocSubscript')} run((v) => labInsertSnippet(v, sliceRef, '+++', 'raw or HTML', '+++'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '+++', 'raw or HTML', '+++'))} > {t('Advanced lab tb adocPassthrough')} - run((v) => labInsertRaw(v, sliceRef, 'footnote:[Footnote text]'))}> + run((v) => labInsertRaw(v, sliceRef, 'footnote:[Footnote text]'))}> {t('Advanced lab tb adocFootnote')} @@ -776,25 +853,32 @@ export function AdvancedEventLabMarkupToolbar({ - - run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> {t('Advanced lab tb adocUnordered')} - run((v) => labInsertRaw(v, sliceRef, '\n. first step\n. second step\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n. first step\n. second step\n'))}> {t('Advanced lab tb adocOrdered')} - run((v) => labInsertRaw(v, sliceRef, '\nterm:: definition line\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\nterm:: definition line\n'))}> {t('Advanced lab tb adocLabeled')} + onSelect={() => run((v) => labInsertRaw( v, @@ -811,15 +895,22 @@ export function AdvancedEventLabMarkupToolbar({ - + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n____\n', 'Quoted paragraph', '\n____\n') ) @@ -828,7 +919,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocQuote')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n....\n', 'Literal monospace block', '\n....\n') ) @@ -838,7 +929,7 @@ export function AdvancedEventLabMarkupToolbar({ + onSelect={() => run((v) => labInsertSnippet( v, @@ -853,7 +944,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocNote')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -868,7 +959,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocTip')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -883,7 +974,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocWarning')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -898,7 +989,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocImportant')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -914,7 +1005,7 @@ export function AdvancedEventLabMarkupToolbar({ + onSelect={() => run((v) => labInsertSnippet( v, @@ -929,7 +1020,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocExampleBlock')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n****\n', 'Sidebar body', '\n****\n') ) @@ -938,7 +1029,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocSidebar')} + onSelect={() => run((v) => labInsertSnippet( v, @@ -953,7 +1044,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocListing')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n--\n', 'Open block body', '\n--\n') ) @@ -966,16 +1057,23 @@ export function AdvancedEventLabMarkupToolbar({ - {t('Advanced lab tb adocStructureHint')} + onSelect={() => run((v) => labInsertRaw( v, @@ -989,7 +1087,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocTable')} + onSelect={() => run((v) => labInsertRawWithOptionalBlockLeadNl(v, sliceRef, '[#section-anchor]\n')) } > @@ -997,7 +1095,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocAnchor')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '<>') ) @@ -1008,7 +1106,7 @@ export function AdvancedEventLabMarkupToolbar({ + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '\nvideo::https://example.com/video.mp4[width=640]\n') ) @@ -1018,7 +1116,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocVideo')} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, '\naudio::https://example.com/audio.mp3[]\n') ) @@ -1028,29 +1126,42 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb adocAudio')} - run((v) => labInsertRaw(v, sliceRef, '\n// Comment line\n'))}> + run((v) => labInsertRaw(v, sliceRef, '\n// Comment line\n'))}> {t('Advanced lab tb adocComment')} - run((v) => labInsertRaw(v, sliceRef, 'kbd:[Ctrl+T]'))}> + run((v) => labInsertRaw(v, sliceRef, 'kbd:[Ctrl+T]'))}> {t('Advanced lab tb adockbd')} run((v) => labInsertRaw(v, sliceRef, 'menu:View[Zoom > In]'))} + onSelect={() => run((v) => labInsertRaw(v, sliceRef, 'menu:View[Zoom > In]'))} > {t('Advanced lab tb adocMenu')} - run((v) => labInsertRaw(v, sliceRef, 'btn:[OK]'))}> + run((v) => labInsertRaw(v, sliceRef, 'btn:[OK]'))}> {t('Advanced lab tb adocBtn')} - !o && setLangFilter('')}> + { + setCodeLangMenuOpen(o) + if (!o) setLangFilter('') + }} + > - @@ -1075,6 +1186,7 @@ export function AdvancedEventLabMarkupToolbar({ onClick={() => { adocSource(lang) setLangFilter('') + setCodeLangMenuOpen(false) }} > {lang} @@ -1087,26 +1199,33 @@ export function AdvancedEventLabMarkupToolbar({ - {t('Advanced lab tb adocStemHint')} run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))} > {t('Advanced lab tb adocStemInline')} run((v) => labInsertSnippet(v, sliceRef, 'latexmath:[', 'x^2 + y^2', ']'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'latexmath:[', 'x^2 + y^2', ']'))} > {t('Advanced lab tb adocLatexmathInline')} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, '\n[stem]\n++++\n', 'E = mc^2', '\n++++\n') ) @@ -1116,25 +1235,25 @@ export function AdvancedEventLabMarkupToolbar({ run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\frac{a}{b}', ']'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\frac{a}{b}', ']'))} > {t('Advanced lab tb katexFrac')} - run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sqrt{x}', ']'))}> + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sqrt{x}', ']'))}> {t('Advanced lab tb katexSqrt')} run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sum_{i=1}^{n} i', ']'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sum_{i=1}^{n} i', ']'))} > {t('Advanced lab tb katexSum')} run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\int_{a}^{b} f(x)\\,dx', ']'))} + onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\int_{a}^{b} f(x)\\,dx', ']'))} > {t('Advanced lab tb katexInt')} + onSelect={() => run((v) => labInsertRaw( v, @@ -1147,7 +1266,7 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb katexMatrix')} + onSelect={() => run((v) => labInsertRaw( v, @@ -1161,16 +1280,16 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb mathGreek')} - run((v) => labInsertRaw(v, sliceRef, 'stem:[\\alpha]'))}> + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\alpha]'))}> {'\\alpha'} {t('Advanced lab tb greekAlpha')} - run((v) => labInsertRaw(v, sliceRef, 'stem:[\\beta]'))}> + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\beta]'))}> {'\\beta'} {t('Advanced lab tb greekBeta')} - run((v) => labInsertRaw(v, sliceRef, 'stem:[\\pi]'))}> + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\pi]'))}> {'\\pi'} {t('Advanced lab tb greekPi')} - run((v) => labInsertRaw(v, sliceRef, 'stem:[\\infty]'))}> + run((v) => labInsertRaw(v, sliceRef, 'stem:[\\infty]'))}> {'\\infty'} {t('Advanced lab tb greekInfty')} @@ -1180,11 +1299,12 @@ export function AdvancedEventLabMarkupToolbar({ type="button" variant="ghost" size="sm" - className="h-8 text-xs shrink-0" + className="h-8 max-md:w-9 max-md:min-w-9 max-md:justify-center max-md:px-0 text-xs shrink-0" + aria-label={t('Advanced lab tb adocHrTitle')} title={t('Advanced lab tb adocHrTitle')} onClick={() => run((v) => labInsertRaw(v, sliceRef, "\n'''\n"))} > - +
{citationPicker} diff --git a/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx b/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx index 4fc19e04..4d3a5806 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabPreviewPane.tsx @@ -37,13 +37,13 @@ export const AdvancedEventLabPreviewPane = memo(function AdvancedEventLabPreview if (!source.trim()) { return ( -

{t('Advanced lab preview empty')}

+

{t('Advanced lab preview empty')}

) } return ( -
+
{markupMode === 'asciidoc' ? ( ) : ( diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index bef2e85a..b5e1e687 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -3124,6 +3124,27 @@ function stripMarkdownGreentextMarker(trimmedLine: string): string { return trimmedLine.replace(/^>(?! )/, '') } +/** + * Marked leaves unclosed `![alt](url` / `![alt](url "title` as a junk paragraph (text + autolink + text). + * When the whole trimmed paragraph is that fragment, recover alt, URL, and optional title. + */ +function tryRecoverMalformedMarkdownImageParagraph( + paragraphTrimmed: string +): { alt: string; href: string; title?: string } | null { + const m = paragraphTrimmed.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(.*)$/i) + if (!m) return null + const alt = m[1] ?? '' + const href = m[2] ?? '' + const tail = m[3] ?? '' + if (tail === '' || tail === ')') return { alt, href } + const fullTitled = /^\s+"([^"]*)"\s*\)\s*$/.exec(tail) + if (fullTitled) return { alt, href, title: fullTitled[1] } + const partialTitle = /^\s+"([^"]*)$/.exec(tail) + if (partialTitle) return { alt, href, title: partialTitle[1] } + if (/^\s*\)\s*$/.test(tail)) return { alt, href } + return null +} + /** * Marked-driven markdown renderer (standard markdown blocks/inline), while keeping * Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom. @@ -3452,6 +3473,43 @@ function parseMarkdownContentMarked( /> ) } + const recoveredMdImage = tryRecoverMalformedMarkdownImageParagraph(paragraphText) + if (recoveredMdImage) { + const cleaned = cleanUrl(recoveredMdImage.href) + if (cleaned && isImage(cleaned) && isSafeMediaUrl(cleaned)) { + const baseImeta = imetaInfoForStandaloneImageUrl(cleaned) + let imageIdx = imageIndexMap.get(cleaned) + if (imageIdx === undefined && getImageIdentifier) { + const id = getImageIdentifier(cleaned) + if (id) imageIdx = imageIndexMap.get(`__img_id:${id}`) + } + const alt = recoveredMdImage.alt || 'image' + const imageTip = + recoveredMdImage.title && recoveredMdImage.title.trim().length > 0 + ? recoveredMdImage.title.trim() + : undefined + return ( +
+ {alt} { + e.stopPropagation() + if (typeof imageIdx === 'number') openLightbox(imageIdx) + }} + /> +
+ ) + } + } const isNostrEventBech32 = (value: string): boolean => value.startsWith('note') || value.startsWith('nevent') || value.startsWith('naddr') const standaloneNostr = paragraphText.match(/^nostr:([a-z0-9]{8,})$/i) @@ -5762,7 +5820,9 @@ export default function MarkdownArticle({ color: #5eead4 !important; } `} -
+
{iArticleUrl && !suppressITagArticleWebPreview && (
diff --git a/src/components/StoredAccountSwitchSelect.tsx b/src/components/StoredAccountSwitchSelect.tsx index a03f9bef..3a3d228a 100644 --- a/src/components/StoredAccountSwitchSelect.tsx +++ b/src/components/StoredAccountSwitchSelect.tsx @@ -1,14 +1,6 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' import UserAvatar from '@/components/UserAvatar' -import Username from '@/components/Username' import { cn } from '@/lib/utils' -import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' +import { formatPubkey, hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { useNostr } from '@/providers/NostrProvider' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -26,6 +18,10 @@ type Props = { /** * Switch {@link useNostr} session among stored accounts (same as notifications spell). * Renders nothing when there is only one stored account or no session. + * + * Uses a native {@link HTMLSelectElement} instead of Radix Select: nested `UserAvatar` / + * `Username` inside `SelectItem` composes refs in ways that have triggered + * “Maximum update depth exceeded” on this page (Radix `compose-refs` + frequent re-renders). */ export default function StoredAccountSwitchSelect({ className, @@ -59,8 +55,8 @@ export default function StoredAccountSwitchSelect({ const handlePick = useCallback( async (v: string) => { const target = normalizeHexPubkey(v) - if (pubkey && hexPubkeysEqual(target, pubkey)) return - const nextAccount = accounts.find((a) => hexPubkeysEqual(a.pubkey, target)) + if (pubkey && hexPubkeysEqual(target, normalizeHexPubkey(pubkey))) return + const nextAccount = accounts.find((a) => hexPubkeysEqual(normalizeHexPubkey(a.pubkey), target)) if (!nextAccount) { toast.error(t('notificationsSwitchAccountFailed')) return @@ -91,32 +87,23 @@ export default function StoredAccountSwitchSelect({ > {t('notificationsViewAsAccount')} - void handlePick(v)} + aria-label={t('notificationsViewAsAccountAria')} + onChange={(e) => void handlePick(e.target.value)} > - - - - - {storedAccountPubkeys.map((pk) => ( - - - - - - - ))} - - + {storedAccountPubkeys.map((pk) => ( + + ))} +
) } diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index 0273d940..9ac00d13 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -19,12 +19,16 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { }, []) useEffect(() => { + let cancelled = false + if (!eventId) { setIsFetching(false) setEvent(undefined) // Do not setError here: this effect re-runs when callback deps (e.g. addReplies) change identity; // allocating a new Error each time would force updates and can exceed React's max update depth. - return + return () => { + cancelled = true + } } const skipShortcuts = refetchToken > 0 @@ -47,7 +51,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { addReplies([initialEvent]) setIsFetching(false) } - return + return () => { + cancelled = true + } } // Check navigation event store first (events passed through navigation) @@ -57,7 +63,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { setEvent(navigationEvent) addReplies([navigationEvent]) setIsFetching(false) - return + return () => { + cancelled = true + } } } @@ -71,18 +79,27 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { skipShortcuts ? await eventService.fetchEventForceRetry(eventId) : await eventService.fetchEvent(eventId) + if (cancelled) return if (fetchedEvent && !isEventDeleted(fetchedEvent)) { setEvent(fetchedEvent) addReplies([fetchedEvent]) } } catch (error) { - setError(error as Error) + if (!cancelled) { + setError(error as Error) + } } finally { - setIsFetching(false) + if (!cancelled) { + setIsFetching(false) + } } } - fetchEvent() + void fetchEvent() + + return () => { + cancelled = true + } }, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken]) useEffect(() => { diff --git a/src/lib/event-kind1111-parent.test.ts b/src/lib/event-kind1111-parent.test.ts new file mode 100644 index 00000000..d09f60ec --- /dev/null +++ b/src/lib/event-kind1111-parent.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { nip19 } from 'nostr-tools' +import { getParentBech32Id, getParentEventHexId, getRootEventHexId } from './event' + +/** Kind 1111 sample: E/e point at a kind-1 parent; must not resolve parent hex to the comment id. */ +const fiatjafCommentSample = { + id: '10144b660d2aaf4eb65f5e60e7cc9d5e3fed7854073f57c1773929837d332dc1', + pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', + kind: 1111, + created_at: 1776511298, + content: 'x', + sig: '0'.repeat(128), + tags: [ + [ + 'E', + '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99', + '', + '1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb' + ], + ['P', '1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb'], + ['K', '1'], + [ + 'e', + '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99', + '', + '1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb' + ], + ['k', '1'], + ['p', '1f79058c77a224e5be226c8f024cacdad4d741855d75ed9f11473ba8eb86e1cb'] + ] +} as const + +describe('kind 1111 parent / root resolution', () => { + it('resolves parent and root hex to the threaded kind-1 id, not the comment id', () => { + const ev = { ...fiatjafCommentSample } as any + expect(getParentEventHexId(ev)).toBe( + '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99' + ) + expect(getRootEventHexId(ev)).toBe( + '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99' + ) + expect(getParentEventHexId(ev)).not.toBe(ev.id) + }) + + it('parent bech32 decodes to the parent hex id', () => { + const ev = { ...fiatjafCommentSample } as any + const parentBech32 = getParentBech32Id(ev) + expect(parentBech32).toBeTruthy() + const decoded = nip19.decode(parentBech32!) + expect(decoded.type).toBe('nevent') + if (decoded.type === 'nevent') { + expect(decoded.data.id).toBe( + '2c88e6bdf1d51d52037078624b21f07eefd86f3413be78efdb64e4931bb6bc99' + ) + expect(decoded.data.id).not.toBe(ev.id) + } + }) +}) diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts new file mode 100644 index 00000000..ea7f7e73 --- /dev/null +++ b/src/lib/relay-url-priority.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { dedupeNormalizeRelayUrlsOrdered, filterContextAuthorReadRelaysForPublish } from '@/lib/relay-url-priority' + +describe('filterContextAuthorReadRelaysForPublish', () => { + it('drops loopback, LAN, and .onion; keeps public relays', () => { + const out = filterContextAuthorReadRelaysForPublish([ + 'ws://localhost:4869/', + 'wss://127.0.0.1/', + 'wss://192.168.0.5/', + 'wss://abcdefghijklmnop.onion/', + 'wss://relay.example.com/' + ]) + expect(out).toEqual(['wss://relay.example.com/']) + }) + + it('dedupes like dedupeNormalizeRelayUrlsOrdered', () => { + const a = dedupeNormalizeRelayUrlsOrdered(['wss://relay.example.com/', 'wss://relay.example.com/']) + const b = filterContextAuthorReadRelaysForPublish([ + 'wss://relay.example.com/', + 'wss://relay.example.com/', + 'ws://localhost:4869/' + ]) + expect(b).toEqual(a) + }) +}) diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index 2d4096f2..d299e0e5 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -21,6 +21,25 @@ export function dedupeNormalizeRelayUrlsOrdered(urls: string[]): string[] { return out } +/** + * NIP-65 **read** (inbox) hints from reply/mention context must never add LAN, loopback, or Tor-only + * endpoints to the publish list — those are the author's private reachability, not yours. + */ +export function filterContextAuthorReadRelaysForPublish(urls: string[]): string[] { + return dedupeNormalizeRelayUrlsOrdered(urls).filter((u) => { + const n = normalizeAnyRelayUrl(u) || u.trim() + if (!n) return false + if (isLocalNetworkUrl(u) || isLocalNetworkUrl(n)) return false + try { + const host = new URL(n).hostname + if (host.endsWith('.onion')) return false + } catch { + return false + } + return true + }) +} + /** LAN / local host relays first, then the rest; deduped. */ export function relayUrlsLocalsFirst(urls: string[]): string[] { const local: string[] = [] @@ -171,7 +190,7 @@ function buildWriteRelayPriorityLayers(opts: { extraRelays?: string[] }): string[][] { const tier1 = relayUrlsLocalsFirst(opts.userWriteRelays) - const tier2 = dedupeNormalizeRelayUrlsOrdered(opts.authorReadRelays ?? []) + const tier2 = filterContextAuthorReadRelaysForPublish(opts.authorReadRelays ?? []) const tier3 = dedupeNormalizeRelayUrlsOrdered(opts.favoriteRelays ?? []) const tier4 = dedupeNormalizeRelayUrlsOrdered(opts.extraRelays ?? []) const tier5 = normFastWrite() diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 9b7c90b1..fd4c993a 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -14,7 +14,13 @@ import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { useFetchEvent, useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { getParentBech32Id, getParentETag, getRootBech32Id } from '@/lib/event' +import { + getParentBech32Id, + getParentETag, + getParentEventHexId, + getRootBech32Id, + getRootEventHexId +} from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { tagNameEquals } from '@/lib/tag' @@ -109,8 +115,18 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: const [externalEvent, setExternalEvent] = useState(undefined) const finalEvent = event || externalEvent - const parentEventId = useMemo(() => getParentBech32Id(finalEvent), [finalEvent]) - const rootEventId = useMemo(() => getRootBech32Id(finalEvent), [finalEvent]) + const parentEventId = useMemo(() => { + if (!finalEvent) return undefined + const parentHex = getParentEventHexId(finalEvent)?.toLowerCase() + if (parentHex && parentHex === finalEvent.id.toLowerCase()) return undefined + return getParentBech32Id(finalEvent) + }, [finalEvent]) + const rootEventId = useMemo(() => { + if (!finalEvent) return undefined + const rootHex = getRootEventHexId(finalEvent)?.toLowerCase() + if (rootHex && rootHex === finalEvent.id.toLowerCase()) return undefined + return getRootBech32Id(finalEvent) + }, [finalEvent]) const rootITag = useMemo( () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), [finalEvent] @@ -120,6 +136,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = useFetchEvent(parentEventId) + const selfHex = finalEvent?.id?.toLowerCase() + const rootEventForStrip = + rootEvent && selfHex && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined + const parentEventForStrip = + parentEvent && selfHex && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined + // When viewing a kind-24 invite (e.g. from notifications), extract calendar event naddr from content and show full calendar card with RSVP const calendarInviteNaddr = useMemo(() => { if (finalEvent?.kind !== ExtendedKind.PUBLIC_MESSAGE || !finalEvent.content?.trim()) return undefined @@ -456,20 +478,22 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: >
{rootITag && } - {rootEventId && rootEventId !== parentEventId && ( - - )} - {parentEventId && ( + {rootEventId && + rootEventId !== parentEventId && + (isFetchingRootEvent || rootEventForStrip) && ( + + )} + {parentEventId && (isFetchingParentEvent || parentEventForStrip) && ( )} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index c2cf4b4f..f6fa77b1 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -101,6 +101,7 @@ import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { buildPrioritizedWriteRelayUrls, dedupeNormalizeRelayUrlsOrdered, + filterContextAuthorReadRelaysForPublish, mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' @@ -621,7 +622,12 @@ class ClientService extends EventTarget { const n = normalizeUrl(u) || u if (n) authorReadSet.add(n) } + for (const u of list?.httpRead ?? []) { + const n = normalizeHttpRelayUrl(u) || u + if (n) authorReadSet.add(n) + } } + authorReadSet = new Set(filterContextAuthorReadRelaysForPublish([...authorReadSet])) } const favSet = new Set(