Use on-device translation on mobile when available (#9930)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-02-27 16:59:19 -08:00
committed by GitHub
parent 7c9f05a2af
commit 9bbcb472ef
17 changed files with 4620 additions and 608 deletions
+1
View File
@@ -85,6 +85,7 @@
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7", "@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
+13
View File
@@ -2,6 +2,8 @@
* Do not import runtime code into this file * Do not import runtime code into this file
*/ */
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -679,6 +681,17 @@ export type Events = {
targetLanguage: string targetLanguage: string
textLength: number textLength: number
} }
'translate:result': {
method: 'on-device' | 'google-translate' | 'fallback-alert'
os: Platform['OS']
sourceLanguage: string | null
targetLanguage: string
}
'translate:override': {
os: Platform['OS']
sourceLanguage: string
targetLanguage: string
}
'verification:create': {} 'verification:create': {}
'verification:revoke': {} 'verification:revoke': {}
+167
View File
@@ -0,0 +1,167 @@
import {useMemo} from 'react'
import {Platform, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {codeToLanguageName, languageName} from '#/locale/helpers'
import {LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {useTranslateOnDevice} from '#/translation'
export function TranslatedPost({
postText,
hideLoading = false,
}: {
postText: string
hideLoading: boolean
}) {
const {translationState} = useTranslateOnDevice()
if (translationState.status === 'loading' && !hideLoading) {
return <TranslationLoading />
}
if (translationState.status === 'success') {
return (
<TranslationResult
postText={postText}
sourceLanguage={translationState.sourceLanguage}
translatedText={translationState.translatedText}
/>
)
}
return null
}
function TranslationLoading() {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
<Loader size="sm" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating…</Trans>
</Text>
</View>
)
}
function TranslationResult({
postText,
sourceLanguage,
translatedText,
}: {
postText: string
sourceLanguage: string | null
translatedText: string
}) {
const t = useTheme()
const {i18n} = useLingui()
const langName = sourceLanguage
? codeToLanguageName(sourceLanguage, i18n.locale)
: undefined
return (
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{langName ? (
<Trans>Translated from {langName}</Trans>
) : (
<Trans>Translated</Trans>
)}
{sourceLanguage != null && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{' '}
&middot;
</Text>{' '}
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
postText={postText}
/>
</>
)}
</Text>
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText}
</Text>
</View>
)
}
function TranslationLanguageSelect({
postText,
sourceLanguage,
}: {
postText: string
sourceLanguage: string
}) {
const ax = useAnalytics()
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const {translate} = useTranslateOnDevice()
const items = useMemo(
() =>
LANGUAGES.filter(
(lang, index, self) =>
!langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
)
.sort(
(a, b) =>
languageName(a, langPrefs.appLanguage).localeCompare(
languageName(b, langPrefs.appLanguage),
langPrefs.appLanguage,
), // Localized sort
)
.map(l => ({
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
value: l.code2,
})),
[langPrefs],
)
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
ax.metric('translate:override', {
os: Platform.OS,
sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage,
})
void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
}
return (
<Select.Root
value={sourceLanguage}
onValueChange={handleChangeTranslationLanguage}>
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}>
{({props}) => {
return (
<Text {...props} style={[a.text_xs]}>
<Trans>Edit</Trans>
</Text>
)
}}
</Select.Trigger>
<Select.Content
label={_(msg`Select the source language`)}
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
)}
items={items}
/>
</Select.Root>
)
}
@@ -219,7 +219,7 @@ let PostMenuItems = ({
const onToggleThreadMute = () => { const onToggleThreadMute = () => {
try { try {
if (isThreadMuted) { if (isThreadMuted) {
unmuteThread() void unmuteThread()
ax.metric('post:unmute', { ax.metric('post:unmute', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
@@ -228,7 +228,7 @@ let PostMenuItems = ({
}) })
Toast.show(_(msg`You will now receive notifications for this thread`)) Toast.show(_(msg`You will now receive notifications for this thread`))
} else { } else {
muteThread() void muteThread()
ax.metric('post:mute', { ax.metric('post:mute', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
@@ -239,7 +239,8 @@ let PostMenuItems = ({
_(msg`You will no longer receive notifications for this thread`), _(msg`You will no longer receive notifications for this thread`),
) )
} }
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e}) logger.error('Failed to toggle thread mute', {message: e})
Toast.show( Toast.show(
@@ -253,12 +254,12 @@ let PostMenuItems = ({
const onCopyPostText = () => { const onCopyPostText = () => {
const str = richTextToString(richText, true) const str = richTextToString(richText, true)
Clipboard.setStringAsync(str) void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
} }
const onPressTranslate = () => { const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage) void translate(record.text, langPrefs.primaryLanguage)
if ( if (
bsky.dangerousIsType<AppBskyFeedPost.Record>( bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -343,7 +344,8 @@ let PostMenuItems = ({
? _(msg`Quote post was successfully detached`) ? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`), : _(msg`Quote post was re-attached`),
) )
} catch (e: any) { } catch (err) {
const e = err as Error
Toast.show( Toast.show(
_(msg({message: 'Updating quote attachment failed', context: 'toast'})), _(msg({message: 'Updating quote attachment failed', context: 'toast'})),
) )
@@ -380,7 +382,8 @@ let PostMenuItems = ({
? _(msg`Reply was successfully hidden`) ? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})), : _(msg({message: 'Reply visibility updated', context: 'toast'})),
) )
} catch (e: any) { } catch (err) {
const e = err as Error
if (e instanceof MaxHiddenRepliesError) { if (e instanceof MaxHiddenRepliesError) {
Toast.show( Toast.show(
_( _(
@@ -409,7 +412,7 @@ let PostMenuItems = ({
const onPressPin = () => { const onPressPin = () => {
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({ void pinPostMutate({
postUri, postUri,
postCid, postCid,
action: isPinned ? 'unpin' : 'pin', action: isPinned ? 'unpin' : 'pin',
@@ -420,7 +423,8 @@ let PostMenuItems = ({
try { try {
await queueBlock() await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e}) logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
@@ -433,7 +437,8 @@ let PostMenuItems = ({
try { try {
await queueUnmute() await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e}) logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
@@ -443,7 +448,8 @@ let PostMenuItems = ({
try { try {
await queueMute() await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e}) logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
@@ -456,7 +462,7 @@ let PostMenuItems = ({
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
href, href,
)}` )}`
openLink(url) void openLink(url)
} }
const onSignIn = () => requireSignIn(() => {}) const onSignIn = () => requireSignIn(() => {})
@@ -687,7 +693,7 @@ let PostMenuItems = ({
? _(msg`Unmute account`) ? _(msg`Unmute account`)
: _(msg`Mute account`) : _(msg`Mute account`)
} }
onPress={onMuteAuthor}> onPress={() => void onMuteAuthor()}>
<Menu.ItemText> <Menu.ItemText>
{postAuthor.viewer?.muted {postAuthor.viewer?.muted
? _(msg`Unmute account`) ? _(msg`Unmute account`)
@@ -796,7 +802,7 @@ let PostMenuItems = ({
description={_( description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)} )}
onConfirm={onToggleQuotePostAttachment} onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)} confirmButtonCta={_(msg`Yes, detach`)}
/> />
@@ -806,7 +812,7 @@ let PostMenuItems = ({
description={_( description={_(
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
)} )}
onConfirm={onToggleReplyVisibility} onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)} confirmButtonCta={_(msg`Yes, hide`)}
/> />
@@ -816,7 +822,7 @@ let PostMenuItems = ({
description={_( description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)} )}
onConfirm={onBlockAuthor} onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)} confirmButtonCta={_(msg`Block`)}
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
+2 -1
View File
@@ -70,7 +70,7 @@ export function Root({children, value, onValueChange, disabled}: RootProps) {
) )
} }
export function Trigger({children, label}: TriggerProps) { export function Trigger({children, hitSlop, label}: TriggerProps) {
const {control} = useSelectContext() const {control} = useSelectContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const { const {
@@ -100,6 +100,7 @@ export function Trigger({children, label}: TriggerProps) {
} else { } else {
return ( return (
<Button <Button
hitSlop={hitSlop}
label={label} label={label}
onPress={control.open} onPress={control.open}
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]} style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
+2
View File
@@ -1,5 +1,6 @@
import { import {
type AccessibilityProps, type AccessibilityProps,
type Insets,
type StyleProp, type StyleProp,
type TextStyle, type TextStyle,
type ViewStyle, type ViewStyle,
@@ -60,6 +61,7 @@ export type RadixPassThroughTriggerProps = {
export type TriggerProps = { export type TriggerProps = {
children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode) children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
hitSlop?: number | Insets | null
label: string label: string
} }
@@ -5,7 +5,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {languageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences/languages' import {useLanguagePrefs} from '#/state/preferences/languages'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
@@ -19,6 +18,16 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
type FlatListItem =
| {
type: 'header'
label: string
}
| {
type: 'item'
lang: Language
}
export function LanguageSelectDialog({ export function LanguageSelectDialog({
titleText, titleText,
subtitleText, subtitleText,
@@ -270,7 +279,7 @@ export function DialogInner({
]} ]}
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]} style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
renderItem={({item, index}) => { renderItem={({item, index}: {item: FlatListItem; index: number}) => {
if (item.type === 'header') { if (item.type === 'header') {
return ( return (
<Text <Text
@@ -295,7 +304,7 @@ export function DialogInner({
<Toggle.Item <Toggle.Item
key={lang.code2} key={lang.code2}
name={lang.code2} name={lang.code2}
label={languageName(lang, langPrefs.appLanguage)} label={lang.name}
style={[ style={[
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
!isLastItem && a.border_b, !isLastItem && a.border_b,
@@ -304,7 +313,7 @@ export function DialogInner({
a.py_md, a.py_md,
]}> ]}>
<Toggle.LabelText style={[a.flex_1]}> <Toggle.LabelText style={[a.flex_1]}>
{languageName(lang, langPrefs.appLanguage)} {lang.name}
</Toggle.LabelText> </Toggle.LabelText>
<Toggle.Checkbox /> <Toggle.Checkbox />
</Toggle.Item> </Toggle.Item>
+12 -4
View File
@@ -1,19 +1,27 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import * as IntentLauncher from 'expo-intent-launcher' import * as IntentLauncher from 'expo-intent-launcher'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {IS_ANDROID} from '#/env' import {IS_ANDROID} from '#/env'
import {useOpenLink} from './useOpenLink'
/**
* Will always link out to Google Translate. If inline translation is desired,
* use `useTranslateOnDevice`
*/
export function useTranslate() { export function useTranslate() {
const openLink = useOpenLink() const openLink = useOpenLink()
return useCallback( return useCallback(
async (text: string, language: string) => { async (text: string, targetLangCode: string, sourceLanguage?: string) => {
const translateUrl = getTranslatorLink(text, language) const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLanguage,
)
if (IS_ANDROID) { if (IS_ANDROID) {
try { try {
// use getApplicationIconAsync to determine if the translate app is installed // use `getApplicationIconAsync` to determine if the translate app is installed
if ( if (
!(await IntentLauncher.getApplicationIconAsync( !(await IntentLauncher.getApplicationIconAsync(
'com.google.android.apps.translate', 'com.google.android.apps.translate',
+6 -2
View File
@@ -127,8 +127,12 @@ export function isPostInLanguage(
return bcp47Match.basicFilter(lang, targetLangs).length > 0 return bcp47Match.basicFilter(lang, targetLangs).length > 0
} }
export function getTranslatorLink(text: string, lang: string): string { export function getTranslatorLink(
return `https://translate.google.com/?sl=auto&tl=${lang}&text=${encodeURIComponent( text: string,
targetLangCode: string,
sourceLanguage?: string,
): string {
return `https://translate.google.com/?sl=${sourceLanguage ?? 'auto'}&tl=${targetLangCode}&text=${encodeURIComponent(
text, text,
)}` )}`
} }
+4011 -498
View File
File diff suppressed because it is too large Load Diff
@@ -7,17 +7,18 @@ import {
AtUri, AtUri,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' import {
getPostLanguage,
getTranslatorLink,
isPostInLanguage,
} from '#/locale/helpers'
import { import {
POST_TOMBSTONE, POST_TOMBSTONE,
type Shadow, type Shadow,
@@ -44,11 +45,13 @@ import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
import {type AppModerationCause} from '#/components/Pills' import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -60,6 +63,10 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
import {WhoCanReply} from '#/components/WhoCanReply' import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import {
Provider as TranslateOnDeviceProvider,
useTranslateOnDevice,
} from '#/translation'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({ export function ThreadItemAnchor({
@@ -82,6 +89,7 @@ export function ThreadItemAnchor({
} }
return ( return (
<TranslateOnDeviceProvider>
<ThreadItemAnchorInner <ThreadItemAnchorInner
// Safeguard from clobbering per-post state below: // Safeguard from clobbering per-post state below:
key={postShadow.uri} key={postShadow.uri}
@@ -92,6 +100,7 @@ export function ThreadItemAnchor({
threadgateRecord={threadgateRecord} threadgateRecord={threadgateRecord}
postSource={postSource} postSource={postSource}
/> />
</TranslateOnDeviceProvider>
) )
} }
@@ -178,7 +187,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession)
@@ -411,6 +420,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true} shouldProxyLinks={true}
/> />
) : undefined} ) : undefined}
<TranslatedPost postText={record.text} hideLoading />
<TranslateLink post={item.value.post} />
{post.embed && ( {post.embed && (
<View style={[a.py_xs]}> <View style={[a.py_xs]}>
<Embed <Embed
@@ -447,7 +458,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
{post.repostCount != null && post.repostCount !== 0 ? ( {post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={_(msg`Reposts of this post`)}> <Link to={repostsHref} label={l`Reposts of this post`}>
<Text <Text
testID="repostCount-expanded" testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
@@ -467,7 +478,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
{post.quoteCount != null && {post.quoteCount != null &&
post.quoteCount !== 0 && post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? ( !post.viewer?.embeddingDisabled ? (
<Link to={quotesHref} label={_(msg`Quotes of this post`)}> <Link to={quotesHref} label={l`Quotes of this post`}>
<Text <Text
testID="quoteCount-expanded" testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
@@ -485,7 +496,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Link> </Link>
) : null} ) : null}
{post.likeCount != null && post.likeCount !== 0 ? ( {post.likeCount != null && post.likeCount !== 0 ? (
<Link to={likesHref} label={_(msg`Likes on this post`)}> <Link to={likesHref} label={l`Likes on this post`}>
<Text <Text
testID="likeCount-expanded" testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
@@ -546,20 +557,18 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
) )
}) })
function ExpandedPostDetails({ function TranslateLink({
post, post,
isThreadAuthor,
}: { }: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
isThreadAuthor: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_, i18n} = useLingui() const {t: l} = useLingui()
const translate = useTranslate()
const isRootPost = !('reply' in post.record)
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {translate, clearTranslation, translationState} = useTranslateOnDevice()
const needsTranslation = useMemo( const needsTranslation = useMemo(
() => () =>
Boolean( Boolean(
@@ -569,10 +578,16 @@ function ExpandedPostDetails({
[post, langPrefs.primaryLanguage], [post, langPrefs.primaryLanguage],
) )
const sourceLanguage = getPostLanguage(post)
const onTranslatePress = useCallback( const onTranslatePress = useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
e.preventDefault() e.preventDefault()
translate(post.record.text || '', langPrefs.primaryLanguage) void translate(
post.record.text || '',
langPrefs.primaryLanguage,
sourceLanguage,
)
if ( if (
bsky.dangerousIsType<AppBskyFeedPost.Record>( bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -589,9 +604,61 @@ function ExpandedPostDetails({
return false return false
}, },
[ax, translate, langPrefs, post], [ax, sourceLanguage, translate, langPrefs, post],
) )
const onHideTranslation = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
clearTranslation()
return false
},
[clearTranslation],
)
return (
needsTranslation && (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
{translationState.status === 'loading' ? (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Loader size="xs" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating…</Trans>
</Text>
</View>
) : translationState.status === 'success' ? (
<InlineLinkText
to="#"
label={l`Hide translation`}
style={[a.text_sm]}
onPress={onHideTranslation}>
<Trans>Hide translation</Trans>
</InlineLinkText>
) : (
<InlineLinkText
to={getTranslatorLink(post.record.text, langPrefs.primaryLanguage)}
label={l`Translate`}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
)}
</View>
)
)
}
function ExpandedPostDetails({
post,
isThreadAuthor,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
isThreadAuthor: boolean
}) {
const t = useTheme()
const {i18n} = useLingui()
const isRootPost = !('reply' in post.record)
return ( return (
<View style={[a.gap_md, a.pt_md, a.align_start]}> <View style={[a.gap_md, a.pt_md, a.align_start]}>
<BackdatedPostIndicator post={post} /> <BackdatedPostIndicator post={post} />
@@ -602,26 +669,6 @@ function ExpandedPostDetails({
{isRootPost && ( {isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
)} )}
{needsTranslation && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
&middot;
</Text>
<InlineLinkText
// 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}>
<Trans>Translate</Trans>
</InlineLinkText>
</>
)}
</View> </View>
</View> </View>
) )
@@ -629,7 +676,7 @@ function ExpandedPostDetails({
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {t: l, i18n} = useLingui()
const control = Prompt.usePromptControl() const control = Prompt.usePromptControl()
const indexedAt = new Date(post.indexedAt) const indexedAt = new Date(post.indexedAt)
@@ -649,10 +696,8 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
return ( return (
<> <>
<Button <Button
label={_(msg`Archived post`)} label={l`Archived post`}
accessibilityHint={_( accessibilityHint={l`Shows information about when this post was created`}
msg`Shows information about when this post was created`,
)}
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@@ -711,7 +756,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
</Prompt.DescriptionText> </Prompt.DescriptionText>
</Prompt.Content> </Prompt.Content>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> <Prompt.Action cta={l`Okay`} onPress={() => {}} />
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>
</> </>
@@ -23,7 +23,7 @@ export function SearchLanguageDropdown({
onChange(value: string): void onChange(value: string): void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {appLanguage, contentLanguages} = useLanguagePrefs() const {appLanguage, contentLanguages, primaryLanguage} = useLanguagePrefs()
const languages = useMemo(() => { const languages = useMemo(() => {
return LANGUAGES.filter( return LANGUAGES.filter(
@@ -47,19 +47,19 @@ export function SearchLanguageDropdown({
al => al =>
// skip `ast`, because it uses a 3-letter code which conflicts with `as` // skip `ast`, because it uses a 3-letter code which conflicts with `as`
// it begins with `a` anyway so still is top of the list // it begins with `a` anyway so still is top of the list
al.code2 !== 'ast' && al.code2.startsWith(a.value), (al.code2 as string) !== 'ast' && al.code2.startsWith(a.value),
) )
const bIsCommon = !!APP_LANGUAGES.find( const bIsCommon = !!APP_LANGUAGES.find(
al => al =>
// ditto // ditto
al.code2 !== 'ast' && al.code2.startsWith(b.value), (al.code2 as string) !== 'ast' && al.code2.startsWith(b.value),
) )
if (aIsCommon && !bIsCommon) return -1 if (aIsCommon && !bIsCommon) return -1
if (bIsCommon && !aIsCommon) return 1 if (bIsCommon && !aIsCommon) return 1
// fall back to alphabetical // fall back to alphabetical
return a.label.localeCompare(b.label) return a.label.localeCompare(b.label, primaryLanguage)
}) })
}, [appLanguage, contentLanguages]) }, [appLanguage, contentLanguages, primaryLanguage])
const currentLanguageLabel = const currentLanguageLabel =
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
+15 -9
View File
@@ -8,7 +8,7 @@ import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {languageName, sanitizeAppLanguageSetting} from '#/locale/helpers' import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {atoms as a, web} from '#/alf' import {atoms as a, web} from '#/alf'
@@ -144,8 +144,11 @@ export function LanguageSettingsScreen({}: Props) {
<Select.ItemText>{label}</Select.ItemText> <Select.ItemText>{label}</Select.ItemText>
</Select.Item> </Select.Item>
)} )}
items={DEDUPED_LANGUAGES.map(l => ({ items={DEDUPED_LANGUAGES.sort(
label: languageName(l, langPrefs.appLanguage), (a, b) =>
a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort
).map(l => ({
label: l.name, // Pre-generated name using Intl.DisplayNames
value: l.code2, value: l.code2,
}))} }))}
/> />
@@ -177,8 +180,13 @@ export function LanguageSettingsScreen({}: Props) {
values={langPrefs.contentLanguages} values={langPrefs.contentLanguages}
onChange={setLangPrefs.setContentLanguages}> onChange={setLangPrefs.setContentLanguages}>
<Toggle.PanelGroup> <Toggle.PanelGroup>
{possibleLanguages.map((language, index) => { {possibleLanguages
const name = languageName(language, langPrefs.appLanguage) .sort(
(a, b) =>
a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort
)
.map((language, index) => {
const name = language.name // Pre-generated name using Intl.DisplayNames
return ( return (
<Toggle.Item <Toggle.Item
key={language.code2} key={language.code2}
@@ -196,13 +204,11 @@ export function LanguageSettingsScreen({}: Props) {
) )
})} })}
<Button <Button
label={_(msg`Add more languages...`)} label={_(msg`Add more languages…`)}
onPress={contentLanguagePrefsControl.open}> onPress={contentLanguagePrefsControl.open}>
<Toggle.Panel adjacent="leading"> <Toggle.Panel adjacent="leading">
<Toggle.PanelIcon icon={PlusIcon} /> <Toggle.PanelIcon icon={PlusIcon} />
<Toggle.PanelText> <Toggle.PanelText>Add more languages…</Toggle.PanelText>
Add more languages...
</Toggle.PanelText>
</Toggle.Panel> </Toggle.Panel>
</Button> </Button>
</Toggle.PanelGroup> </Toggle.PanelGroup>
+188
View File
@@ -0,0 +1,188 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
type TranslationState =
| {status: 'idle'}
| {status: 'loading'}
| {
status: 'success'
translatedText: string
sourceLanguage: TranslationTaskResult['sourceLanguage']
targetLanguage: TranslationTaskResult['targetLanguage']
}
const IDLE: TranslationState = {status: 'idle'}
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
* Uses a lazy import to avoid crashing if the native module isn't linked into
* the current build.
*/
async function attemptTranslation(
input: string,
targetLangCodeOriginal: string,
sourceLangCodeOriginal?: string, // Auto-detects if not provided
): Promise<{
translatedText: string
targetLanguage: TranslationTaskResult['targetLanguage']
sourceLanguage: TranslationTaskResult['sourceLanguage']
}> {
// Note that Android only supports two-character language codes and will fail
// on other input.
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
let targetLangCode =
Platform.OS === 'android'
? targetLangCodeOriginal.split('-')[0]
: targetLangCodeOriginal
const sourceLangCode =
Platform.OS === 'android'
? sourceLangCodeOriginal?.split('-')[0]
: sourceLangCodeOriginal
// Special cases for regional languages
if (Platform.OS !== 'android') {
const deviceLocales = getLocales()
const primaryLanguageTag = deviceLocales[0]?.languageTag
switch (targetLangCodeOriginal) {
case 'en': // en-US, en-GB
case 'es': // es-419, es-ES
case 'pt': // pt-BR, pt-PT
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal
break
}
}
const {onTranslateTask} =
// Needed in order to type check the dynamically imported module.
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
require('@bsky.app/expo-translate-text') as typeof import('@bsky.app/expo-translate-text')
const result = await onTranslateTask({
input,
targetLangCode,
sourceLangCode,
})
// Since `input` is always a string, the result should always be a string.
return {
translatedText:
typeof result.translatedTexts === 'string' ? result.translatedTexts : '',
targetLanguage: result.targetLanguage,
sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // iOS doesn't return the source language
}
}
const Context = createContext<{
translationState: TranslationState
translate: (
text: string,
targetLangCode: string,
sourceLangCode?: string,
) => Promise<void>
clearTranslation: () => void
}>({
translationState: IDLE,
translate: async () => {},
clearTranslation: () => {},
})
Context.displayName = 'TranslationContext'
/**
* 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.
*
* Web uses index.web.ts which always opens Google Translate.
*/
export function useTranslateOnDevice() {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslateOnDevice must be used within a TranslateOnDeviceProvider',
)
}
return context
}
export function Provider({children}: {children?: React.ReactNode}) {
const [translationState, setTranslationState] =
useState<TranslationState>(IDLE)
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
const clearTranslation = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(IDLE)
}, [])
const translate = useCallback(
async (
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode?: string,
) => {
setTranslationState({status: 'loading'})
try {
const result = await attemptTranslation(
text,
targetLangCode,
sourceLangCode,
)
ax.metric('translate:result', {
method: 'on-device',
os: Platform.OS,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState({
status: 'success',
translatedText: result.translatedText,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
})
} catch (e) {
logger.error('Failed to translate post on device', {safeMessage: e})
// On-device translation failed (language pack missing or user dismissed
// the download prompt). Fall back to Google Translate.
ax.metric('translate:result', {
method: 'fallback-alert',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
setTranslationState({status: 'idle'})
const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLangCode,
)
await openLink(translateUrl)
}
},
[ax, openLink, primaryLanguage, setTranslationState],
)
const ctx = useMemo(
() => ({clearTranslation, translate, translationState}),
[clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+43
View File
@@ -0,0 +1,43 @@
import {useCallback} from 'react'
import {Platform} from 'react-native'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
const translationState = {status: 'idle'} // No on-device translations for web.
const clearTranslation = () => {} // no-op on web
/**
* Web always opens Google Translate.
*/
export function useTranslateOnDevice() {
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
const translate = useCallback(
async (
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode: string,
) => {
const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLangCode,
)
ax.metric('translate:result', {
method: 'google-translate',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await openLink(translateUrl)
},
[ax, openLink, primaryLanguage],
)
return {clearTranslation, translate, translationState}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import type React from 'react' import type React from 'react'
import {HITSLOP_10} from '#/lib/constants'
import {useKawaiiMode} from '#/state/preferences/kawaii' import {useKawaiiMode} from '#/state/preferences/kawaii'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
@@ -53,7 +54,7 @@ function HomeHeaderLayoutDesktopAndTablet({
</View> </View>
<Link <Link
to="/feeds" to="/feeds"
hitSlop={10} hitSlop={HITSLOP_10}
label={_(msg`View your feeds and explore more`)} label={_(msg`View your feeds and explore more`)}
size="small" size="small"
variant="ghost" variant="ghost"
+5
View File
@@ -3731,6 +3731,11 @@
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg== integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg==
"@bsky.app/expo-translate-text@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.4.tgz#6e7f20f286111ee4d550c0c84f57393fc215a675"
integrity sha512-7mvFggNfkJEufI5A3WnjfjdN3H9P6Dpx7CpDkA9npWqA8Cb2icXq3k3nz3MaXGrVKTYiJnytAffYtos7mPoeOg==
"@bsky.app/react-native-mmkv@2.12.5": "@bsky.app/react-native-mmkv@2.12.5":
version "2.12.5" version "2.12.5"
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"