diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 0df11ce7..480d9cf8 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -27,6 +27,7 @@ import { muteSetHas } from '@/lib/mute-set' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' +import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { @@ -139,6 +140,9 @@ export default function Note({ const [postEditorOpen, setPostEditorOpen] = useState(false) const [publicMessageTo, setPublicMessageTo] = useState(null) const [callInviteContent, setCallInviteContent] = useState(null) + const noteTranslation = useNoteTranslation(event.id) + const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation]) + const reactionDisplay = useNotificationReactionDisplay(event) const webReactionParentUrl = useMemo( () => @@ -188,7 +192,7 @@ export default function Note({ hideMetadata?: boolean className?: string } = {}) => { - if (isStringifiedJsonContent(event.content)) { + if (isStringifiedJsonContent(displayEvent.content)) { return (
-            {event.content}
+            {displayEvent.content}
           
) } - if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) { + if (ASCIIDOC_CONTENT_KINDS.has(displayEvent.kind)) { return ( ) @@ -212,21 +216,21 @@ export default function Note({ return ( ) }, - [event, fullCalendarInvite, autoLoadMedia] + [displayEvent, fullCalendarInvite, autoLoadMedia] ) let content: React.ReactNode if (!isRenderableNoteKind(event.kind)) { logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind) - content = + content = } else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) { content = setShowMuted(true)} /> } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { @@ -234,11 +238,11 @@ export default function Note({ } else if (isNip25ReactionKind(event.kind)) { content = null } else if (isNip18RepostKind(event.kind) || event.kind === ExtendedKind.POLL_RESPONSE) { - content = + content = } else if (event.kind === kinds.Highlights) { // Try to render the Highlight component with error boundary try { - content = + content = } catch (error) { logger.error('Note component - Error rendering Highlight component:', error) content =
@@ -249,8 +253,8 @@ export default function Note({
} } else if (event.kind === ExtendedKind.WEB_BOOKMARK) { - const href = getWebBookmarkArticleUrl(event) - const title = event.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() + const href = getWebBookmarkArticleUrl(displayEvent) + const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() content = ( <> {title ? ( @@ -269,43 +273,43 @@ export default function Note({ ) : null} - {event.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} + {displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE) { content = showFull ? ( renderEventContent() ) : ( - + ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { content = showFull ? ( renderEventContent() ) : ( - + ) } else if (event.kind === ExtendedKind.PUBLICATION) { content = showFull ? ( - + ) : ( - + ) } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( renderEventContent() ) : ( - + ) } else if (event.kind === kinds.LongFormArticle) { content = renderEventContent({ hideMetadata: true }) } else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { - content = + content = } else if (event.kind === ExtendedKind.GROUP_METADATA) { - content = + content = } else if (event.kind === kinds.CommunityDefinition) { - content = + content = } else if (event.kind === ExtendedKind.DISCUSSION) { - const titleTag = event.tags.find(tag => tag[0] === 'title') + const titleTag = displayEvent.tags.find(tag => tag[0] === 'title') const title = titleTag?.[1] || 'Untitled Discussion' content = ( <> @@ -319,12 +323,12 @@ export default function Note({ event.kind === ExtendedKind.CITATION_HARDCOPY || event.kind === ExtendedKind.CITATION_PROMPT ) { - content = + content = } else if (event.kind === ExtendedKind.POLL) { content = ( <> {renderEventContent({ hideMetadata: true })} - + ) } else if (event.kind === ExtendedKind.ZAP_POLL) { @@ -333,7 +337,7 @@ export default function Note({ {renderEventContent({ hideMetadata: true })} @@ -357,21 +361,21 @@ export default function Note({ } else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) { content = } else if (event.kind === ExtendedKind.RELAY_REVIEW) { - content = + content = } else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) { - content = + content = } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = renderEventContent({ hideMetadata: true }) } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { - content = + content = } else if (event.kind === ExtendedKind.FOLLOW_PACK) { - content = + content = } else if ( event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT || event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_RELEASE ) { - content = + content = } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT) { content = renderEventContent({ hideMetadata: true }) } else { @@ -381,7 +385,7 @@ export default function Note({ const isSyntheticRssParent = isRssThreadSyntheticParentEvent(event) const wrappedContent = isHighlightableKind ? ( - {content} + {content} ) : ( content ) diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 3bf1990d..b52a4182 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -12,6 +12,12 @@ import { type PublicationSectionRef } from '@/lib/publication-section-fetch' import { normalizeAnyRelayUrl, normalizeHttpRelayUrl, simplifyUrl } from '@/lib/url' +import { + clearNoteTranslation, + getNoteTranslation, + setNoteTranslation, + subscribeNoteTranslations +} from '@/lib/note-translation-display' import { speakNoteReadAloud } from '@/lib/read-aloud' import { buildPinListTagsAfterToggle, @@ -46,11 +52,19 @@ import { Trash2, TriangleAlert, Video, - Volume2 + Volume2, + Languages } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { nip19 } from 'nostr-tools' -import { useMemo, useState, useEffect, useRef, useContext } from 'react' +import { + articleHasTranslatableTitle, + eventHasTranslatableTextBody, + translateNoteForDisplay +} from '@/lib/translate-note-for-menu' +import { isTranslateConfigured } from '@/lib/translate-client' +import { LocalizedLanguageNames, SUPPORTED_APP_LANGUAGE_CODES, type TLanguage } from '@/i18n' +import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import RelayIcon from '../RelayIcon' @@ -141,7 +155,13 @@ export function useMenuActions({ }, []) const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event]) - + + const noteTranslationFromMenu = useSyncExternalStore( + subscribeNoteTranslations, + () => getNoteTranslation(event.id), + () => getNoteTranslation(event.id) + ) + // Check if event is pinned const [isPinned, setIsPinned] = useState(false) @@ -808,6 +828,50 @@ export function useMenuActions({ const d = encodeURIComponent(dTag) window.open(`https://decentnewsroom.com/p/${p}/d/${d}`, '_blank', 'noopener,noreferrer') } + + const noteSupportsTranslateMenu = + isTranslateConfigured() && + (eventHasTranslatableTextBody(event) || articleHasTranslatableTitle(event)) + + const translateTargetSubmenu: SubMenuAction[] = noteSupportsTranslateMenu + ? [ + { + label: t('Show original text'), + onClick: () => { + closeDrawer() + clearNoteTranslation(event.id) + toast.success(t('Showing original note text')) + } + }, + ...SUPPORTED_APP_LANGUAGE_CODES.map( + (code: TLanguage, i): SubMenuAction => ({ + label: LocalizedLanguageNames[code], + separator: i === 0, + onClick: () => { + closeDrawer() + void toast.promise( + translateNoteForDisplay(event, code).then((out) => { + setNoteTranslation(event.id, { + lang: code, + content: out.content, + title: out.title + }) + }), + { + loading: t('Translating note…'), + success: t('Note translated', { language: LocalizedLanguageNames[code] }), + error: (err: unknown) => + t('Note translation failed', { + message: err instanceof Error ? err.message : String(err) + }) + } + ) + } + }) + ) + ] + : [] + const actions: MenuAction[] = [ { icon: Copy, @@ -845,6 +909,18 @@ export function useMenuActions({ } as MenuAction ] : []), + ...(noteSupportsTranslateMenu + ? [ + { + icon: Languages, + label: t('Translate note'), + onClick: isSmallScreen + ? () => showSubMenuActions(translateTargetSubmenu, t('Translate note')) + : undefined, + subMenu: isSmallScreen ? undefined : translateTargetSubmenu + } as MenuAction + ] + : []), ...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage ? [ { @@ -1152,7 +1228,8 @@ export function useMenuActions({ onOpenCallInvite, onOpenEditOrClone, canSignEvents, - profile + profile, + noteTranslationFromMenu ]) return menuActions diff --git a/src/components/ReadAloudPlayerModal.tsx b/src/components/ReadAloudPlayerModal.tsx index 7a2ff651..5034cb4d 100644 --- a/src/components/ReadAloudPlayerModal.tsx +++ b/src/components/ReadAloudPlayerModal.tsx @@ -115,6 +115,19 @@ export default function ReadAloudPlayerModal(): JSX.Element {

{snap.title}

) : null}

{phaseLabel(snap, t)}

+ {snap.piperUsedEnglishVoiceFallback && snap.piperVoiceRequestedLanguageName ? ( +
+

{t('Read-aloud Piper English voice fallback title')}

+

+ {t('Read-aloud Piper English voice fallback detail', { + language: snap.piperVoiceRequestedLanguageName + })} +

+
+ ) : null} {snap.engine === 'piper' ? (

{t('TTS endpoint')}: {snap.backend || '—'} diff --git a/src/components/Settings/SettingsMenuBody.tsx b/src/components/Settings/SettingsMenuBody.tsx index 6a45f7c9..582458a0 100644 --- a/src/components/Settings/SettingsMenuBody.tsx +++ b/src/components/Settings/SettingsMenuBody.tsx @@ -4,7 +4,6 @@ import { toPostSettings, toRelaySettings, toCacheSettings, - toTranslation, toWallet, toRssFeedSettings, toPersonalListsSettings @@ -19,7 +18,6 @@ import { Database, Info, KeyRound, - Languages, PencilLine, Rss, Server, @@ -64,15 +62,6 @@ export default function SettingsMenuBody({ className }: { className?: string }) - {!!pubkey && ( - navigateToSettings(toTranslation())}> -

- -
{t('Translation')}
-
- - - )} {!!pubkey && ( navigateToSettings(toWallet())}>
diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 1b1ac567..d32b5dc0 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -30,11 +30,15 @@ export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGU const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[] +/** App UI languages (same set used for “Translate to …” in note menus). */ +export const SUPPORTED_APP_LANGUAGE_CODES: readonly TLanguage[] = supportedLanguages + const localeModules = import.meta.glob<{ default: Resource }>('./locales/*.ts') const localePath = (code: TLanguage): string => `./locales/${code}.ts` -function normalizeToSupported(lng: string): TLanguage { +/** Normalize a browser / i18next language tag to a supported app locale. */ +export function normalizeToSupportedAppLanguage(lng: string): TLanguage { const exact = supportedLanguages.find((s) => lng === s) if (exact) return exact return supportedLanguages.find((s) => lng.startsWith(s)) ?? 'en' @@ -71,7 +75,7 @@ export function initI18n(): Promise { escapeValue: false }, detection: { - convertDetectedLanguage: (lng) => normalizeToSupported(lng) + convertDetectedLanguage: (lng) => normalizeToSupportedAppLanguage(lng) } }) @@ -102,7 +106,7 @@ export function initI18n(): Promise { } }) - const target = normalizeToSupported(i18n.language) + const target = normalizeToSupportedAppLanguage(i18n.language) if (target !== 'en') { await ensureLocaleLoaded(target) await i18n.changeLanguage(target) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3e305589..b7366dae 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -980,6 +980,15 @@ export default { 'Advanced lab translation languages empty': 'Translate service returned no languages (check Docker / LibreTranslate).', 'Advanced lab translation languages error': 'Could not load languages from the translate service.', 'Advanced lab translation same source target': 'Source and target language must differ.', + 'Show original text': 'Show original text', + 'Showing original note text': 'Showing original text for this note.', + 'Translate note': 'Translate note', + 'Translating note…': 'Translating note…', + 'Note translated': 'Translated to {{language}}.', + 'Note translation failed': 'Translation failed: {{message}}', + 'Read-aloud Piper English voice fallback title': 'English voice in use', + 'Read-aloud Piper English voice fallback detail': + 'Piper has no voice model for {{language}}. This session uses the English Piper voice instead; pronunciation may not match the written text.', 'Advanced lab translate not configured': 'Translation URL is not set (VITE_TRANSLATE_URL).', 'Advanced lab translate done': 'Translation inserted into the editor.', 'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)', diff --git a/src/lib/link.ts b/src/lib/link.ts index 850eb976..b71edf92 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -67,7 +67,6 @@ export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => { export const toWallet = () => '/settings/wallet' export const toPostSettings = () => '/settings/posts' export const toGeneralSettings = () => '/settings/general' -export const toTranslation = () => '/settings/translation' export const toRssFeedSettings = () => '/settings/rss-feeds' export const toFollowSetsSettings = () => '/settings/follow-sets' export const toEmojiSetsSettings = () => '/settings/emoji-sets' diff --git a/src/lib/note-translation-display.ts b/src/lib/note-translation-display.ts new file mode 100644 index 00000000..55a8e025 --- /dev/null +++ b/src/lib/note-translation-display.ts @@ -0,0 +1,59 @@ +import type { TLanguage } from '@/i18n' +import type { Event } from 'nostr-tools' +import { useSyncExternalStore } from 'react' + +export type NoteTranslationEntry = { + lang: TLanguage + content: string + /** When present, replaces or inserts a `title` tag (articles, discussions, web bookmarks). */ + title?: string +} + +const map = new Map() +const listeners = new Set<() => void>() + +function emit(): void { + listeners.forEach((l) => l()) +} + +export function subscribeNoteTranslations(onStoreChange: () => void): () => void { + listeners.add(onStoreChange) + return () => listeners.delete(onStoreChange) +} + +export function setNoteTranslation(eventId: string, entry: NoteTranslationEntry): void { + map.set(eventId, entry) + emit() +} + +export function clearNoteTranslation(eventId: string): void { + map.delete(eventId) + emit() +} + +export function getNoteTranslation(eventId: string): NoteTranslationEntry | undefined { + return map.get(eventId) +} + +export function useNoteTranslation(eventId: string): NoteTranslationEntry | undefined { + return useSyncExternalStore( + subscribeNoteTranslations, + () => map.get(eventId), + () => map.get(eventId) + ) +} + +function patchTitleInTagsCopy(tags: string[][], title: string): string[][] { + const out = tags.map((row) => row.slice()) + const i = out.findIndex((r) => r[0] === 'title') + if (i >= 0) out[i] = ['title', title] + else out.unshift(['title', title]) + return out +} + +/** Event with translated `content` / optional `title` tag for body renderers. */ +export function mergeTranslatedNote(event: Event, tr?: NoteTranslationEntry | null): Event { + if (!tr) return event + const tags = tr.title ? patchTitleInTagsCopy(event.tags, tr.title) : event.tags + return { ...event, content: tr.content, tags } +} diff --git a/src/lib/piper-tts-cache-policy.ts b/src/lib/piper-tts-cache-policy.ts index 9b7378ff..93280118 100644 --- a/src/lib/piper-tts-cache-policy.ts +++ b/src/lib/piper-tts-cache-policy.ts @@ -15,15 +15,18 @@ export function getPiperTtsCacheBudget(): { maxEntries: number; maxBytes: number } /** - * Stable key for a Piper request: same URL + text + speed → same audio. + * Stable key for a Piper request: same URL + text + speed + voice → same audio. * Server upgrades / voice changes require a new endpoint URL or speed to bust the cache. */ export async function buildPiperTtsCacheKey( endpointUrl: string, text: string, - speed: number + speed: number, + voice: string ): Promise { - const payload = new TextEncoder().encode(JSON.stringify({ u: endpointUrl, t: text, s: speed })) + const payload = new TextEncoder().encode( + JSON.stringify({ u: endpointUrl, t: text, s: speed, v: voice }) + ) const digest = await crypto.subtle.digest('SHA-256', payload) return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) diff --git a/src/lib/piper-voice-for-app-language.ts b/src/lib/piper-voice-for-app-language.ts new file mode 100644 index 00000000..53af94e1 --- /dev/null +++ b/src/lib/piper-voice-for-app-language.ts @@ -0,0 +1,32 @@ +import type { TLanguage } from '@/i18n' + +/** + * Piper voice ids aligned with the default Wyoming / `piper-tts-proxy` stock models + * (see `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`). + * App locales without a dedicated model use {@link PIPER_FALLBACK_ENGLISH_VOICE}. + */ +const PIPER_VOICE_BY_APP_LANGUAGE: Partial> = { + en: 'en_US-lessac-medium', + de: 'de_DE-thorsten-medium', + fr: 'fr_FR-siwis-medium', + es: 'es_ES-davefx-medium', + ru: 'ru_RU-ruslan-medium', + zh: 'zh_CN-huayan-medium', + pl: 'pl_PL-darkman-medium' +} + +export const PIPER_FALLBACK_ENGLISH_VOICE = 'en_US-lessac-medium' + +export function getPiperVoiceForChosenLanguage(lang: TLanguage): { + voice: string + usedEnglishVoiceFallback: boolean +} { + const v = PIPER_VOICE_BY_APP_LANGUAGE[lang] + if (v) { + return { voice: v, usedEnglishVoiceFallback: false } + } + return { + voice: PIPER_FALLBACK_ENGLISH_VOICE, + usedEnglishVoiceFallback: lang !== 'en' + } +} diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index b834472d..f771c0df 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -1,4 +1,7 @@ import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' +import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n' +import { getNoteTranslation } from '@/lib/note-translation-display' +import { getPiperVoiceForChosenLanguage } from '@/lib/piper-voice-for-app-language' import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' import { buildPiperTtsCacheKey, @@ -66,6 +69,10 @@ export type ReadAloudSnapshot = { readAloudPiperTryStartedAt: number | null volume: number backend: string + /** Piper has no model for the chosen language; the English Piper voice is used instead. */ + piperUsedEnglishVoiceFallback: boolean + /** Display name of the requested language when {@link piperUsedEnglishVoiceFallback} is true. */ + piperVoiceRequestedLanguageName: string } const initialSnapshot: ReadAloudSnapshot = { @@ -88,7 +95,9 @@ const initialSnapshot: ReadAloudSnapshot = { readAloudPiperSkipped: false, readAloudPiperTryStartedAt: null, volume: 1, - backend: '' + backend: '', + piperUsedEnglishVoiceFallback: false, + piperVoiceRequestedLanguageName: '' } let snapshot: ReadAloudSnapshot = { ...initialSnapshot } @@ -295,7 +304,8 @@ async function fetchPiperTtsBlobForChunk( chunkIndex: number, totalChunks: number, text: string, - signal: AbortSignal + signal: AbortSignal, + voice: string ): Promise { const url = READ_ALOUD_TTS_URL if (!url) { @@ -307,7 +317,7 @@ async function fetchPiperTtsBlobForChunk( const budget = getPiperTtsCacheBudget() let cacheKey: string | undefined try { - cacheKey = await buildPiperTtsCacheKey(url, text, speed) + cacheKey = await buildPiperTtsCacheKey(url, text, speed, voice) const hit = await indexedDb.getPiperTtsBlobCache(cacheKey, ttlMs) if (hit && hit.size > 0) { return hit @@ -321,7 +331,7 @@ async function fetchPiperTtsBlobForChunk( response = await fetchWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, speed }), + body: JSON.stringify({ text, speed, voice }), signal, timeoutMs: 120_000 }) @@ -474,7 +484,7 @@ function playPiperBlob(blob: Blob, signal: AbortSignal): Promise<'ok' | 'error' }) } -async function speakViaPiperTtsChunks(chunks: string[]): Promise { +async function speakViaPiperTtsChunks(chunks: string[], piperVoice: string): Promise { stopReadAloudPlayback() readAloudAbort = new AbortController() const signal = readAloudAbort.signal @@ -493,7 +503,7 @@ async function speakViaPiperTtsChunks(chunks: string[]): Promise } const translationOverride = takeReadAloudTranslationForEvent(event.id) - const text = translationOverride - ? stripMarkupForReadAloud(translationOverride) - : buildReadAloudPlainText(event) + const persistedTranslation = getNoteTranslation(event.id) + let text: string + if (translationOverride) { + text = stripMarkupForReadAloud(translationOverride) + } else if (persistedTranslation) { + let raw = persistedTranslation.content.trim() + if (KINDS_WITH_METADATA_TITLE.has(event.kind) && persistedTranslation.title?.trim()) { + raw = `${persistedTranslation.title.trim()}. ${raw}` + } + text = stripMarkupForReadAloud(raw) + } else { + text = buildReadAloudPlainText(event) + } if (!text) { return 'empty' } const title = readAloudTitleFromEvent(event) + const chosenReadAloudLang: TLanguage = + persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en') + const { voice: piperVoice, usedEnglishVoiceFallback } = + getPiperVoiceForChosenLanguage(chosenReadAloudLang) + const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback + ? LocalizedLanguageNames[chosenReadAloudLang] + : '' + if (READ_ALOUD_TTS_URL) { stopReadAloudPlayback() readAloudUserPaused = false @@ -707,12 +738,14 @@ export async function speakNoteReadAloud(event: Event): Promise piperFallbackDetail: null, readAloudPiperSkipped: false, readAloudPiperTryStartedAt: Date.now(), - backend: readAloudEndpointForLog() + backend: readAloudEndpointForLog(), + piperUsedEnglishVoiceFallback: usedEnglishVoiceFallback, + piperVoiceRequestedLanguageName }) await yieldForReadAloudUi() - const piperResult = await speakViaPiperTtsChunks(chunks) + const piperResult = await speakViaPiperTtsChunks(chunks, piperVoice) if (piperResult === 'ok') { return 'ok' } diff --git a/src/lib/translate-note-for-menu.ts b/src/lib/translate-note-for-menu.ts new file mode 100644 index 00000000..7b02851b --- /dev/null +++ b/src/lib/translate-note-for-menu.ts @@ -0,0 +1,93 @@ +import { ExtendedKind } from '@/constants' +import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' +import { + translateAdvancedLabMarkup, + type AdvancedLabMarkupMode +} from '@/lib/advanced-lab-markup-protect' +import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import type { TLanguage } from '@/i18n' +import type { Event } from 'nostr-tools' + +const CHUNK_MAX = 2500 + +/** Map app UI locale codes to LibreTranslate `target` codes where they differ. */ +export function translateTargetFromAppLanguage(code: TLanguage): string { + if (code === 'pt-BR' || code === 'pt-PT') return 'pt' + return code +} + +function looksLikeStringifiedJsonObject(content: string): boolean { + const trimmed = content.trim() + if ( + !(trimmed.startsWith('{') && trimmed.endsWith('}')) && + !(trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + return false + } + try { + const parsed = JSON.parse(trimmed) as unknown + return parsed !== null && typeof parsed === 'object' + } catch { + return false + } +} + +export function eventHasTranslatableTextBody(event: Event): boolean { + const c = event.content?.trim() ?? '' + if (!c) return false + if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) { + return false + } + if (looksLikeStringifiedJsonObject(c)) return false + return true +} + +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. */ +async function translateLongProtectedBody( + text: string, + target: string, + markupMode: AdvancedLabMarkupMode +): Promise { + const t = text.trim() + if (!t) return text + if (t.length <= CHUNK_MAX) { + return translateAdvancedLabMarkup(t, target, 'auto', markupMode) + } + const blocks: string[] = [] + let rest = t + while (rest.length) { + let slice = rest.slice(0, CHUNK_MAX) + const nl = slice.lastIndexOf('\n') + if (nl > 600) { + slice = rest.slice(0, nl + 1) + } + const part = slice.trimEnd() + if (part) { + blocks.push(await translateAdvancedLabMarkup(part, target, 'auto', markupMode)) + } + rest = rest.slice(slice.length).trimStart() + } + return blocks.join('\n') +} + +export async function translateNoteForDisplay( + event: Event, + appLang: TLanguage +): Promise<{ content: string; title?: string }> { + const target = translateTargetFromAppLanguage(appLang) + const markupMode: AdvancedLabMarkupMode = isAsciidocMarkupKind(event.kind) ? 'asciidoc' : 'markdown' + const meta = getLongFormArticleMetadataFromEvent(event) + const origTitle = meta.title?.trim() + const title = origTitle + ? await translateAdvancedLabMarkup(origTitle, target, 'auto', markupMode) + : undefined + const rawContent = event.content ?? '' + const content = rawContent.trim() + ? await translateLongProtectedBody(rawContent, target, markupMode) + : rawContent + return { content: content || rawContent, title } +} diff --git a/src/pages/secondary/TranslationPage/index.tsx b/src/pages/secondary/TranslationPage/index.tsx deleted file mode 100644 index f4885f27..00000000 --- a/src/pages/secondary/TranslationPage/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { RefreshButton } from '@/components/RefreshButton' -import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { forwardRef, useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' - -const TranslationPage = forwardRef( - ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { - const { t } = useTranslation() - const { registerPrimaryPanelRefresh } = usePrimaryNoteView() - const [contentKey, setContentKey] = useState(0) - const bump = useCallback(() => setContentKey((k) => k + 1), []) - - useEffect(() => { - if (!hideTitlebar) { - registerPrimaryPanelRefresh(null) - return - } - registerPrimaryPanelRefresh(bump) - return () => registerPrimaryPanelRefresh(null) - }, [hideTitlebar, registerPrimaryPanelRefresh, bump]) - - return ( - } - > -
-

- {t( - 'To translate notes and other content, use your browser’s built-in translation. For example: right-click the page and choose “Translate to…”, or use the translate icon in the address bar.' - )} -

-
-
- ) - } -) -TranslationPage.displayName = 'TranslationPage' -export default TranslationPage diff --git a/src/routes.tsx b/src/routes.tsx index f52219e3..6f7e57ba 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -33,7 +33,6 @@ const UserEmojiListPageLazy = lazy(() => import('./pages/secondary/UserEmojiList const EmojiSetsSettingsPageLazy = lazy(() => import('./pages/secondary/EmojiSetsSettingsPage')) const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage')) const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage')) -const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage')) const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage')) const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect')) const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage')) @@ -84,7 +83,6 @@ const ROUTES = [ { path: '/settings/wallet', element: SR(WalletPageLazy) }, { path: '/settings/posts', element: SR(PostSettingsPageLazy) }, { path: '/settings/general', element: SR(GeneralSettingsPageLazy) }, - { path: '/settings/translation', element: SR(TranslationPageLazy) }, { path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) }, { path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) }, { path: '/settings/emoji-sets', element: SR(EmojiSetsSettingsPageLazy) }, diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index fe516974..fb2464a9 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -13,7 +13,6 @@ import RelaySettingsPage from '@/pages/secondary/RelaySettingsPage' import WalletPage from '@/pages/secondary/WalletPage' import PostSettingsPage from '@/pages/secondary/PostSettingsPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' -import TranslationPage from '@/pages/secondary/TranslationPage' import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage' import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage' import EmojiSetsSettingsPage from '@/pages/secondary/EmojiSetsSettingsPage' @@ -95,7 +94,6 @@ export class URLParser { 'relays', 'wallet', 'posts', - 'translation', 'rss-feeds', 'follow-sets', 'emoji-sets', @@ -159,8 +157,6 @@ export class ComponentFactory { return React.createElement(PostSettingsPage, { index: 0, hideTitlebar: true }) case 'general': return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true }) - case 'translation': - return React.createElement(TranslationPage, { index: 0, hideTitlebar: true }) case 'rss-feeds': return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true }) case 'follow-sets': @@ -274,7 +270,6 @@ export class NavigationService { if (pathname.includes('/cache')) return 'Cache & offline storage' if (pathname.includes('/wallet')) return 'Wallet Settings' if (pathname.includes('/posts')) return 'Post Settings' - if (pathname.includes('/translation')) return 'Translation Settings' if (pathname.includes('/emoji-sets')) return 'Emoji sets' return 'Settings' }