Refine UX for on-device translation for posts (#9987)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
DS Boyce
2026-03-04 18:37:44 -08:00
committed by GitHub
parent 1782a65174
commit afbade6f6f
25 changed files with 1014 additions and 1639 deletions
+314 -68
View File
@@ -1,113 +1,345 @@
import {useMemo} from 'react'
import {useCallback, 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 {type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {guessLanguage, useTranslate} from '#/lib/translation'
import {type TranslationFunction} from '#/lib/translation'
import {codeToLanguageName, languageName} from '#/locale/helpers'
import {LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {createStaticClick, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {useTranslateOnDevice} from '#/translation'
import {IS_WEB} from '#/env'
export function TranslatedPost({
hideTranslateLink = false,
post,
postText,
hideLoading = false,
}: {
hideTranslateLink?: boolean
post: AppBskyFeedDefs.PostView
postText: string
hideLoading: boolean
}) {
const {translationState} = useTranslateOnDevice()
const langPrefs = useLanguagePrefs()
const {clearTranslation, translate, translationState} = useTranslate({
key: post.uri,
})
if (translationState.status === 'loading' && !hideLoading) {
return <TranslationLoading />
const postLanguage = useMemo(() => guessLanguage(postText), [postText])
const needsTranslation = postLanguage !== langPrefs.primaryLanguage
switch (translationState.status) {
case 'loading':
return <TranslationLoading />
case 'success':
return (
<TranslationResult
clearTranslation={clearTranslation}
translate={translate}
postText={postText}
sourceLanguage={
translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS
}
translatedText={translationState.translatedText}
/>
)
case 'error':
return (
<TranslationError
clearTranslation={clearTranslation}
message={translationState.message}
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
/>
)
default:
return (
!hideTranslateLink &&
needsTranslation && (
<TranslationLink
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
sourceLanguage={postLanguage}
translate={translate}
/>
)
)
}
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 style={[a.gap_md, a.pt_md, a.align_start]}>
<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>
</View>
)
}
function TranslationLink({
postText,
primaryLanguage,
sourceLanguage,
translate,
}: {
postText: string
primaryLanguage: string
sourceLanguage: string | null
translate: TranslationFunction
}) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const handleTranslate = useCallback(() => {
void translate({
text: postText,
targetLangCode: primaryLanguage,
})
ax.metric('translate', {
sourceLanguages: sourceLanguage ? [sourceLanguage] : [],
targetLanguage: primaryLanguage,
textLength: postText.length,
})
}, [ax, postText, primaryLanguage, translate, sourceLanguage])
return (
<View
style={[
a.gap_md,
a.pt_md,
a.align_start,
a.flex_row,
a.align_center,
a.gap_xs,
]}>
<Link
role={IS_WEB ? 'link' : 'button'}
{...createStaticClick(() => {
handleTranslate()
})}
label={l`Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
]}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Translate</Trans>
</Text>
</Link>
</View>
)
}
function TranslationError({
clearTranslation,
message,
postText,
primaryLanguage,
}: {
clearTranslation: () => void
message: string
postText: string
primaryLanguage: string
}) {
const t = useTheme()
const {t: l} = useLingui()
const translate = useGoogleTranslate()
const handleFallback = () => {
void translate(postText, primaryLanguage)
}
return (
<View
style={[
a.px_lg,
a.pt_sm,
a.pb_md,
a.mt_sm,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}>
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
{message}
</Text>
</View>
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
<Button
label={l`Hide translation`}
hitSlop={HITSLOP_30}
hoverStyle={{opacity: 0.5}}
onPress={clearTranslation}>
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
</Button>
</View>
</View>
<View style={[a.flex_row, a.align_center]}>
<Link
{...createStaticClick(() => {
handleFallback()
})}
label={l`Try Google Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
]}
hitSlop={HITSLOP_30}>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}>
<Trans>Try Google Translate</Trans>
</Text>
</Link>
</View>
</View>
)
}
function TranslationResult({
clearTranslation,
translate,
postText,
sourceLanguage,
translatedText,
}: {
clearTranslation: () => void
translate: TranslationFunction
postText: string
sourceLanguage: string | null
translatedText: string
}) {
const t = useTheme()
const {i18n} = useLingui()
const langPrefs = useLanguagePrefs()
const {i18n, t: l} = 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>
<View
style={[
a.px_lg,
a.pt_sm,
a.pb_md,
a.mt_sm,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
]}>
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
{langName ? (
<View style={[a.flex_row, a.align_center]}>
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
]}>
{langName}{' '}
</Text>
<View style={[a.mt_2xs]}>
<ArrowRightIcon
size="xs"
fill={t.atoms.text_contrast_medium.color}
/>
</View>
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
]}>
{' '}
{codeToLanguageName(
langPrefs.primaryLanguage,
langPrefs.appLanguage,
)}
</Text>
</View>
) : (
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_xs,
]}>
<Trans>Translated</Trans>
</Text>
)}
{sourceLanguage != null && (
<>
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
]}>
{' '}
&middot;{' '}
</Text>
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
translate={translate}
postText={postText}
/>
</>
)}
</View>
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText}
</Text>
<Button
label={l`Hide translation`}
hitSlop={HITSLOP_30}
hoverStyle={native({opacity: 0.5})}
style={[a.absolute, a.z_10, {top: 12, right: 14}]}
onPress={clearTranslation}>
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
</Button>
</View>
</View>
)
}
function TranslationLanguageSelect({
translate,
postText,
sourceLanguage,
}: {
translate: TranslationFunction
postText: string
sourceLanguage: string
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const langPrefs = useLanguagePrefs()
const {translate} = useTranslateOnDevice()
const items = useMemo(
() =>
@@ -116,18 +348,21 @@ function TranslationLanguageSelect({
!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
)
.sort((a, b) => {
// Prioritize sourceLanguage at the top
if (a.code2 === sourceLanguage) return -1
if (b.code2 === sourceLanguage) return 1
// Localized sort
return languageName(a, langPrefs.appLanguage).localeCompare(
languageName(b, langPrefs.appLanguage),
langPrefs.appLanguage,
)
})
.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],
[langPrefs, sourceLanguage],
)
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
@@ -136,24 +371,35 @@ function TranslationLanguageSelect({
sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage,
})
void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
void translate({
text: postText,
targetLangCode: langPrefs.primaryLanguage,
sourceLangCode,
})
}
return (
<Select.Root
value={sourceLanguage}
onValueChange={handleChangeTranslationLanguage}>
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}>
<Select.Trigger label={l`Change the source language`}>
{({props}) => {
return (
<Text {...props} style={[a.text_xs]}>
<Trans>Change</Trans>
</Text>
<Button
label={props.accessibilityLabel}
{...props}
hitSlop={HITSLOP_30}
hoverStyle={native({opacity: 0.5})}>
<Text
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
<Trans>Change</Trans>
</Text>
</Button>
)
}}
</Select.Trigger>
<Select.Content
label={_(msg`Select the source language`)}
label={l`Select the source language`}
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.ItemIndicator />