Use on-device translation on mobile when available (#9930)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -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]}>
|
||||
{' '}
|
||||
·
|
||||
</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"
|
||||
/>
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user