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 && (
) : undefined}
+
{post.embed ? (