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:
@@ -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]}>
|
||||
{' '}
|
||||
·
|
||||
</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,
|
||||
]}>
|
||||
{' '}
|
||||
·{' '}
|
||||
</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 />
|
||||
|
||||
Reference in New Issue
Block a user