diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index db956c44..7bc0014c 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useMuteListOptional } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' +import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -85,6 +86,7 @@ export default function ContentPreview({ forParentReplyBlurb?: boolean }) { const { t } = useTranslation() + const noteTr = useNoteTranslation(event?.id ?? '') const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER) const muteList = useMuteListOptional() const mutePubkeySet = muteList?.mutePubkeySet ?? new Set() @@ -120,11 +122,13 @@ export default function ContentPreview({ ) } + const previewEvent = mergeTranslatedNote(event, noteTr) + const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className) const withKindRow = (node: React.ReactNode) => (
- +
{node}
) @@ -139,92 +143,92 @@ export default function ContentPreview({ ExtendedKind.PUBLIC_MESSAGE ].includes(event.kind) ) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.DISCUSSION) { return (
- +
- +
) } if (event.kind === kinds.Highlights) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.WEB_BOOKMARK) { - const href = getWebBookmarkArticleUrl(event) - const title = event.tags.find((t) => t[0] === 'title')?.[1]?.trim() + const href = getWebBookmarkArticleUrl(previewEvent) + const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() const line = title?.trim() || href?.trim() || t('Web bookmark') return withKindRow(
{line}
) } if (event.kind === ExtendedKind.POLL) { if (forParentReplyBlurb) { - const snippet = parentReplyPollQuestionBlurb(event.content ?? '') + const snippet = parentReplyPollQuestionBlurb(previewEvent.content ?? '') return (
{snippet || t('Poll')}
) } - return withKindRow() + return withKindRow() } if (event.kind === kinds.LongFormArticle) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.PICTURE) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.GROUP_METADATA) { - return withKindRow() + return withKindRow() } if (event.kind === kinds.CommunityDefinition) { - return withKindRow() + return withKindRow() } if (event.kind === kinds.LiveEvent) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.ZAP_REQUEST) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) { if (previewDensity === 'compact') { return (
- +
) } - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { - return withKindRow() + return withKindRow() } if (event.kind === ExtendedKind.FOLLOW_PACK) { - return withKindRow() + return withKindRow() } if ( @@ -232,7 +236,7 @@ export default function ContentPreview({ event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_RELEASE ) { - return withKindRow() + return withKindRow() } if (isNip25ReactionKind(event.kind)) { @@ -249,7 +253,7 @@ export default function ContentPreview({ {DISCUSSION_DOWNVOTE_DISPLAY} ) : ( - + )} {t(notificationReactionSummaryKey(reactionDisplay))} @@ -270,5 +274,5 @@ export default function ContentPreview({ ) } - return withKindRow(
[{t('Cannot handle event of kind k', { k: event.kind })}]
) + return withKindRow(
[{t('Cannot handle event of kind k', { k: previewEvent.kind })}]
) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index a0890c52..bef2e85a 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -998,10 +998,7 @@ function parseMarkdownContentLegacy( } // Blockquotes (> text or >) and Greentext (>text with no space) else if (line.match(/^>\s*/)) { - // Check if this is greentext: >text with no space after > - // Pattern: > followed immediately by non-whitespace, non-> character - const greentextMatch = line.match(/^>([^\s>].*)$/) - const isGreentext = greentextMatch !== null + const isGreentext = isMarkdownGreentextLine(line.trim()) // Collect consecutive blockquote/greentext lines const blockquoteLines: string[] = [] @@ -1012,8 +1009,7 @@ function parseMarkdownContentLegacy( while (blockquoteLineIdx < lines.length) { const blockquoteLine = lines[blockquoteLineIdx] - const lineGreentextMatch = blockquoteLine.match(/^>([^\s>].*)$/) - const lineIsGreentext = lineGreentextMatch !== null + const lineIsGreentext = isMarkdownGreentextLine(blockquoteLine.trim()) if (blockquoteLine.match(/^>\s*/)) { // If we started with greentext, only continue if this line is also greentext @@ -1025,8 +1021,9 @@ function parseMarkdownContentLegacy( break } - // Strip the > prefix and optional space - const content = blockquoteLine.replace(/^>\s?/, '') + const content = lineIsGreentext + ? stripMarkdownGreentextMarker(blockquoteLine.trim()) + : blockquoteLine.replace(/^>\s?/, '') blockquoteLines.push(content) blockquoteLineIdx++ tempIndex += blockquoteLine.length + 1 // +1 for newline @@ -2481,7 +2478,7 @@ function parseMarkdownContentLegacy( parts.push( {greentextContent} @@ -3113,6 +3110,20 @@ function parseMarkdownContentLegacy( return { nodes: wrappedParts, hashtagsInContent, footnotes, citations } } +/** + * Block **quote** (sidebar): `> ` — `>` then ASCII space (CommonMark). + * **Greentext** (4chan-style): `>` not followed by that space, e.g. `>implying` vs `> implying`. + */ +function isMarkdownGreentextLine(trimmedLine: string): boolean { + if (!trimmedLine.startsWith('>')) return false + if (trimmedLine.length < 2) return false + return /^>(?! ).+$/.test(trimmedLine) +} + +function stripMarkdownGreentextMarker(trimmedLine: string): string { + return trimmedLine.replace(/^>(?! )/, '') +} + /** * Marked-driven markdown renderer (standard markdown blocks/inline), while keeping * Nostr-specific enrichments (embeds, wikilinks, relay/profile navigation) custom. @@ -4224,13 +4235,17 @@ function parseMarkdownContentMarked( case 'blockquote': { const rawLines = String(token.raw ?? '') .split('\n') + .map((line) => line.replace(/\r$/u, '')) .filter((line) => line.trim().length > 0) const isGreentext = - rawLines.length > 0 && rawLines.every((line) => /^>([^\s>].*)$/.test(line.trim())) + rawLines.length > 0 && rawLines.every((line) => isMarkdownGreentextLine(line.trim())) if (isGreentext) { - const lines = rawLines.map((line) => line.replace(/^>\s?/, '')) + const lines = rawLines.map((line) => stripMarkdownGreentextMarker(line.trim())) nodes.push( -
+
{lines.map((line, idx) => ( {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-gt-inline-${idx}`)} diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index c03088fe..1afc3fbd 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -15,7 +15,6 @@ import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/ import { clearNoteTranslation, getNoteTranslation, - setNoteTranslation, subscribeNoteTranslations } from '@/lib/note-translation-display' import { speakNoteReadAloud } from '@/lib/read-aloud' @@ -60,7 +59,7 @@ import { nip19 } from 'nostr-tools' import { articleHasTranslatableTitle, eventHasTranslatableTextBody, - translateNoteForDisplay + translateNoteAndRelatedForDisplay } from '@/lib/translate-note-for-menu' import { fetchTranslateLanguages, @@ -892,14 +891,12 @@ export function useMenuActions({ onClick: () => { closeDrawer() void toast.promise( - translateNoteForDisplay(event, opt.code).then((out) => { - setNoteTranslation(event.id, { - lang: opt.code, - langLabel: languageSelectSingleLine(opt.code), - content: out.content, - title: out.title - }) - }), + translateNoteAndRelatedForDisplay( + event, + opt.code, + languageSelectSingleLine(opt.code), + (id) => client.fetchEvent(id) + ), { loading: t('Translating note…'), success: t('Note translated', { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6e3a2a14..b19fa090 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -171,6 +171,12 @@ export default { "Read this note aloud": "Read this note aloud", "Read-aloud is not supported in this browser": "Read-aloud is not supported in this browser", "Nothing to read aloud": "Nothing to read aloud", + "Read aloud quoted from": "Quoted from {{name}}.", + "Read aloud unknown author": "Unknown author", + "Read aloud embedded note unavailable": "Quoted note not loaded.", + "Read aloud nostr profile unavailable": "Nostr profile reference.", + "Read aloud relay reference": "Nostr relay reference.", + "Read aloud nostr reference unavailable": "Nostr reference.", "Read-aloud failed": "Read-aloud failed", "Read aloud": "Read aloud", "Read-aloud idle": "Idle", diff --git a/src/index.css b/src/index.css index 1f416802..1e990320 100644 --- a/src/index.css +++ b/src/index.css @@ -596,6 +596,17 @@ margin: 0.25rem 0; } +/* Tailwind Typography `prose` sets body/link colors; keep greentext green inside note prose. */ +.prose .greentext, +.prose-invert .greentext { + color: #4a7c3a; +} + +.dark .prose-invert .greentext, +.dark .prose .greentext { + color: #8fbc8f; +} + /* Lightbox (yet-another-react-lightbox) caption title: wrap on narrow screens instead of truncating */ .yarl__slide_title { white-space: normal !important; diff --git a/src/lib/advanced-lab-markup-protect.test.ts b/src/lib/advanced-lab-markup-protect.test.ts index e84d68ed..fb6195fa 100644 --- a/src/lib/advanced-lab-markup-protect.test.ts +++ b/src/lib/advanced-lab-markup-protect.test.ts @@ -10,6 +10,17 @@ vi.mock('@/lib/translate-client', () => ({ })) describe('getMarkupProtectRanges', () => { + it('kind-1 editor promo: most of body stays translatable (no runaway freeze before CodeMirror)', () => { + const t = + "I added an advanced editor to #imwald. I'm testing it, locally, and it seems like some sort of miracle. CodeMirror for Markdown and Asciidoc, grammar and spelling checks for all languages with my own instance of https://languagetool.org/, translations with LibreTranslate that can handle markup, citation event helper, Latex helper, custom emoji support, undo button, local autosave and recovery, npub/naddr/nevent mention, etc." + const merged = getMarkupProtectRanges(t, 'markdown') + const cm = t.indexOf('CodeMirror') + expect(cm).toBeGreaterThan(0) + expect(rangeIntersectsMerged(cm, 'CodeMirror'.length, merged)).toBe(false) + const frozen = merged.reduce((acc, [a, b]) => acc + (b - a), 0) + expect(frozen).toBeLessThan(t.length * 0.25) + }) + it('freezes ATX heading marker and following whitespace', () => { const merged = getMarkupProtectRanges('# Hello', 'markdown') expect(merged.some(([a, b]) => a === 0 && b === 2)).toBe(true) @@ -240,10 +251,39 @@ describe('translateAdvancedLabMarkup', () => { expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1', 'Line2']) }) + it('optional preserveEmbeddedNewlinesInTranslatable sends one translatePlainText per translatable segment', async () => { + const { translatePlainText } = await import('@/lib/translate-client') + const spy = vi.mocked(translatePlainText) + spy.mockClear() + spy.mockImplementation(async (s: string) => `<<${s}>>`) + await translateAdvancedLabMarkup('Line1\nLine2', 'de', 'en', 'markdown', { + preserveEmbeddedNewlinesInTranslatable: true + }) + expect(spy.mock.calls.map((c) => c[0])).toEqual(['Line1\nLine2']) + }) + it('preserves blank lines between translated lines', async () => { const { translatePlainText } = await import('@/lib/translate-client') vi.mocked(translatePlainText).mockImplementation(async (s: string) => (s === 'A' ? 'Aa' : 'Bb')) const out = await translateAdvancedLabMarkup('A\n\nB', 'de', 'en', 'markdown') expect(out).toBe('Aa\n\nBb') }) + + it('markdown: every blockquote line body is passed to translate (regression: middle line not frozen)', async () => { + const content = + 'Far-right and far-left logic be like:\n\n' + + '> Stephen cheered when John was fired.\n' + + '> We do not like Stephen.\n' + + '> John should not have been fired.\n\n' + + 'This is a logical argument the Germans call _Beifall von der falschen Seite._ Where you judge.' + const { translatePlainText } = await import('@/lib/translate-client') + const spy = vi.mocked(translatePlainText) + spy.mockClear() + spy.mockImplementation(async (s: string) => `[${s}]`) + await translateAdvancedLabMarkup(content, 'de', 'en', 'markdown') + const payloads = spy.mock.calls.map((c) => String(c[0])) + expect(payloads.some((p) => p.includes('We do not like Stephen'))).toBe(true) + expect(payloads.some((p) => p.includes('Stephen cheered when John was fired'))).toBe(true) + expect(payloads.some((p) => p.includes('John should not have been fired'))).toBe(true) + }) }) diff --git a/src/lib/advanced-lab-markup-protect.ts b/src/lib/advanced-lab-markup-protect.ts index e648d6ad..68dc30f0 100644 --- a/src/lib/advanced-lab-markup-protect.ts +++ b/src/lib/advanced-lab-markup-protect.ts @@ -13,6 +13,15 @@ import { translatePlainText } from '@/lib/translate-client' export type AdvancedLabMarkupMode = 'markdown' | 'asciidoc' +export type TranslateAdvancedLabMarkupOptions = { + /** + * When true, translatable segments are sent in a single `translatePlainText` call per segment, + * preserving embedded newlines. Default splits on newlines so LibreTranslate cannot drop a line; + * coalesced Markdown blockquote runs use this so the model sees full quote context in one `q`. + */ + preserveEmbeddedNewlinesInTranslatable?: boolean +} + function mergeSortedRanges(ranges: [number, number][]): [number, number][] { if (ranges.length === 0) return [] const s = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]) @@ -953,8 +962,12 @@ function buildSegments(text: string, merged: [number, number][]): Seg[] { async function translatePreservingLineBreaks( text: string, targetLang: string, - sourceLang: string + sourceLang: string, + opts?: TranslateAdvancedLabMarkupOptions ): Promise { + if (opts?.preserveEmbeddedNewlinesInTranslatable && text.trim() !== '') { + return translatePlainText(text, targetLang, sourceLang) + } const pieces = text.split(/(\r?\n+)/) const out: string[] = [] for (const p of pieces) { @@ -976,11 +989,12 @@ export async function translateAdvancedLabMarkup( text: string, targetLang: string, sourceLang: string, - mode: AdvancedLabMarkupMode + mode: AdvancedLabMarkupMode, + options?: TranslateAdvancedLabMarkupOptions ): Promise { const merged = getMarkupProtectRanges(text, mode) if (merged.length === 0) { - return translatePreservingLineBreaks(text, targetLang, sourceLang) + return translatePreservingLineBreaks(text, targetLang, sourceLang, options) } const segs = buildSegments(text, merged) const parts = await Promise.all( @@ -988,7 +1002,7 @@ export async function translateAdvancedLabMarkup( const chunk = text.slice(s.start, s.end) if (!s.translatable) return chunk if (!chunk) return chunk - return translatePreservingLineBreaks(chunk, targetLang, sourceLang) + return translatePreservingLineBreaks(chunk, targetLang, sourceLang, options) }) ) return parts.join('') diff --git a/src/lib/note-translation-display.ts b/src/lib/note-translation-display.ts index a1f17ae4..99b8f89b 100644 --- a/src/lib/note-translation-display.ts +++ b/src/lib/note-translation-display.ts @@ -9,6 +9,11 @@ export type NoteTranslationEntry = { content: string /** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */ title?: string + /** + * Related notes (parent preview, embedded) translated in the same action as this note. + * Cleared together when the user chooses “show original” on this note. + */ + coTranslatedIds?: string[] } const map = new Map() @@ -29,6 +34,12 @@ export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry) } export function clearNoteTranslation(eventId: string): void { + const entry = map.get(eventId) + if (entry?.coTranslatedIds?.length) { + for (const id of entry.coTranslatedIds) { + map.delete(id) + } + } map.delete(eventId) emit() } diff --git a/src/lib/read-aloud-content-language.test.ts b/src/lib/read-aloud-content-language.test.ts new file mode 100644 index 00000000..1dac1ea9 --- /dev/null +++ b/src/lib/read-aloud-content-language.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { detectReadAloudContentLanguage } from '@/lib/read-aloud-content-language' + +describe('detectReadAloudContentLanguage', () => { + it('detects German from umlauts', () => { + expect(detectReadAloudContentLanguage('Grüße aus München und Spaß')).toBe('de') + }) + + it('detects plain ASCII English', () => { + expect(detectReadAloudContentLanguage('Hello this is a test note about nothing special.')).toBe('en') + }) + + it('prefers British English when UK spellings dominate', () => { + const s = + 'The colour of the behaviour was odd; we organised a favourable defence of the centre.' + expect(detectReadAloudContentLanguage(s)).toBe('en-gb') + }) + + it('prefers US English when American spellings dominate', () => { + const s = 'The color and behavior at the center were organized with a traveling neighbor.' + expect(detectReadAloudContentLanguage(s)).toBe('en') + }) + + it('detects Russian from Cyrillic', () => { + expect(detectReadAloudContentLanguage('Привет мир это тест')).toBe('ru') + }) + + it('detects Polish', () => { + expect(detectReadAloudContentLanguage('Cześć, to jest żółć i łódź.')).toBe('pl') + }) + + it('detects Turkish from distinctive letters (avoid ü so German is not triggered)', () => { + expect(detectReadAloudContentLanguage('Merhaba, dağ ve şeker \u0130stanbul.')).toBe('tr') + }) +}) diff --git a/src/lib/read-aloud-content-language.ts b/src/lib/read-aloud-content-language.ts new file mode 100644 index 00000000..d5d70eb1 --- /dev/null +++ b/src/lib/read-aloud-content-language.ts @@ -0,0 +1,69 @@ +/** + * Heuristic language guess for read-aloud / Piper when there is no persisted translation `lang`. + * Keep in sync with `services/piper-tts-proxy/server.ts` `detectLanguage` (same script / ratio logic), + * plus `en-gb` hints and a few extra Latin scripts (pl, cs, tr) that have Piper voices in-app. + */ +export function detectReadAloudContentLanguage(text: string): string { + if (!text || text.length === 0) return 'en' + + const sample = text.slice(0, Math.min(500, text.length)) + const total = sample.length || 1 + + const germanChars = (sample.match(/[äöüßÄÖÜ]/g) || []).length + const frenchChars = (sample.match(/[éèêëàâäçôùûüÉÈÊËÀÂÄÇÔÙÛÜ]/g) || []).length + const spanishChars = (sample.match(/[ñáéíóúüÑÁÉÍÓÚÜ¿¡]/g) || []).length + const italianChars = (sample.match(/[àèéìòùÀÈÉÌÒÙ]/g) || []).length + const cyrillicChars = (sample.match(/[а-яёА-ЯЁ]/g) || []).length + const hangulChars = (sample.match(/[\uac00-\ud7af]/g) || []).length + const kanaChars = (sample.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length + const hanChars = (sample.match(/[\u4e00-\u9fff]/g) || []).length + const arabicChars = (sample.match(/[\u0600-\u06ff]/g) || []).length + const polishChars = (sample.match(/[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g) || []).length + const czechChars = (sample.match(/[řůŘŮ]/g) || []).length + /** Exclude üöç (shared with German / French); rely on ğ/ı/ş/İ so “Grüße” is not Turkish. */ + const turkishChars = (sample.match(/[ğĞıİşŞ]/g) || []).length + + const cyrillicRatio = cyrillicChars / total + const hangulRatio = hangulChars / total + const kanaRatio = kanaChars / total + const hanRatio = hanChars / total + const arabicRatio = arabicChars / total + const germanRatio = germanChars / total + const frenchRatio = frenchChars / total + const spanishRatio = spanishChars / total + const italianRatio = italianChars / total + const polishRatio = polishChars / total + const czechRatio = czechChars / total + const turkishRatio = turkishChars / total + + if (cyrillicRatio > 0.1) return 'ru' + if (hangulRatio > 0.06 || kanaRatio > 0.02) return 'en' + if (hanRatio > 0.1) return 'zh' + if (arabicRatio > 0.1) return 'ar' + if (germanRatio > 0.02) return 'de' + if (frenchRatio > 0.02) return 'fr' + /** Before Spanish: shared letters like `ó` (Polish) would otherwise count as Spanish. */ + if (polishRatio > 0.02) return 'pl' + if (czechRatio > 0.015) return 'cs' + if (spanishRatio > 0.02) return 'es' + if (italianRatio > 0.02) return 'it' + if (turkishRatio > 0.02) return 'tr' + + if (preferBritishEnglish(sample)) { + return 'en-gb' + } + return 'en' +} + +/** Weak signal: UK spellings vs US spellings when the rest looks like Latin “English”. */ +function preferBritishEnglish(sample: string): boolean { + const uk = + /\b(colour|behaviour|realise|realising|centre|defence|favour|favourite|organised|travelling|neighbour|humour|labour)\b/gi + const us = + /\b(color|behavior|realize|realizing|center|defense|favor|favorite|organized|traveling|neighbor|humor|labor)\b/gi + let ukN = 0 + let usN = 0 + for (const _ of sample.matchAll(uk)) ukN++ + for (const _ of sample.matchAll(us)) usN++ + return ukN > usN +} diff --git a/src/lib/read-aloud-nostr-expand.test.ts b/src/lib/read-aloud-nostr-expand.test.ts new file mode 100644 index 00000000..d6da7f8f --- /dev/null +++ b/src/lib/read-aloud-nostr-expand.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' +import type { Event } from 'nostr-tools' +import client from '@/services/client.service' +import { expandNostrReferencesForReadAloud } from '@/lib/read-aloud' + +vi.mock('@/i18n', () => ({ + default: { + t: (key: string, opts?: { name?: string }) => { + const table: Record = { + 'Read aloud unknown author': 'Unknown author', + 'Read aloud embedded note unavailable': 'Quoted note not loaded.', + 'Read aloud nostr profile unavailable': 'Nostr profile reference.', + 'Read aloud relay reference': 'Nostr relay reference.', + 'Read aloud nostr reference unavailable': 'Nostr reference.' + } + if (key === 'Read aloud quoted from') { + return `Quoted from ${opts?.name ?? ''}.` + } + return table[key] ?? key + } + }, + normalizeToSupportedAppLanguage: (c: string) => c as 'en', + LocalizedLanguageNames: {} as Record<'en', string> +})) + +vi.mock('@/services/client.service', () => ({ + default: { + peekSessionCachedEvent: vi.fn(), + eventService: { + getSessionMetadataForPubkey: vi.fn() + } + } +})) + +const peek = vi.mocked(client.peekSessionCachedEvent) +const getMeta = vi.mocked(client.eventService.getSessionMetadataForPubkey) + +describe('expandNostrReferencesForReadAloud', () => { + beforeEach(() => { + peek.mockReset() + getMeta.mockReset() + }) + + it('leaves plain text unchanged', () => { + expect(expandNostrReferencesForReadAloud('hello world')).toBe('hello world') + }) + + it('replaces nostr:note with placeholder when event is not cached', () => { + const sk = generateSecretKey() + const ev = finalizeEvent({ kind: 1, content: 'x', tags: [], created_at: 1 }, sk) + const note = nip19.noteEncode(ev.id) + peek.mockReturnValue(undefined) + expect(expandNostrReferencesForReadAloud(`See nostr:${note} please`)).toBe('See Quoted note not loaded. please') + }) + + it('replaces nostr:note with quoted-from line and body when cached', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const inner = finalizeEvent({ kind: 1, content: 'Inner **bold**', tags: [], created_at: 2 }, sk) + const note = nip19.noteEncode(inner.id) + peek.mockImplementation((id: string) => { + if (id === note || id === inner.id) return inner as Event + return undefined + }) + const profile = finalizeEvent( + { + kind: 0, + pubkey: pk, + content: JSON.stringify({ display_name: 'Pat' }), + tags: [], + created_at: 1 + }, + sk + ) as Event + getMeta.mockImplementation((hex: string) => (hex.toLowerCase() === pk.toLowerCase() ? profile : undefined)) + const out = expandNostrReferencesForReadAloud(`Quote: nostr:${note} end`) + expect(out).toContain('Quoted from Pat.') + expect(out).toContain('Inner bold') + expect(out).not.toContain('nostr:') + }) + + it('uses unknown author when embedded note is cached but profile is not', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const inner = finalizeEvent({ kind: 1, content: 'Hi', tags: [], created_at: 2 }, sk) + const note = nip19.noteEncode(inner.id) + peek.mockImplementation((id: string) => (id === note || id === inner.id ? (inner as Event) : undefined)) + getMeta.mockReturnValue(undefined) + const out = expandNostrReferencesForReadAloud(`nostr:${note}`) + expect(out).toMatch(/^Quoted from Unknown author\. Hi$/) + }) + + it('replaces nostr:npub with display name when kind 0 is cached', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const profile = finalizeEvent( + { + kind: 0, + pubkey: pk, + content: JSON.stringify({ name: 'n_name' }), + tags: [], + created_at: 1 + }, + sk + ) as Event + const npub = nip19.npubEncode(pk) + getMeta.mockImplementation((hex: string) => (hex.toLowerCase() === pk.toLowerCase() ? profile : undefined)) + expect(expandNostrReferencesForReadAloud(`Hey nostr:${npub}!`)).toBe('Hey n_name!') + }) + + it('replaces nostr:npub with placeholder when profile is not cached', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const npub = nip19.npubEncode(pk) + getMeta.mockReturnValue(undefined) + expect(expandNostrReferencesForReadAloud(`x nostr:${npub} y`)).toBe('x Nostr profile reference. y') + }) + + it('does not match bare npub inside nostr:npub… (no double replacement)', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const npub = nip19.npubEncode(pk) + getMeta.mockReturnValue(undefined) + expect(expandNostrReferencesForReadAloud(`nostr:${npub}`)).toBe('Nostr profile reference.') + }) +}) diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index fdd15db9..741da0a0 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -1,6 +1,7 @@ import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' -import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage } from '@/i18n' +import i18n, { LocalizedLanguageNames } from '@/i18n' import { getNoteTranslation } from '@/lib/note-translation-display' +import { detectReadAloudContentLanguage } from '@/lib/read-aloud-content-language' import { getPiperVoiceForChosenLanguage, isTrinityLanguageCode, @@ -14,10 +15,19 @@ import { } from '@/lib/piper-tts-cache-policy' import indexedDb from '@/services/indexed-db.service' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' -import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import { + BECH32_NADDR, + BECH32_NEVENT, + BECH32_NOTE, + BECH32_NPROFILE, + BECH32_NPUB, + NOSTR_URI_INLINE_REGEX +} from '@/lib/content-patterns' +import { getLongFormArticleMetadataFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { normalizeTranslateLangCode } from '@/lib/translate-client' -import { Event, kinds } from 'nostr-tools' +import client from '@/services/client.service' +import { Event, kinds, nip19 } from 'nostr-tools' /** Keep each Piper request small: long JSON bodies and WAV responses can OOM or time out the server. */ const PIPER_CHUNK_MAX_CHARS = 3600 @@ -303,6 +313,122 @@ function buildReadAloudPlainText(event: Event): string { return stripMarkupForReadAloud(raw) } +/** Bare bech32 not prefixed with `nostr:` (negative lookbehind avoids matching inside `nostr:…`). */ +const READ_ALOUD_BARE_BECH32_REGEX = new RegExp( + `(?): boolean { + return Boolean( + (p.original_username && p.original_username.trim()) || (p.nip05 && String(p.nip05).trim()) + ) +} + +function readAloudProfileLabelForPubkey(hexPubkey: string): string | null { + const meta = client.eventService.getSessionMetadataForPubkey(hexPubkey) + if (!meta) return null + const p = getProfileFromEvent(meta) + if (!readAloudHasRichProfile(p)) return null + try { + const o = JSON.parse(meta.content || '{}') as { display_name?: string; name?: string } + const fromJson = (o.display_name?.trim() || o.name?.trim()) ?? '' + if (fromJson) return fromJson + } catch { + /* ignore */ + } + return (p.original_username || p.nip05 || '').trim() || null +} + +function readAloudAuthorNameForQuote(hexPubkey: string): string { + return readAloudProfileLabelForPubkey(hexPubkey) ?? i18n.t('Read aloud unknown author') +} + +function replacementForNostrBech32ReadAloud(bech32: string, depth: number): string { + if (depth > READ_ALOUD_NOSTR_EXPAND_MAX_DEPTH) { + return i18n.t('Read aloud nostr reference unavailable') + } + const trimmed = bech32.trim() + try { + const decoded = nip19.decode(trimmed) + switch (decoded.type) { + case 'note': + case 'nevent': + case 'naddr': { + const ev = client.peekSessionCachedEvent(trimmed) + if (!ev) { + return i18n.t('Read aloud embedded note unavailable') + } + const quotedFrom = i18n.t('Read aloud quoted from', { + name: readAloudAuthorNameForQuote(ev.pubkey) + }) + const body = buildReadAloudPlainText(ev) + return `${quotedFrom} ${expandNostrReferencesForReadAloud(body, depth + 1)}` + } + case 'npub': + case 'nprofile': { + const pk = decoded.type === 'npub' ? decoded.data : decoded.data.pubkey + const label = readAloudProfileLabelForPubkey(pk) + if (label) return label + return i18n.t('Read aloud nostr profile unavailable') + } + default: { + if ((decoded as { type: string }).type === 'nrelay') { + return i18n.t('Read aloud relay reference') + } + return i18n.t('Read aloud nostr reference unavailable') + } + } + } catch { + return i18n.t('Read aloud nostr reference unavailable') + } +} + +function replaceAllNostrUriInline(text: string, depth: number): string { + const re = new RegExp(NOSTR_URI_INLINE_REGEX.source, NOSTR_URI_INLINE_REGEX.flags) + const matches = [...text.matchAll(re)] + matches.sort((a, b) => (b.index ?? 0) - (a.index ?? 0)) + let out = text + for (const m of matches) { + const idx = m.index + const full = m[0] + const inner = m[1] + if (idx === undefined || !full || !inner) continue + const replacement = replacementForNostrBech32ReadAloud(inner, depth) + out = out.slice(0, idx) + replacement + out.slice(idx + full.length) + } + return out +} + +function replaceAllBareNostrBech32(text: string, depth: number): string { + const matches = [...text.matchAll(READ_ALOUD_BARE_BECH32_REGEX)] + matches.sort((a, b) => (b.index ?? 0) - (a.index ?? 0)) + let out = text + for (const m of matches) { + const idx = m.index + const full = m[0] + if (idx === undefined || !full) continue + const replacement = replacementForNostrBech32ReadAloud(full, depth) + out = out.slice(0, idx) + replacement + out.slice(idx + full.length) + } + return out +} + +/** + * Expands `nostr:` links and bare Nostr bech32 using session cache only (no network). + * Exported for unit tests. + */ +export function expandNostrReferencesForReadAloud(text: string, depth = 0): string { + if (depth > READ_ALOUD_NOSTR_EXPAND_MAX_DEPTH) { + return text + } + let out = replaceAllNostrUriInline(text, depth) + out = replaceAllBareNostrBech32(out, depth) + return out +} + function isAbortError(e: unknown): boolean { return ( (e instanceof DOMException && e.name === 'AbortError') || @@ -719,14 +845,17 @@ export async function speakNoteReadAloud(event: Event): Promise } else { text = buildReadAloudPlainText(event) } + text = expandNostrReferencesForReadAloud(text.trim()) if (!text) { return 'empty' } const title = readAloudTitleFromEvent(event) - const chosenReadAloudLang: string = - persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en') + /** Persisted translate action carries an explicit target `lang`; otherwise infer from body (Piper + Web Speech). */ + const chosenReadAloudLang: string = persistedTranslation?.lang?.trim() + ? persistedTranslation.lang.trim() + : detectReadAloudContentLanguage(text) const { voice: piperVoice, usedEnglishVoiceFallback, diff --git a/src/lib/translate-client.ts b/src/lib/translate-client.ts index 581da2fc..75e25ca2 100644 --- a/src/lib/translate-client.ts +++ b/src/lib/translate-client.ts @@ -153,6 +153,15 @@ export async function translatePlainText( if (!base) { throw new Error('Translation URL not configured') } + + /** LibreTranslate often trims `q` / `translatedText`; keep edge whitespace so markup segments still join cleanly. */ + const leadingWs = text.match(/^\s*/u)?.[0] ?? '' + const trailingWs = text.match(/\s*$/u)?.[0] ?? '' + const core = text.slice(leadingWs.length, text.length - trailingWs.length) + if (core === '') { + return text + } + const resolvedTarget = translateApiLanguageCode(targetLang) const resolvedSource = sourceLang === 'auto' ? 'auto' : translateApiLanguageCode(sourceLang) @@ -171,17 +180,17 @@ export async function translatePlainText( ) } - const key = cacheKey(text, resolvedSource, resolvedTarget) + const key = cacheKey(core, resolvedSource, resolvedTarget) const hit = memoryCache.get(key) if (hit && Date.now() - hit.at < CACHE_TTL_MS) { logger.info('[AdvancedLab] translate', { source: resolvedSource, target: resolvedTarget, inputChars: text.length, - outputChars: hit.text.length, + outputChars: hit.text.length + leadingWs.length + trailingWs.length, cacheHit: true }) - return hit.text + return leadingWs + hit.text + trailingWs } const url = `${base}/translate` @@ -189,7 +198,7 @@ export async function translatePlainText( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - q: text, + q: core, source: resolvedSource, target: resolvedTarget, format: 'text' @@ -204,15 +213,15 @@ export async function translatePlainText( ) } const data = (await res.json()) as { translatedText?: string } - const out = data.translatedText ?? '' + const outCore = data.translatedText ?? '' pruneMemory() - memoryCache.set(key, { text: out, at: Date.now() }) + memoryCache.set(key, { text: outCore, at: Date.now() }) logger.info('[AdvancedLab] translate', { source: resolvedSource, target: resolvedTarget, inputChars: text.length, - outputChars: out.length, + outputChars: leadingWs.length + outCore.length + trailingWs.length, cacheHit: false }) - return out + return leadingWs + outCore + trailingWs } diff --git a/src/lib/translate-note-for-menu.test.ts b/src/lib/translate-note-for-menu.test.ts new file mode 100644 index 00000000..cd93f91c --- /dev/null +++ b/src/lib/translate-note-for-menu.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Event } from 'nostr-tools' +import { translateNoteForDisplay } from '@/lib/translate-note-for-menu' + +const BLOCKQUOTE_THREE_LINES = + 'Intro paragraph.\n\n' + + '> Stephen cheered when John was fired.\n' + + '> We do not like Stephen.\n' + + '> John should not have been fired.\n\n' + + 'After quote.' + +vi.mock('@/lib/translate-client', () => ({ + /** Identity so chunk assembly can be asserted without delimiter noise from the mock. */ + translatePlainText: vi.fn(async (text: string) => text), + normalizeTranslateLangCode: (c: string) => c, + translateApiLanguageCode: (c: string) => c, + translateServerSupportsLogicalTarget: () => true, + isTranslateConfigured: () => true, + fetchTranslateLanguages: vi.fn(async () => []), + clearTranslateLanguagesCache: vi.fn() +})) + +function kind1Event(content: string): Event { + return { + id: '0'.repeat(64), + pubkey: '1'.repeat(64), + kind: 1, + content, + tags: [], + created_at: 0, + sig: '' + } as Event +} + +describe('translateNoteForDisplay', () => { + afterEach(async () => { + const { translatePlainText } = await import('@/lib/translate-client') + vi.mocked(translatePlainText).mockImplementation(async (text: string) => text) + }) + + it('keeps blank lines across chunk boundaries (list → paragraph)', async () => { + const fill = 'word '.repeat(520) + const content = `${fill.trimEnd()}\n\nFINAL_LINE_UNIQUE` + expect(content.length).toBeGreaterThan(2500) + + const out = await translateNoteForDisplay(kind1Event(content), 'fr') + expect(out.content).toContain('\n\nFINAL_LINE_UNIQUE') + }) + + it('keeps markdown bullet list, blank line, and paragraph across chunk splits', async () => { + const long = 'x'.repeat(700) + const tail = 'z'.repeat(2200) + const content = `- ${long}\n- y\n\nPARA_MARKER${tail}` + expect(content.length).toBeGreaterThan(2500) + + const out = await translateNoteForDisplay(kind1Event(content), 'fr') + expect(out.content).toContain('\n\nPARA_MARKER') + expect(out.content).toContain('- y') + expect(out.content).toContain('PARA_MARKER') + expect(out.content).toBe(content) + }) + + it('coalesces consecutive Markdown blockquote bodies into one translatePlainText (embedded newlines)', async () => { + const { translatePlainText } = await import('@/lib/translate-client') + const spy = vi.mocked(translatePlainText) + spy.mockClear() + spy.mockImplementation(async (s: string) => `[${s}]`) + const ev = { + id: '0'.repeat(64), + pubkey: '1'.repeat(64), + kind: 1, + content: BLOCKQUOTE_THREE_LINES, + tags: [], + created_at: 0, + sig: '' + } as Event + await translateNoteForDisplay(ev, 'de') + const payloads = spy.mock.calls.map((c) => String(c[0])) + const merged = payloads.find( + (p) => p.includes('Stephen cheered when John was fired') && p.includes('We do not like Stephen') + ) + expect(merged).toBeDefined() + expect(merged).toContain('John should not have been fired') + }) +}) diff --git a/src/lib/translate-note-for-menu.ts b/src/lib/translate-note-for-menu.ts index 7926838f..75f88636 100644 --- a/src/lib/translate-note-for-menu.ts +++ b/src/lib/translate-note-for-menu.ts @@ -4,12 +4,122 @@ import { translateAdvancedLabMarkup, type AdvancedLabMarkupMode } from '@/lib/advanced-lab-markup-protect' +import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import { getParentEventHexId } from '@/lib/event' +import { setNoteTranslation } from '@/lib/note-translation-display' import { normalizeTranslateLangCode } from '@/lib/translate-client' -import type { Event } from 'nostr-tools' +import { nip19, type Event } from 'nostr-tools' const CHUNK_MAX = 2500 +/** GFM-style blockquote line (indent, `>`, optional space, body). */ +const MD_BLOCKQUOTE_LINE = /^([\t ]{0,3})(> ?)(.*)$/ + +function isMarkdownFenceDelimiterLine(line: string): boolean { + return /^[\t ]{0,3}```/.test(line.replace(/\r$/u, '')) +} + +/** + * LibreTranslate can leave an isolated middle line in English when each `>` line is translated + * separately. Coalesce consecutive blockquote bodies (outside fenced code) into one request with + * embedded newlines preserved via {@link translateAdvancedLabMarkup} options. + */ +async function translateMarkdownBodyCoalescingBlockquotes(text: string, target: string): Promise { + const lines = text.split(/\r?\n/) + let inFence = false + type PlainSeg = { type: 'plain'; lines: string[] } + type BqSeg = { type: 'bq'; lines: string[] } + type Seg = PlainSeg | BqSeg + const segments: Seg[] = [] + + const pushPlainLine = (ln: string): void => { + const last = segments[segments.length - 1] + if (last?.type === 'plain') last.lines.push(ln) + else segments.push({ type: 'plain', lines: [ln] }) + } + + let i = 0 + while (i < lines.length) { + const line = lines[i]! + if (isMarkdownFenceDelimiterLine(line)) { + inFence = !inFence + pushPlainLine(line) + i++ + continue + } + if (inFence) { + pushPlainLine(line) + i++ + continue + } + const m = line.match(MD_BLOCKQUOTE_LINE) + if (m) { + const runLines: string[] = [] + while (i < lines.length) { + if (isMarkdownFenceDelimiterLine(lines[i]!)) break + const m2 = lines[i]!.match(MD_BLOCKQUOTE_LINE) + if (!m2) break + runLines.push(lines[i]!) + i++ + } + segments.push({ type: 'bq', lines: runLines }) + continue + } + pushPlainLine(line) + i++ + } + + const outs: string[] = [] + for (const seg of segments) { + if (seg.type === 'plain') { + const joined = seg.lines.join('\n') + outs.push(joined === '' ? '' : await translateAdvancedLabMarkup(joined, target, 'auto', 'markdown')) + continue + } + const runLines = seg.lines + const prefixes: string[] = [] + const bodies: string[] = [] + for (const ln of runLines) { + const mm = ln.match(MD_BLOCKQUOTE_LINE)! + prefixes.push(mm[1]! + mm[2]!) + bodies.push(mm[3] ?? '') + } + if (bodies.length === 0) continue + if (bodies.length === 1) { + const tb = await translateAdvancedLabMarkup(bodies[0]!, target, 'auto', 'markdown') + outs.push(`${prefixes[0]}${tb}`) + continue + } + const joinedBodies = bodies.join('\n') + const translatedJoined = await translateAdvancedLabMarkup(joinedBodies, target, 'auto', 'markdown', { + preserveEmbeddedNewlinesInTranslatable: true + }) + const outLines = translatedJoined.split(/\r?\n/) + if (outLines.length !== bodies.length) { + const perLine = await Promise.all( + bodies.map((b) => translateAdvancedLabMarkup(b, target, 'auto', 'markdown')) + ) + outs.push(prefixes.map((pref, idx) => `${pref}${perLine[idx]}`).join('\n')) + } else { + outs.push(prefixes.map((pref, idx) => `${pref}${outLines[idx] ?? ''}`).join('\n')) + } + } + return outs.join('\n') +} + +async function translateBodyChunk( + core: string, + target: string, + markupMode: AdvancedLabMarkupMode +): Promise { + if (core.trim() === '') return '' + if (markupMode === 'markdown') { + return translateMarkdownBodyCoalescingBlockquotes(core, target) + } + return translateAdvancedLabMarkup(core, target, 'auto', markupMode) +} + function looksLikeStringifiedJsonObject(content: string): boolean { const trimmed = content.trim() if ( @@ -40,7 +150,13 @@ export function articleHasTranslatableTitle(event: Event): boolean { return Boolean(getLongFormArticleMetadataFromEvent(event).title?.trim()) } -/** Same exclusions as the advanced lab (`translateAdvancedLabMarkup`). Chunk large bodies for the API. */ +/** + * Same exclusions as the advanced lab (`translateAdvancedLabMarkup`). Chunk large bodies for the API. + * + * Trailing whitespace/newlines on a chunk must not be dropped when advancing `rest` (they are not + * re-sent on the next iteration). Do not `trimStart()` the remainder or blank lines after lists and + * paragraph breaks vanish from the output. + */ async function translateLongProtectedBody( text: string, target: string, @@ -49,7 +165,7 @@ async function translateLongProtectedBody( const t = text.trim() if (!t) return text if (t.length <= CHUNK_MAX) { - return translateAdvancedLabMarkup(t, target, 'auto', markupMode) + return translateBodyChunk(t, target, markupMode) } const blocks: string[] = [] let rest = t @@ -59,13 +175,20 @@ async function translateLongProtectedBody( if (nl > 600) { slice = rest.slice(0, nl + 1) } - const part = slice.trimEnd() - if (part) { - blocks.push(await translateAdvancedLabMarkup(part, target, 'auto', markupMode)) + let endCore = slice.length + while (endCore > 0 && /\s/u.test(slice[endCore - 1]!)) { + endCore-- } - rest = rest.slice(slice.length).trimStart() + const core = slice.slice(0, endCore) + const trailingLiteral = slice.slice(endCore) + const translated = + core.trim() === '' + ? '' + : await translateBodyChunk(core, target, markupMode) + blocks.push(translated + trailingLiteral) + rest = rest.slice(slice.length) } - return blocks.join('\n') + return blocks.join('') } /** @@ -88,3 +211,92 @@ export async function translateNoteForDisplay( : rawContent return { content: content || rawContent, title } } + +/** + * Parent (`e` reply) and `nostr:…` embeds in the body — same scope as prefetch, but not every thread `e` tag. + */ +export function collectRelatedNoteTranslateTargets(event: Event): { + hexIds: string[] + nip19Pointers: string[] +} { + const hexSet = new Set() + const nip19Set = new Set() + const self = event.id.toLowerCase() + const addHex = (id: string | undefined) => { + if (!id) return + const h = id.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(h) && h !== self) hexSet.add(h) + } + + addHex(getParentEventHexId(event)) + + const body = event.content ?? '' + for (const full of body.match(EMBEDDED_EVENT_REGEX) ?? []) { + const colon = full.indexOf(':') + if (colon < 0) continue + const bech32 = full.slice(colon + 1).trim() + if (!bech32) continue + try { + const { type, data } = nip19.decode(bech32) + if (type === 'note') addHex(data) + else if (type === 'nevent') addHex(data.id) + else if (type === 'naddr') nip19Set.add(bech32) + } catch { + /* ignore */ + } + } + + return { hexIds: Array.from(hexSet), nip19Pointers: Array.from(nip19Set) } +} + +/** + * Translates the note body/title and any reply-parent / embedded notes shown with it, then updates the translation store. + */ +export async function translateNoteAndRelatedForDisplay( + event: Event, + targetCode: string, + langLabel: string, + fetchEvent: (id: string) => Promise +): Promise { + const mainOut = await translateNoteForDisplay(event, targetCode) + const { hexIds, nip19Pointers } = collectRelatedNoteTranslateTargets(event) + const coIds: string[] = [] + const seenRel = new Set() + const self = event.id.toLowerCase() + + const translateRelated = async (rel: Event) => { + const idl = rel.id.toLowerCase() + if (idl === self || seenRel.has(idl)) return + if (!eventHasTranslatableTextBody(rel) && !articleHasTranslatableTitle(rel)) return + seenRel.add(idl) + try { + const out = await translateNoteForDisplay(rel, targetCode) + setNoteTranslation(rel.id, { + lang: targetCode, + langLabel, + content: out.content, + title: out.title + }) + coIds.push(rel.id) + } catch { + seenRel.delete(idl) + } + } + + for (const hex of hexIds) { + const rel = await fetchEvent(hex) + if (rel) await translateRelated(rel) + } + for (const ptr of nip19Pointers) { + const rel = await fetchEvent(ptr) + if (rel) await translateRelated(rel) + } + + setNoteTranslation(event.id, { + lang: targetCode, + langLabel, + content: mainOut.content, + title: mainOut.title, + coTranslatedIds: coIds.length > 0 ? coIds : undefined + }) +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 1bb09136..9519c099 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -147,12 +147,27 @@ export class EventService { /** * Read parent/root (or any) event from the session cache without removing it. - * Accepts hex, note1, or nevent1 (not naddr). + * Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only). */ peekSessionCachedEvent(noteId: string): NEvent | undefined { - const hex = this.resolveHexWaiterKey(noteId.trim()) - if (!hex) return undefined - return this.getSessionEventIfAllowed(hex) + const trimmed = noteId.trim() + const hex = this.resolveHexWaiterKey(trimmed) + if (hex) { + return this.getSessionEventIfAllowed(hex) + } + try { + const { type, data } = nip19.decode(trimmed) + if (type === 'naddr') { + return this.getSessionEventIfMatchingNaddr({ + pubkey: data.pubkey, + kind: data.kind, + identifier: data.identifier + }) + } + } catch { + /* invalid */ + } + return undefined } /**