Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by GitHub bb949e4f 2a6172cb

Changed files
+120 -53
plugins
src
components
PostControls
dms
lib
screens
PostThread
view
com
post-thread
+2 -1
app.config.js
··· 234 234 ], 235 235 './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', 236 236 './plugins/withGradleJVMHeapSizeIncrease.js', 237 - './plugins/withAndroidManifestPlugin.js', 237 + './plugins/withAndroidManifestLargeHeapPlugin.js', 238 238 './plugins/withAndroidManifestFCMIconPlugin.js', 239 + './plugins/withAndroidManifestIntentQueriesPlugin.js', 239 240 './plugins/withAndroidStylesAccentColorPlugin.js', 240 241 './plugins/withAndroidDayNightThemePlugin.js', 241 242 './plugins/withAndroidNoJitpackPlugin.js',
+1
package.json
··· 146 146 "expo-image-crop-tool": "^0.1.8", 147 147 "expo-image-manipulator": "~13.1.7", 148 148 "expo-image-picker": "~16.1.4", 149 + "expo-intent-launcher": "^12.1.5", 149 150 "expo-linear-gradient": "~14.1.5", 150 151 "expo-linking": "~7.1.5", 151 152 "expo-localization": "~16.1.5",
+30
plugins/withAndroidManifestIntentQueriesPlugin.js
··· 1 + const {withAndroidManifest} = require('@expo/config-plugins') 2 + 3 + const withProcessTextQuery = config => 4 + // eslint-disable-next-line no-shadow 5 + withAndroidManifest(config, config => { 6 + const manifest = config.modResults.manifest 7 + 8 + // Ensure <queries> stub exists 9 + if (!manifest.queries) manifest.queries = [{}] 10 + const queries = manifest.queries[0] 11 + 12 + queries.intent = queries.intent || [] 13 + 14 + const exists = queries.intent.some( 15 + i => 16 + i.action?.[0]?.$?.['android:name'] === 17 + 'android.intent.action.PROCESS_TEXT', 18 + ) 19 + 20 + if (!exists) { 21 + queries.intent.push({ 22 + action: [{$: {'android:name': 'android.intent.action.PROCESS_TEXT'}}], 23 + data: [{$: {'android:mimeType': 'text/plain'}}], 24 + }) 25 + } 26 + 27 + return config 28 + }) 29 + 30 + module.exports = withProcessTextQuery
plugins/withAndroidManifestPlugin.js plugins/withAndroidManifestLargeHeapPlugin.js
+4 -8
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 19 19 20 20 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 21 21 import {useOpenLink} from '#/lib/hooks/useOpenLink' 22 + import {useTranslate} from '#/lib/hooks/useTranslate' 22 23 import {getCurrentRoute} from '#/lib/routes/helpers' 23 24 import {makeProfileLink} from '#/lib/routes/links' 24 25 import { ··· 28 29 import {logEvent, useGate} from '#/lib/statsig/statsig' 29 30 import {richTextToString} from '#/lib/strings/rich-text-helpers' 30 31 import {toShareUrl} from '#/lib/strings/url-helpers' 31 - import {getTranslatorLink} from '#/locale/helpers' 32 32 import {logger} from '#/logger' 33 33 import {type Shadow} from '#/state/cache/post-shadow' 34 34 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 118 118 const {hidePost} = useHiddenPostsApi() 119 119 const feedFeedback = useFeedFeedbackContext() 120 120 const openLink = useOpenLink() 121 + const translate = useTranslate() 121 122 const navigation = useNavigation<NavigationProp>() 122 123 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 123 124 const blockPromptControl = useDialogControl() ··· 172 173 return makeProfileLink(postAuthor, 'post', urip.rkey) 173 174 }, [postUri, postAuthor]) 174 175 175 - const translatorUrl = getTranslatorLink( 176 - record.text, 177 - langPrefs.primaryLanguage, 178 - ) 179 - 180 176 const onDeletePost = () => { 181 177 deletePostMutate({uri: postUri}).then( 182 178 () => { ··· 234 230 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 235 231 } 236 232 237 - const onPressTranslate = async () => { 238 - await openLink(translatorUrl, true) 233 + const onPressTranslate = () => { 234 + translate(record.text, langPrefs.primaryLanguage) 239 235 240 236 if ( 241 237 bsky.dangerousIsType<AppBskyFeedPost.Record>(
+4 -9
src/components/dms/MessageContextMenu.tsx
··· 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 8 + import {useTranslate} from '#/lib/hooks/useTranslate' 9 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 - import {getTranslatorLink} from '#/locale/helpers' 11 10 import {logger} from '#/logger' 12 11 import {isNative} from '#/platform/detection' 13 12 import {useConvoActive} from '#/state/messages/convo' ··· 39 38 const deleteControl = usePromptControl() 40 39 const reportControl = usePromptControl() 41 40 const langPrefs = useLanguagePrefs() 42 - const openLink = useOpenLink() 41 + const translate = useTranslate() 43 42 44 43 const isFromSelf = message.sender?.did === currentAccount?.did 45 44 ··· 57 56 }, [_, message.text, message.facets]) 58 57 59 58 const onPressTranslateMessage = useCallback(() => { 60 - const translatorUrl = getTranslatorLink( 61 - message.text, 62 - langPrefs.primaryLanguage, 63 - ) 64 - openLink(translatorUrl, true) 59 + translate(message.text, langPrefs.primaryLanguage) 65 60 66 61 logger.metric( 67 62 'translate', ··· 72 67 }, 73 68 {statsig: false}, 74 69 ) 75 - }, [langPrefs.primaryLanguage, message.text, openLink]) 70 + }, [langPrefs.primaryLanguage, message.text, translate]) 76 71 77 72 const onDelete = useCallback(() => { 78 73 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+54
src/lib/hooks/useTranslate.ts
··· 1 + import {useCallback} from 'react' 2 + import * as IntentLauncher from 'expo-intent-launcher' 3 + 4 + import {getTranslatorLink} from '#/locale/helpers' 5 + import {isAndroid} from '#/platform/detection' 6 + import {useOpenLink} from './useOpenLink' 7 + 8 + export function useTranslate() { 9 + const openLink = useOpenLink() 10 + 11 + return useCallback( 12 + async (text: string, language: string) => { 13 + const translateUrl = getTranslatorLink(text, language) 14 + if (isAndroid) { 15 + try { 16 + // use getApplicationIconAsync to determine if the translate app is installed 17 + if ( 18 + !(await IntentLauncher.getApplicationIconAsync( 19 + 'com.google.android.apps.translate', 20 + )) 21 + ) { 22 + throw new Error('Translate app not installed') 23 + } 24 + 25 + // TODO: this should only be called one at a time, use something like 26 + // RQ's `scope` - otherwise can trigger the browser to open unexpectedly when the call throws -sfn 27 + await IntentLauncher.startActivityAsync( 28 + 'android.intent.action.PROCESS_TEXT', 29 + { 30 + type: 'text/plain', 31 + extra: { 32 + 'android.intent.extra.PROCESS_TEXT': text, 33 + 'android.intent.extra.PROCESS_TEXT_READONLY': true, 34 + }, 35 + // note: to skip the intermediate app select, we need to specify a 36 + // `className`. however, this isn't safe to hardcode, we'd need to query the 37 + // package manager for the correct activity. this requires native code, so 38 + // skip for now -sfn 39 + // packageName: 'com.google.android.apps.translate', 40 + // className: 'com.google.android.apps.translate.TranslateActivity', 41 + }, 42 + ) 43 + } catch (err) { 44 + if (__DEV__) console.error(err) 45 + // most likely means they don't have the translate app 46 + await openLink(translateUrl) 47 + } 48 + } else { 49 + await openLink(translateUrl) 50 + } 51 + }, 52 + [openLink], 53 + ) 54 + }
+10 -9
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 12 12 13 13 import {useActorStatus} from '#/lib/actor-status' 14 14 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 15 + import {useTranslate} from '#/lib/hooks/useTranslate' 16 16 import {makeProfileLink} from '#/lib/routes/links' 17 17 import {sanitizeDisplayName} from '#/lib/strings/display-names' 18 18 import {sanitizeHandle} from '#/lib/strings/handles' ··· 509 509 }) { 510 510 const t = useTheme() 511 511 const {_, i18n} = useLingui() 512 - const openLink = useOpenLink() 512 + const translate = useTranslate() 513 513 const isRootPost = !('reply' in post.record) 514 514 const langPrefs = useLanguagePrefs() 515 515 516 - const translatorUrl = getTranslatorLink( 517 - post.record?.text || '', 518 - langPrefs.primaryLanguage, 519 - ) 520 516 const needsTranslation = useMemo( 521 517 () => 522 518 Boolean( ··· 529 525 const onTranslatePress = useCallback( 530 526 (e: GestureResponderEvent) => { 531 527 e.preventDefault() 532 - openLink(translatorUrl, true) 528 + translate(post.record.text || '', langPrefs.primaryLanguage) 533 529 534 530 if ( 535 531 bsky.dangerousIsType<AppBskyFeedPost.Record>( ··· 546 542 547 543 return false 548 544 }, 549 - [openLink, translatorUrl, langPrefs, post], 545 + [translate, langPrefs, post], 550 546 ) 551 547 552 548 return ( ··· 566 562 </Text> 567 563 568 564 <InlineLinkText 569 - to={translatorUrl} 565 + // overridden to open an intent on android, but keep 566 + // as anchor tag for accessibility 567 + to={getTranslatorLink( 568 + post.record.text, 569 + langPrefs.primaryLanguage, 570 + )} 570 571 label={_(msg`Translate`)} 571 572 style={[a.text_sm]} 572 573 onPress={onTranslatePress}>
+10 -12
src/view/com/post-thread/PostThreadItem.tsx
··· 19 19 import {useActorStatus} from '#/lib/actor-status' 20 20 import {MAX_POST_LINES} from '#/lib/constants' 21 21 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 22 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 23 22 import {usePalette} from '#/lib/hooks/usePalette' 23 + import {useTranslate} from '#/lib/hooks/useTranslate' 24 24 import {makeProfileLink} from '#/lib/routes/links' 25 25 import {sanitizeDisplayName} from '#/lib/strings/display-names' 26 26 import {sanitizeHandle} from '#/lib/strings/handles' ··· 273 273 const showFollowButton = 274 274 currentAccount?.did !== post.author.did && !onlyFollowersCanReply 275 275 276 - const translatorUrl = getTranslatorLink( 277 - record?.text || '', 278 - langPrefs.primaryLanguage, 279 - ) 280 276 const needsTranslation = useMemo( 281 277 () => 282 278 Boolean( ··· 477 473 </ContentHider> 478 474 <ExpandedPostDetails 479 475 post={post} 476 + record={record} 480 477 isThreadAuthor={isThreadAuthor} 481 - translatorUrl={translatorUrl} 482 478 needsTranslation={needsTranslation} 483 479 /> 484 480 {post.repostCount !== 0 || ··· 824 820 825 821 function ExpandedPostDetails({ 826 822 post, 823 + record, 827 824 isThreadAuthor, 828 825 needsTranslation, 829 - translatorUrl, 830 826 }: { 831 827 post: AppBskyFeedDefs.PostView 828 + record: AppBskyFeedPost.Record 832 829 isThreadAuthor: boolean 833 830 needsTranslation: boolean 834 - translatorUrl: string 835 831 }) { 836 832 const t = useTheme() 837 833 const pal = usePalette('default') 838 834 const {_, i18n} = useLingui() 839 - const openLink = useOpenLink() 835 + const translate = useTranslate() 840 836 const isRootPost = !('reply' in post.record) 841 837 const langPrefs = useLanguagePrefs() 842 838 843 839 const onTranslatePress = useCallback( 844 840 (e: GestureResponderEvent) => { 845 841 e.preventDefault() 846 - openLink(translatorUrl, true) 842 + translate(record.text || '', langPrefs.primaryLanguage) 847 843 848 844 if ( 849 845 bsky.dangerousIsType<AppBskyFeedPost.Record>( ··· 864 860 865 861 return false 866 862 }, 867 - [openLink, translatorUrl, langPrefs, post], 863 + [translate, record.text, langPrefs, post], 868 864 ) 869 865 870 866 return ( ··· 884 880 </Text> 885 881 886 882 <InlineLinkText 887 - to={translatorUrl} 883 + // overridden to open an intent on android, but keep 884 + // as anchor tag for accessibility 885 + to={getTranslatorLink(record.text, langPrefs.primaryLanguage)} 888 886 label={_(msg`Translate`)} 889 887 style={[a.text_sm, pal.link]} 890 888 onPress={onTranslatePress}>
+5 -14
yarn.lock
··· 77 77 tlds "^1.234.0" 78 78 zod "^3.23.8" 79 79 80 - "@atproto/api@^0.16.2": 81 - version "0.16.2" 82 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd" 83 - integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ== 84 - dependencies: 85 - "@atproto/common-web" "^0.4.2" 86 - "@atproto/lexicon" "^0.4.12" 87 - "@atproto/syntax" "^0.4.0" 88 - "@atproto/xrpc" "^0.7.1" 89 - await-lock "^2.2.2" 90 - multiformats "^9.9.0" 91 - tlds "^1.234.0" 92 - zod "^3.23.8" 93 - 94 80 "@atproto/aws@^0.2.25": 95 81 version "0.2.25" 96 82 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.25.tgz#d07265a656db990ffd54b254cae54388468d1dca" ··· 11320 11306 version "2.4.0" 11321 11307 resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-2.4.0.tgz#02f7fd743387206914cd431a6367f5be53509e3e" 11322 11308 integrity sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw== 11309 + 11310 + expo-intent-launcher@^12.1.5: 11311 + version "12.1.5" 11312 + resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-12.1.5.tgz#ed3051292b33e131535d9b35ca20b48cf56d1364" 11313 + integrity sha512-KmCc/dJHTnVf2ZdrZhYSkvQ588K7qQW+nBGfJj5woCwhEXwYz1xOLQcShnPQgQWRf8conAvQDkI3pbjYNPcECw== 11323 11314 11324 11315 expo-json-utils@~0.15.0: 11325 11316 version "0.15.0"