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
+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 = () => {
try {
if (isThreadMuted) {
unmuteThread()
void unmuteThread()
ax.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -228,7 +228,7 @@ let PostMenuItems = ({
})
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
void muteThread()
ax.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -239,7 +239,8 @@ let PostMenuItems = ({
_(msg`You will no longer receive notifications for this thread`),
)
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(
@@ -253,12 +254,12 @@ let PostMenuItems = ({
const onCopyPostText = () => {
const str = richTextToString(richText, true)
Clipboard.setStringAsync(str)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage)
void translate(record.text, langPrefs.primaryLanguage)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -343,7 +344,8 @@ let PostMenuItems = ({
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
)
} catch (e: any) {
} catch (err) {
const e = err as Error
Toast.show(
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
)
@@ -380,7 +382,8 @@ let PostMenuItems = ({
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
)
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
@@ -409,7 +412,7 @@ let PostMenuItems = ({
const onPressPin = () => {
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({
void pinPostMutate({
postUri,
postCid,
action: isPinned ? 'unpin' : 'pin',
@@ -420,7 +423,8 @@ let PostMenuItems = ({
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
@@ -433,7 +437,8 @@ let PostMenuItems = ({
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
@@ -443,7 +448,8 @@ let PostMenuItems = ({
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
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(
href,
)}`
openLink(url)
void openLink(url)
}
const onSignIn = () => requireSignIn(() => {})
@@ -687,7 +693,7 @@ let PostMenuItems = ({
? _(msg`Unmute account`)
: _(msg`Mute account`)
}
onPress={onMuteAuthor}>
onPress={() => void onMuteAuthor()}>
<Menu.ItemText>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
@@ -796,7 +802,7 @@ let PostMenuItems = ({
description={_(
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`)}
/>
@@ -806,7 +812,7 @@ let PostMenuItems = ({
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.`,
)}
onConfirm={onToggleReplyVisibility}
onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)}
/>
@@ -816,7 +822,7 @@ let PostMenuItems = ({
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={onBlockAuthor}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)}
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 {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -100,6 +100,7 @@ export function Trigger({children, label}: TriggerProps) {
} else {
return (
<Button
hitSlop={hitSlop}
label={label}
onPress={control.open}
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
+2
View File
@@ -1,5 +1,6 @@
import {
type AccessibilityProps,
type Insets,
type StyleProp,
type TextStyle,
type ViewStyle,
@@ -60,6 +61,7 @@ export type RadixPassThroughTriggerProps = {
export type TriggerProps = {
children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
hitSlop?: number | Insets | null
label: string
}
@@ -5,7 +5,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {languageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences/languages'
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 {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
type FlatListItem =
| {
type: 'header'
label: string
}
| {
type: 'item'
lang: Language
}
export function LanguageSelectDialog({
titleText,
subtitleText,
@@ -270,7 +279,7 @@ export function DialogInner({
]}
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
renderItem={({item, index}) => {
renderItem={({item, index}: {item: FlatListItem; index: number}) => {
if (item.type === 'header') {
return (
<Text
@@ -295,7 +304,7 @@ export function DialogInner({
<Toggle.Item
key={lang.code2}
name={lang.code2}
label={languageName(lang, langPrefs.appLanguage)}
label={lang.name}
style={[
t.atoms.border_contrast_low,
!isLastItem && a.border_b,
@@ -304,7 +313,7 @@ export function DialogInner({
a.py_md,
]}>
<Toggle.LabelText style={[a.flex_1]}>
{languageName(lang, langPrefs.appLanguage)}
{lang.name}
</Toggle.LabelText>
<Toggle.Checkbox />
</Toggle.Item>