Fix translations on Android using PROCESS_TEXT intent (#8486)

* use intents to translate text on android

* clean up config plugins

* restore day night plugin

just to be safe

* leave a comment for why we can't open translate directly

* add todo

* fix lockfile lint
This commit is contained in:
Samuel Newman
2025-08-11 18:37:39 +03:00
committed by GitHub
parent 2a6172cbaf
commit bb949e4f44
10 changed files with 120 additions and 53 deletions
@@ -19,6 +19,7 @@ import {useNavigation} from '@react-navigation/native'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {
@@ -28,7 +29,6 @@ import {
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -118,6 +118,7 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const translate = useTranslate()
const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
@@ -172,11 +173,6 @@ let PostMenuItems = ({
return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = () => {
deletePostMutate({uri: postUri}).then(
() => {
@@ -234,8 +230,8 @@ let PostMenuItems = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
const onPressTranslate = async () => {
await openLink(translatorUrl, true)
const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
+4 -9
View File
@@ -5,9 +5,8 @@ import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
@@ -39,7 +38,7 @@ export let MessageContextMenu = ({
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const translate = useTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
@@ -57,11 +56,7 @@ export let MessageContextMenu = ({
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
translate(message.text, langPrefs.primaryLanguage)
logger.metric(
'translate',
@@ -72,7 +67,7 @@ export let MessageContextMenu = ({
},
{statsig: false},
)
}, [langPrefs.primaryLanguage, message.text, openLink])
}, [langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+54
View File
@@ -0,0 +1,54 @@
import {useCallback} from 'react'
import * as IntentLauncher from 'expo-intent-launcher'
import {getTranslatorLink} from '#/locale/helpers'
import {isAndroid} from '#/platform/detection'
import {useOpenLink} from './useOpenLink'
export function useTranslate() {
const openLink = useOpenLink()
return useCallback(
async (text: string, language: string) => {
const translateUrl = getTranslatorLink(text, language)
if (isAndroid) {
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 {
await openLink(translateUrl)
}
},
[openLink],
)
}
@@ -12,7 +12,7 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -509,14 +509,10 @@ function ExpandedPostDetails({
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const translate = useTranslate()
const isRootPost = !('reply' in post.record)
const langPrefs = useLanguagePrefs()
const translatorUrl = getTranslatorLink(
post.record?.text || '',
langPrefs.primaryLanguage,
)
const needsTranslation = useMemo(
() =>
Boolean(
@@ -529,7 +525,7 @@ function ExpandedPostDetails({
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
openLink(translatorUrl, true)
translate(post.record.text || '', langPrefs.primaryLanguage)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -546,7 +542,7 @@ function ExpandedPostDetails({
return false
},
[openLink, translatorUrl, langPrefs, post],
[translate, langPrefs, post],
)
return (
@@ -566,7 +562,12 @@ function ExpandedPostDetails({
</Text>
<InlineLinkText
to={translatorUrl}
// overridden to open an intent on android, but keep
// as anchor tag for accessibility
to={getTranslatorLink(
post.record.text,
langPrefs.primaryLanguage,
)}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
+10 -12
View File
@@ -19,8 +19,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {usePalette} from '#/lib/hooks/usePalette'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -273,10 +273,6 @@ let PostThreadItemLoaded = ({
const showFollowButton =
currentAccount?.did !== post.author.did && !onlyFollowersCanReply
const translatorUrl = getTranslatorLink(
record?.text || '',
langPrefs.primaryLanguage,
)
const needsTranslation = useMemo(
() =>
Boolean(
@@ -477,8 +473,8 @@ let PostThreadItemLoaded = ({
</ContentHider>
<ExpandedPostDetails
post={post}
record={record}
isThreadAuthor={isThreadAuthor}
translatorUrl={translatorUrl}
needsTranslation={needsTranslation}
/>
{post.repostCount !== 0 ||
@@ -824,26 +820,26 @@ function PostOuterWrapper({
function ExpandedPostDetails({
post,
record,
isThreadAuthor,
needsTranslation,
translatorUrl,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
isThreadAuthor: boolean
needsTranslation: boolean
translatorUrl: string
}) {
const t = useTheme()
const pal = usePalette('default')
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const translate = useTranslate()
const isRootPost = !('reply' in post.record)
const langPrefs = useLanguagePrefs()
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
openLink(translatorUrl, true)
translate(record.text || '', langPrefs.primaryLanguage)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -864,7 +860,7 @@ function ExpandedPostDetails({
return false
},
[openLink, translatorUrl, langPrefs, post],
[translate, record.text, langPrefs, post],
)
return (
@@ -884,7 +880,9 @@ function ExpandedPostDetails({
</Text>
<InlineLinkText
to={translatorUrl}
// overridden to open an intent on android, but keep
// as anchor tag for accessibility
to={getTranslatorLink(record.text, langPrefs.primaryLanguage)}
label={_(msg`Translate`)}
style={[a.text_sm, pal.link]}
onPress={onTranslatePress}>