From ea6cde2f6a8340b564c6b665168813c06dcfdb79 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Sun, 15 Feb 2026 15:58:36 -0800 Subject: [PATCH] On-device translation --- package.json | 1 + src/analytics/metrics/types.ts | 3 + src/components/Post/TranslatedPost.tsx | 77 ++++++++++ .../PostControls/PostMenu/PostMenuItems.tsx | 2 +- src/lib/hooks/useTranslate.ts | 135 ++++++++++++------ src/lib/hooks/useTranslate.web.ts | 16 +++ .../components/ThreadItemAnchor.tsx | 58 ++++++-- src/state/translation.ts | 45 ++++++ src/view/com/post/Post.tsx | 2 + src/view/com/posts/PostFeedItem.tsx | 2 + yarn.lock | 51 ++----- 11 files changed, 296 insertions(+), 96 deletions(-) create mode 100644 src/components/Post/TranslatedPost.tsx create mode 100644 src/lib/hooks/useTranslate.web.ts create mode 100644 src/state/translation.ts diff --git a/package.json b/package.json index 0c49dc24da..c3457f050e 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "expo-splash-screen": "~31.0.12", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", + "expo-translate-text": "^0.1.0", "expo-updates": "~29.0.14", "expo-video": "~3.0.15", "expo-video-thumbnails": "^10.0.8", diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 65f2a5d39f..b6a2e1f958 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -667,6 +667,9 @@ export type Events = { targetLanguage: string textLength: number } + 'translate:result': { + method: 'on-device' | 'google-translate' | 'fallback-alert' + } 'verification:create': {} 'verification:revoke': {} diff --git a/src/components/Post/TranslatedPost.tsx b/src/components/Post/TranslatedPost.tsx new file mode 100644 index 0000000000..5b25cd074e --- /dev/null +++ b/src/components/Post/TranslatedPost.tsx @@ -0,0 +1,77 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {codeToLanguageName} from '#/locale/helpers' +import {useTranslationState} from '#/state/translation' +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function TranslatedPost({ + postUri, + hideLoading, +}: { + postUri: string + hideLoading?: boolean +}) { + const state = useTranslationState(postUri) + + if (state.status === 'loading' && !hideLoading) { + return + } + + if (state.status === 'success') { + return ( + + ) + } + + return null +} + +function TranslationLoading() { + const t = useTheme() + + return ( + + + + Translating... + + + ) +} + +function TranslationResult({ + translatedText, + sourceLanguage, +}: { + translatedText: string + sourceLanguage: string +}) { + const t = useTheme() + const {i18n} = useLingui() + + const langName = sourceLanguage + ? codeToLanguageName(sourceLanguage, i18n.locale) + : undefined + + return ( + + + {langName ? ( + Translated from {langName} + ) : ( + Translated + )} + + + {translatedText} + + + ) +} diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 0dc204534c..f4bfec0055 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -258,7 +258,7 @@ let PostMenuItems = ({ } const onPressTranslate = () => { - translate(record.text, langPrefs.primaryLanguage) + translate(record.text, langPrefs.primaryLanguage, {postUri: post.uri}) if ( bsky.dangerousIsType( diff --git a/src/lib/hooks/useTranslate.ts b/src/lib/hooks/useTranslate.ts index 822452bb3b..2c19edef5b 100644 --- a/src/lib/hooks/useTranslate.ts +++ b/src/lib/hooks/useTranslate.ts @@ -1,54 +1,109 @@ import {useCallback} from 'react' -import * as IntentLauncher from 'expo-intent-launcher' +import {Alert} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {getTranslatorLink} from '#/locale/helpers' -import {IS_ANDROID} from '#/env' +import {logger} from '#/logger' +import {setTranslationState} from '#/state/translation' +import {useAnalytics} from '#/analytics' import {useOpenLink} from './useOpenLink' +/** + * Attempts on-device translation via expo-translate-text. + * Uses a lazy require to avoid crashing if the native module + * isn't linked into the current build. + */ +async function attemptTranslation( + text: string, + language: string, + postUri: string, +): Promise { + const {onTranslateTask} = + require('expo-translate-text') as typeof import('expo-translate-text') + const result = await onTranslateTask({ + input: text, + targetLangCode: language, + }) + + setTranslationState(postUri, { + status: 'success', + translatedText: + typeof result.translatedTexts === 'string' ? result.translatedTexts : '', + sourceLanguage: result.sourceLanguage ?? '', + }) +} + +/** + * Native translation hook. Attempts on-device translation using + * Apple Translation (iOS 18+) or Google ML Kit (Android). + * Falls back to Google Translate URL if the language pack is + * unavailable or the user declines to download it. + * + * Web uses useTranslate.web.ts which always opens Google Translate. + */ export function useTranslate() { const openLink = useOpenLink() + const {_} = useLingui() + const ax = useAnalytics() return useCallback( - async (text: string, language: string) => { - const translateUrl = getTranslatorLink(text, language) - if (IS_ANDROID) { - try { - // use getApplicationIconAsync to determine if the translate app is installed - if ( - !(await IntentLauncher.getApplicationIconAsync( - 'com.google.android.apps.translate', - )) - ) { - throw new Error('Translate app not installed') - } - - // TODO: this should only be called one at a time, use something like - // RQ's `scope` - otherwise can trigger the browser to open unexpectedly when the call throws -sfn - await IntentLauncher.startActivityAsync( - 'android.intent.action.PROCESS_TEXT', - { - type: 'text/plain', - extra: { - 'android.intent.extra.PROCESS_TEXT': text, - 'android.intent.extra.PROCESS_TEXT_READONLY': true, - }, - // note: to skip the intermediate app select, we need to specify a - // `className`. however, this isn't safe to hardcode, we'd need to query the - // package manager for the correct activity. this requires native code, so - // skip for now -sfn - // packageName: 'com.google.android.apps.translate', - // className: 'com.google.android.apps.translate.TranslateActivity', - }, - ) - } catch (err) { - if (__DEV__) console.error(err) - // most likely means they don't have the translate app - await openLink(translateUrl) - } - } else { + async (text: string, language: string, opts?: {postUri?: string}) => { + // No postUri means non-post context (e.g. DMs) — open Google Translate + if (!opts?.postUri) { + const translateUrl = getTranslatorLink(text, language) await openLink(translateUrl) + return + } + + const postUri = opts.postUri + setTranslationState(postUri, {status: 'loading'}) + + try { + await attemptTranslation(text, language, postUri) + ax.metric('translate:result', {method: 'on-device'}) + } catch (err) { + logger.error('Failed to translate post', {safeMessage: err}) + setTranslationState(postUri, {status: 'idle'}) + + // On-device translation failed (language pack missing or user + // dismissed the download prompt). Show options to retry or + // fall back to Google Translate. + ax.metric('translate:result', {method: 'fallback-alert'}) + Alert.alert( + _(msg`Translation unavailable`), + _(msg`The required language pack is not installed on your device.`), + [ + { + text: _(msg`Download language pack`), + onPress: async () => { + setTranslationState(postUri, {status: 'loading'}) + try { + await attemptTranslation(text, language, postUri) + ax.metric('translate:result', {method: 'on-device'}) + } catch (retryErr) { + logger.error('Failed to translate post', { + safeMessage: retryErr, + }) + setTranslationState(postUri, {status: 'idle'}) + } + }, + }, + { + text: _(msg`Use Google Translate`), + onPress: () => { + ax.metric('translate:result', {method: 'google-translate'}) + openLink(getTranslatorLink(text, language)) + }, + }, + { + text: _(msg`Cancel`), + style: 'cancel', + }, + ], + ) } }, - [openLink], + [_, ax, openLink], ) } diff --git a/src/lib/hooks/useTranslate.web.ts b/src/lib/hooks/useTranslate.web.ts new file mode 100644 index 0000000000..5d65f0f321 --- /dev/null +++ b/src/lib/hooks/useTranslate.web.ts @@ -0,0 +1,16 @@ +import {useCallback} from 'react' + +import {getTranslatorLink} from '#/locale/helpers' +import {useOpenLink} from './useOpenLink' + +export function useTranslate() { + const openLink = useOpenLink() + + return useCallback( + async (text: string, language: string, _opts?: {postUri?: string}) => { + const translateUrl = getTranslatorLink(text, language) + await openLink(translateUrl) + }, + [openLink], + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index e475b42de1..36f7ede194 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -30,6 +30,7 @@ import {type ThreadItem} from '#/state/queries/usePostThread/types' import {useSession} from '#/state/session' import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {clearTranslation, useTranslationState} from '#/state/translation' import {type PostSource} from '#/state/unstable-post-source' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' @@ -45,11 +46,13 @@ import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {InlineLinkText, Link} from '#/components/Link' +import {Loader} from '#/components/Loader' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' +import {TranslatedPost} from '#/components/Post/TranslatedPost' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {useFormatPostStatCount} from '#/components/PostControls/util' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -412,6 +415,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ shouldProxyLinks={true} /> ) : undefined} + {post.embed && ( { e.preventDefault() - translate(post.record.text || '', langPrefs.primaryLanguage) + translate(post.record.text || '', langPrefs.primaryLanguage, { + postUri: post.uri, + }) if ( bsky.dangerousIsType( @@ -581,6 +589,15 @@ function ExpandedPostDetails({ [ax, translate, langPrefs, post], ) + const onHideTranslation = useCallback( + (e: GestureResponderEvent) => { + e.preventDefault() + clearTranslation(post.uri) + return false + }, + [post.uri], + ) + return ( @@ -597,18 +614,33 @@ function ExpandedPostDetails({ · - - Translate - + {translationState.status === 'loading' ? ( + + + + Translating... + + + ) : translationState.status === 'success' ? ( + + Hide translation + + ) : ( + + Translate + + )} )} diff --git a/src/state/translation.ts b/src/state/translation.ts new file mode 100644 index 0000000000..c9927fca58 --- /dev/null +++ b/src/state/translation.ts @@ -0,0 +1,45 @@ +/** + * Per-post translation state store. Uses the EventEmitter + Map pattern + * (same as post-shadow.ts) so only components subscribed to a specific + * post URI re-render when its translation state changes. + */ +import {useEffect, useState} from 'react' +import EventEmitter from 'eventemitter3' + +export type TranslationState = + | {status: 'idle'} + | {status: 'loading'} + | {status: 'success'; translatedText: string; sourceLanguage: string} + +const IDLE: TranslationState = {status: 'idle'} + +const emitter = new EventEmitter() +const translations = new Map() + +export function setTranslationState(postUri: string, state: TranslationState) { + translations.set(postUri, state) + emitter.emit(postUri) +} + +export function clearTranslation(postUri: string) { + translations.delete(postUri) + emitter.emit(postUri) +} + +export function useTranslationState(postUri: string): TranslationState { + const [state, setState] = useState( + () => translations.get(postUri) ?? IDLE, + ) + + useEffect(() => { + function onUpdate() { + setState(translations.get(postUri) ?? IDLE) + } + emitter.addListener(postUri, onUpdate) + return () => { + emitter.removeListener(postUri, onUpdate) + } + }, [postUri]) + + return state +} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 67eebc5ef8..1630866afd 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {PostRepliedTo} from '#/components/Post/PostRepliedTo' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' +import {TranslatedPost} from '#/components/Post/TranslatedPost' import {PostControls} from '#/components/PostControls' import {RichText} from '#/components/RichText' import {SubtleHover} from '#/components/SubtleHover' @@ -217,6 +218,7 @@ function PostInner({ )} ) : undefined} + {post.embed ? ( ) : undefined} + {postEmbed ? (