Compare commits

...

27 Commits

Author SHA1 Message Date
Samuel Newman 387b150825 guess source language if unavailable 2026-03-04 10:44:06 +02:00
Samuel Newman f42e69aa2f fix type error 2026-03-04 10:41:39 +02:00
Samuel Newman 93857f78be use Link for a11y 2026-03-04 10:33:10 +02:00
Samuel Newman 763e4f6e29 adjust arrow style 2026-03-04 10:22:08 +02:00
Samuel Newman 92de981547 fix error not using useGoogleTranslate hook, fix hitslop 2026-03-04 10:01:10 +02:00
Samuel Newman 5707866c3b support android 16KB pages 2026-03-04 08:55:04 +02:00
DS Boyce d335430b53 Update comment 2026-03-03 16:05:14 -08:00
DS Boyce 19022c2d6f Update comment 2026-03-03 15:57:11 -08:00
DS Boyce b375fd2c18 Remove ios and android fields from Language[] 2026-03-03 15:55:40 -08:00
DS Boyce 8315b54f49 Use a switch for returns 2026-03-03 15:53:37 -08:00
DS Boyce 3a617e2531 Even out padding in error message 2026-03-03 15:51:30 -08:00
DS Boyce ffce872adb Add error handling with Google Translate link 2026-03-03 15:50:35 -08:00
DS Boyce c47c1e7aaf Tweak border size 2026-03-03 14:48:39 -08:00
DS Boyce 44a53ea66d Update presentation based on latest design 2026-03-03 14:47:10 -08:00
DS Boyce 4d6f98ba87 Add some comments 2026-03-03 13:27:09 -08:00
DS Boyce 4818b7b7e7 Move forceGoogleTranslate param to useTranslate hook 2026-03-03 13:19:04 -08:00
DS Boyce 1e04166c93 Remove unnecessary check for key 2026-03-03 13:05:55 -08:00
DS Boyce 2a141c641b Delay translation until the loading spinner appears 2026-03-03 12:57:15 -08:00
DS Boyce f39e449683 Call useTranslate once and pass down translate function 2026-03-03 12:46:47 -08:00
DS Boyce 5d22e727c8 Explicity check for iOS 2026-03-03 12:37:23 -08:00
DS Boyce 00c66eca47 Pass key as argument to useTranslate hook 2026-03-03 12:36:22 -08:00
DS Boyce 06daefabac Fix translations for posts on profiles 2026-03-03 12:03:30 -08:00
DS Boyce 1c5522164e Move TranslateOnDeviceProvider 2026-03-03 11:40:35 -08:00
DS Boyce 116784463a Update option name to forceGoogleTranslate 2026-03-03 11:38:22 -08:00
DS Boyce b7ab03bf18 Rename useTranslateOnDevice 2026-03-03 11:20:20 -08:00
DS Boyce a9eba81c4a Rename translation hooks 2026-03-03 11:18:41 -08:00
DS Boyce 701b1ed0c6 Refine post translation UX 2026-03-03 09:39:53 -08:00
22 changed files with 1003 additions and 1647 deletions
+1 -1
View File
@@ -85,7 +85,7 @@
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.4",
"@bsky.app/expo-translate-text": "^0.2.6",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
+6 -3
View File
@@ -19,6 +19,7 @@ import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottom
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
@@ -179,9 +180,11 @@ function InnerApp() {
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
+5 -2
View File
@@ -10,6 +10,7 @@ import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
@@ -154,8 +155,10 @@ function InnerApp() {
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
<TranslateOnDeviceProvider>
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
+318 -62
View File
@@ -1,113 +1,358 @@
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 {useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Platform, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {codeToLanguageName, languageName} from '#/locale/helpers'
import {HITSLOP_30} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {useTranslate} from '#/lib/translation'
import {type TranslationFunction} from '#/lib/translation/types'
import {
codeToLanguageName,
getTranslatorLink,
isPostInLanguage,
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 {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({
post,
postText,
hideLoading = false,
}: {
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 needsTranslation = useMemo(
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[post, langPrefs.primaryLanguage],
)
switch (translationState.status) {
case 'loading':
return <TranslationLoading />
case 'success':
return (
<TranslationResult
clearTranslation={clearTranslation}
translate={translate}
postText={postText}
sourceLanguage={translationState.sourceLanguage}
translatedText={translationState.translatedText}
/>
)
case 'error':
return (
<TranslationError
clearTranslation={clearTranslation}
message={translationState.message}
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
/>
)
default:
return (
needsTranslation && (
<TranslationLink
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
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,
translate,
}: {
postText: string
primaryLanguage: string
translate: TranslationFunction
}) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const handleTranslate = useCallback(
(evt: GestureResponderEvent) => {
evt.preventDefault()
void translate({
text: postText,
targetLangCode: primaryLanguage,
})
ax.metric('translate', {
sourceLanguages: [],
targetLanguage: primaryLanguage,
textLength: postText.length,
})
return false
},
[ax, postText, primaryLanguage, translate],
)
return (
<View
style={[
a.gap_md,
a.pt_md,
a.align_start,
a.flex_row,
a.align_center,
a.gap_xs,
]}>
<Link
to={getTranslatorLink(postText, primaryLanguage)}
role={IS_WEB ? 'link' : 'button'}
onPress={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 = (evt: GestureResponderEvent) => {
evt.preventDefault()
void translate(postText, primaryLanguage)
return false as const
}
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.flex_wrap, 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
to={getTranslatorLink(postText, primaryLanguage)}
onPress={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(
() =>
@@ -136,24 +381,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 />
@@ -13,13 +13,12 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {
@@ -28,6 +27,7 @@ import {
} from '#/lib/routes/types'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTranslate} from '#/lib/translation'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -106,6 +106,7 @@ let PostMenuItems = ({
threadgateRecord,
onShowLess,
logContext,
forceGoogleTranslate,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -120,9 +121,10 @@ let PostMenuItems = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
forceGoogleTranslate: boolean
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
@@ -133,7 +135,10 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const translate = useTranslate()
const {clearTranslation, translate, translationState} = useTranslate({
key: post.uri,
forceGoogleTranslate,
})
const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
@@ -191,7 +196,7 @@ let PostMenuItems = ({
const onDeletePost = () => {
deletePostMutate({uri: postUri}).then(
() => {
Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
Toast.show(l({message: 'Post deleted', context: 'toast'}))
const route = getCurrentRoute(navigation.getState())
if (route.name === 'PostThread') {
@@ -211,7 +216,7 @@ let PostMenuItems = ({
},
e => {
logger.error('Failed to delete post', {message: e})
Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
Toast.show(l`Failed to delete post, please try again`, 'xmark')
},
)
}
@@ -226,7 +231,7 @@ let PostMenuItems = ({
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(_(msg`You will now receive notifications for this thread`))
Toast.show(l`You will now receive notifications for this thread`)
} else {
void muteThread()
ax.metric('post:mute', {
@@ -235,18 +240,13 @@ let PostMenuItems = ({
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
Toast.show(l`You will no longer receive notifications for this thread`)
}
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(
_(msg`Failed to toggle thread mute, please try again`),
'xmark',
)
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
}
}
}
@@ -255,11 +255,14 @@ let PostMenuItems = ({
const str = richTextToString(richText, true)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
Toast.show(l`Copied to clipboard`, 'clipboard-check')
}
const onPressTranslate = () => {
void translate(record.text, langPrefs.primaryLanguage)
void translate({
text: record.text,
targetLangCode: langPrefs.primaryLanguage,
})
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -297,9 +300,7 @@ let PostMenuItems = ({
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
)
Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
}
const onPressShowLess = () => {
@@ -322,7 +323,7 @@ let PostMenuItems = ({
})
} else {
Toast.show(
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
l({message: 'Feedback sent to feed operator', context: 'toast'}),
)
}
}
@@ -341,13 +342,13 @@ let PostMenuItems = ({
})
Toast.show(
isDetach
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
? l`Quote post was successfully detached`
: l`Quote post was re-attached`,
)
} catch (err) {
const e = err as Error
Toast.show(
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
l({message: 'Updating quote attachment failed', context: 'toast'}),
)
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
}
@@ -379,31 +380,27 @@ let PostMenuItems = ({
Toast.show(
isHide
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
? l`Reply was successfully hidden`
: l({message: 'Reply visibility updated', context: 'toast'}),
)
} catch (err) {
const e = err as Error
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
),
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
)
} else if (e instanceof InvalidInteractionSettingsError) {
Toast.show(
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
l({message: 'Invalid interaction settings.', context: 'toast'}),
)
} else {
Toast.show(
_(
msg({
message: 'Updating reply visibility failed',
context: 'toast',
}),
),
l({
message: 'Updating reply visibility failed',
context: 'toast',
}),
)
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
}
@@ -422,12 +419,12 @@ let PostMenuItems = ({
const onBlockAuthor = async () => {
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
Toast.show(l({message: 'Account blocked', context: 'toast'}))
} 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')
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
}
}
}
@@ -436,23 +433,23 @@ let PostMenuItems = ({
if (postAuthor.viewer?.muted) {
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
} 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')
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
}
}
} else {
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
Toast.show(l({message: 'Account muted', context: 'toast'}))
} 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')
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
}
}
}
@@ -467,6 +464,8 @@ let PostMenuItems = ({
const onSignIn = () => requireSignIn(() => {})
const onPressHideTranslation = () => clearTranslation()
const isDiscoverDebugUser =
IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
@@ -481,16 +480,12 @@ let PostMenuItems = ({
<Menu.Item
testID="pinPostBtn"
label={
isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)
isPinned ? l`Unpin from profile` : l`Pin to your profile`
}
disabled={isPinPending}
onPress={onPressPin}>
<Menu.ItemText>
{isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)}
{isPinned ? l`Unpin from profile` : l`Pin to your profile`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isPinPending ? Loader : PinIcon}
@@ -505,28 +500,46 @@ let PostMenuItems = ({
<Menu.Group>
{!hideInPWI || hasSession ? (
<>
<Menu.Item
testID="postDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
{translationState.status === 'loading' ? (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Translating…`}
onPress={() => {}}>
<Menu.ItemText>{l`Translating…`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
) : translationState.status === 'success' ? (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Hide translation`}
onPress={onPressHideTranslation}>
<Menu.ItemText>{l`Hide translation`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
) : (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslate}>
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
)}
<Menu.Item
testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)}
label={l`Copy post text`}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
<Menu.ItemText>{l`Copy post text`}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</>
) : (
<Menu.Item
testID="postDropdownSignInBtn"
label={_(msg`Sign in to view post`)}
label={l`Sign in to view post`}
onPress={onSignIn}>
<Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText>
<Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText>
<Menu.ItemIcon icon={Eye} position="right" />
</Menu.Item>
)}
@@ -538,17 +551,17 @@ let PostMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownShowMoreBtn"
label={_(msg`Show more like this`)}
label={l`Show more like this`}
onPress={onPressShowMore}>
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
<Menu.ItemText>{l`Show more like this`}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSmile} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownShowLessBtn"
label={_(msg`Show less like this`)}
label={l`Show less like this`}
onPress={onPressShowLess}>
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
<Menu.ItemText>{l`Show less like this`}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSad} position="right" />
</Menu.Item>
</Menu.Group>
@@ -560,9 +573,9 @@ let PostMenuItems = ({
<Menu.Divider />
<Menu.Item
testID="postDropdownReportMisclassificationBtn"
label={_(msg`Assign topic for algo`)}
label={l`Assign topic for algo`}
onPress={onReportMisclassification}>
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
<Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText>
<Menu.ItemIcon icon={AtomIcon} position="right" />
</Menu.Item>
</>
@@ -574,12 +587,10 @@ let PostMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownMuteThreadBtn"
label={
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}
label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
onPress={onToggleThreadMute}>
<Menu.ItemText>
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
{isThreadMuted ? l`Unmute thread` : l`Mute thread`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isThreadMuted ? Unmute : Mute}
@@ -589,9 +600,9 @@ let PostMenuItems = ({
<Menu.Item
testID="postDropdownMuteWordsBtn"
label={_(msg`Mute words & tags`)}
label={l`Mute words & tags`}
onPress={() => mutedWordsDialogControl.open()}>
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
<Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item>
</Menu.Group>
@@ -606,16 +617,10 @@ let PostMenuItems = ({
{canHidePostForMe && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
label={isReply ? l`Hide reply for me` : l`Hide post for me`}
onPress={() => hidePromptControl.open()}>
<Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
{isReply ? l`Hide reply for me` : l`Hide post for me`}
</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
@@ -625,8 +630,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn"
label={
isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)
? l`Show reply for everyone`
: l`Hide reply for everyone`
}
onPress={
isReplyHiddenByThreadgate
@@ -635,8 +640,8 @@ let PostMenuItems = ({
}>
<Menu.ItemText>
{isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)}
? l`Show reply for everyone`
: l`Hide reply for everyone`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
@@ -651,8 +656,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn"
label={
quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)
? l`Re-attach quote`
: l`Detach quote`
}
onPress={
quoteEmbed.isDetached
@@ -661,8 +666,8 @@ let PostMenuItems = ({
}>
<Menu.ItemText>
{quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)}
? l`Re-attach quote`
: l`Detach quote`}
</Menu.ItemText>
<Menu.ItemIcon
icon={
@@ -690,14 +695,14 @@ let PostMenuItems = ({
testID="postDropdownMuteBtn"
label={
postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)
? l`Unmute account`
: l`Mute account`
}
onPress={() => void onMuteAuthor()}>
<Menu.ItemText>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)}
? l`Unmute account`
: l`Mute account`}
</Menu.ItemText>
<Menu.ItemIcon
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
@@ -708,18 +713,18 @@ let PostMenuItems = ({
{!postAuthor.viewer?.blocking && (
<Menu.Item
testID="postDropdownBlockBtn"
label={_(msg`Block account`)}
label={l`Block account`}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
<Menu.ItemText>{l`Block account`}</Menu.ItemText>
<Menu.ItemIcon icon={PersonX} position="right" />
</Menu.Item>
)}
<Menu.Item
testID="postDropdownReportBtn"
label={_(msg`Report post`)}
label={l`Report post`}
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
<Menu.ItemText>{l`Report post`}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
</>
@@ -729,7 +734,7 @@ let PostMenuItems = ({
<>
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
label={l`Edit interaction settings`}
onPress={() => postInteractionSettingsDialogControl.open()}
{...(isAuthor
? Platform.select({
@@ -742,15 +747,15 @@ let PostMenuItems = ({
})
: {})}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
{l`Edit interaction settings`}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
label={l`Delete post`}
onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemText>{l`Delete post`}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
@@ -759,28 +764,21 @@ let PostMenuItems = ({
</>
)}
</Menu.Outer>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
title={l`Delete this post?`}
description={l`If you remove this post, you won't be able to recover it.`}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
description={_(
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
)}
title={isReply ? l`Hide this reply?` : l`Hide this post?`}
description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
confirmButtonCta={l`Hide`}
/>
<ReportDialog
control={reportDialogControl}
subject={{
@@ -788,42 +786,32 @@ let PostMenuItems = ({
$type: 'app.bsky.feed.defs#postView',
}}
/>
<PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl}
postUri={post.uri}
rootPostUri={rootUri}
initialThreadgateView={post.threadgate}
/>
<Prompt.Basic
control={quotePostDetachConfirmControl}
title={_(msg`Detach quote post?`)}
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
title={l`Detach quote post?`}
description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)}
confirmButtonCta={l`Yes, detach`}
/>
<Prompt.Basic
control={hideReplyConfirmControl}
title={_(msg`Hide this reply?`)}
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.`,
)}
title={l`Hide this reply?`}
description={l`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={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)}
confirmButtonCta={l`Yes, hide`}
/>
<Prompt.Basic
control={blockPromptControl}
title={_(msg`Block Account?`)}
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
title={l`Block Account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
</>
@@ -6,8 +6,7 @@ import {
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
@@ -30,6 +29,7 @@ let PostMenuButton = ({
onShowLess,
hitSlop,
logContext,
forceGoogleTranslate,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -43,8 +43,9 @@ let PostMenuButton = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
forceGoogleTranslate: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {t: l} = useLingui()
const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false)
@@ -63,7 +64,7 @@ let PostMenuButton = ({
return (
<EventStopper onKeyDown={false}>
<Menu.Root control={lazyMenuControl}>
<Menu.Trigger label={_(msg`Open post options menu`)}>
<Menu.Trigger label={l`Open post options menu`}>
{({props}) => {
return (
<PostControlButton
@@ -90,6 +91,7 @@ let PostMenuButton = ({
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
logContext={logContext}
forceGoogleTranslate={forceGoogleTranslate}
/>
)}
</Menu.Root>
+38 -48
View File
@@ -6,8 +6,8 @@ import {
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
@@ -55,6 +55,7 @@ let PostControls = ({
onShowLess,
viaRepost,
variant,
forceGoogleTranslate = false,
}: {
big?: boolean
post: Shadow<AppBskyFeedDefs.PostView>
@@ -70,9 +71,10 @@ let PostControls = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
viaRepost?: {uri: string; cid: string}
variant?: 'compact' | 'normal' | 'large'
forceGoogleTranslate?: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
@@ -104,10 +106,7 @@ let PostControls = ({
const onPressToggleLike = async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
return
}
@@ -126,7 +125,8 @@ let PostControls = ({
} else {
await queueUnlike()
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
throw e
}
@@ -135,10 +135,7 @@ let PostControls = ({
const onRepost = async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
return
}
@@ -154,7 +151,8 @@ let PostControls = ({
} else {
await queueUnrepost()
}
} catch (e: any) {
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
throw e
}
@@ -163,10 +161,7 @@ let PostControls = ({
const onQuote = () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
return
}
@@ -238,16 +233,14 @@ let PostControls = ({
})
: undefined
}
label={_(
msg({
message: `Reply (${plural(post.replyCount || 0, {
one: '# reply',
other: '# replies',
})})`,
comment:
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
}),
)}
label={l({
message: `Reply (${plural(post.replyCount || 0, {
one: '# reply',
other: '# replies',
})})`,
comment:
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
})}
big={big}>
<PostControlButtonIcon icon={Bubble} />
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
@@ -261,7 +254,7 @@ let PostControls = ({
<RepostButton
isReposted={!!post.viewer?.repost}
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
onRepost={onRepost}
onRepost={() => void onRepost()}
onQuote={onQuote}
big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
@@ -274,26 +267,22 @@ let PostControls = ({
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
? _(
msg({
message: `Unlike (${plural(post.likeCount || 0, {
one: '# like',
other: '# likes',
})})`,
comment:
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
}),
)
: _(
msg({
message: `Like (${plural(post.likeCount || 0, {
one: '# like',
other: '# likes',
})})`,
comment:
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
}),
)
? l({
message: `Unlike (${plural(post.likeCount || 0, {
one: '# like',
other: '# likes',
})})`,
comment:
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
})
: l({
message: `Like (${plural(post.likeCount || 0, {
one: '# like',
other: '# likes',
})})`,
comment:
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
})
}>
<AnimatedLikeIcon
isLiked={Boolean(post.viewer?.like)}
@@ -350,6 +339,7 @@ let PostControls = ({
left: secondaryControlSpacingStyles.gap / 2,
}}
logContext={logContext}
forceGoogleTranslate={forceGoogleTranslate}
/>
</View>
</View>
+4 -4
View File
@@ -5,7 +5,7 @@ import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
@@ -41,7 +41,7 @@ export let MessageContextMenu = ({
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const translate = useTranslate()
const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
@@ -54,12 +54,12 @@ export let MessageContextMenu = ({
true,
)
Clipboard.setStringAsync(str)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
translate(message.text, langPrefs.primaryLanguage)
void translate(message.text, langPrefs.primaryLanguage)
ax.metric('translate', {
sourceLanguages: [],
@@ -6,10 +6,9 @@ import {getTranslatorLink} from '#/locale/helpers'
import {IS_ANDROID} from '#/env'
/**
* Will always link out to Google Translate. If inline translation is desired,
* use `useTranslateOnDevice`
* @deprecated Will always link out to Google Translate. Prefer `useTranslate`.
*/
export function useTranslate() {
export function useGoogleTranslate() {
const openLink = useOpenLink()
return useCallback(
+16
View File
@@ -0,0 +1,16 @@
import {createContext} from 'react'
import {type TranslationFunctionParams, type TranslationState} from './types'
export const Context = createContext<{
translationState: Record<string, TranslationState>
translate: (
parameters: TranslationFunctionParams & {
key: string
forceGoogleTranslate: boolean
},
) => Promise<void>
clearTranslation: (key: string) => void
acquireTranslation: (key: string) => () => void
} | null>(null)
Context.displayName = 'TranslationContext'
+288
View File
@@ -0,0 +1,288 @@
import {useCallback, useContext, useEffect, 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 {useLingui} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import lande from 'lande'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {code3ToCode2Strict} from '#/locale/helpers'
import {logger} from '#/logger'
import {useAnalytics} from '#/analytics'
import {Context} from './context'
import {type TranslationFunctionParams, type TranslationState} from './types'
/**
* 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 since iOS differentiates and missing
// language packs must be downloaded and installed.
if (Platform.OS === 'ios') {
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.
const translatedText =
typeof result.translatedTexts === 'string' ? result.translatedTexts : ''
if (translatedText === input) {
throw new Error('Translation result is the same as the source text.')
}
return {
translatedText,
targetLanguage: result.targetLanguage,
sourceLanguage:
result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language
}
}
// TODO: Replace with expo-guess-language
function guessLanguage(text: string): string | null {
const results = lande(text)
// only return high-confidence results
if (results[0] && results[0][1] > 0.97) {
return code3ToCode2Strict(results[0][0]) ?? null
}
return null
}
/**
* 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 useTranslate({
key,
forceGoogleTranslate = false,
}: {
key: string
forceGoogleTranslate?: boolean
}) {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslate must be used within a TranslateOnDeviceProvider',
)
}
useFocusEffect(
useCallback(() => {
const cleanup = context.acquireTranslation(key)
return cleanup
}, [key, context]),
)
const translate = useCallback(
async (params: TranslationFunctionParams) => {
return context.translate({...params, key, forceGoogleTranslate})
},
[context, forceGoogleTranslate, key],
)
const clearTranslation = useCallback(
() => context.clearTranslation(key),
[context, key],
)
return {
translationState: context.translationState[key] ?? {
status: 'idle',
},
translate,
clearTranslation,
}
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const [translationState, setTranslationState] = useState<
Record<string, TranslationState>
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const ax = useAnalytics()
const {t: l} = useLingui()
const googleTranslate = useGoogleTranslate()
useEffect(() => {
setTranslationState(prev => {
const keysToDelete: string[] = []
for (const key of Object.keys(prev)) {
if ((refCounts[key] ?? 0) <= 0) {
keysToDelete.push(key)
}
}
if (keysToDelete.length > 0) {
const newState = {...prev}
keysToDelete.forEach(key => {
delete newState[key]
})
return newState
}
return prev
})
}, [refCounts])
const acquireTranslation = useCallback((key: string) => {
setRefCounts(prev => ({
...prev,
[key]: (prev[key] ?? 0) + 1,
}))
return () => {
setRefCounts(prev => {
const newCount = (prev[key] ?? 1) - 1
if (newCount <= 0) {
const {[key]: _, ...rest} = prev
return rest
}
return {...prev, [key]: newCount}
})
}
}, [])
const clearTranslation = useCallback((key: string) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => {
delete prev[key]
return {...prev}
})
}, [])
const translate = useCallback(
async ({
key,
text,
targetLangCode,
sourceLangCode,
...options
}: {
key: string
text: string
targetLangCode: string
sourceLangCode?: string
forceGoogleTranslate?: boolean
}) => {
if (options?.forceGoogleTranslate) {
ax.metric('translate:result', {
method: 'google-translate',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await googleTranslate(text, targetLangCode, sourceLangCode)
return
}
// Delay the translation until the animation completes for cases where the
// `attemptTranslation` call resolves very quickly.
const translateAfterAnimation = async () => {
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(prev => ({
...prev,
[key]: {
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,
})
let errorMessage = l`Device failed to translate :(`
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {status: 'error', message: errorMessage},
}))
}
}
// Translate after the next state change.
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
() => void translateAfterAnimation(),
)
setTranslationState(prev => ({
...prev,
[key]: {status: 'loading'},
}))
},
[ax, googleTranslate, l],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[acquireTranslation, clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+83
View File
@@ -0,0 +1,83 @@
import {useCallback, useContext, useMemo} from 'react'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {useAnalytics} from '#/analytics'
import {Context} from './context'
import {type TranslationFunctionParams, type TranslationState} from './types'
const translationState: Record<string, TranslationState> = {}
const acquireTranslation = (_key: string) => {
return () => {}
}
const clearTranslation = (_key: string) => {}
/**
* Web always opens Google Translate.
*/
export function useTranslate({
key,
}: {
key: string
forceGoogleTranslate?: boolean
}) {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslate must be used within a TranslateOnDeviceProvider',
)
}
// Always call hooks in consistent order
const translate = useCallback(
async (params: TranslationFunctionParams) => {
return context.translate({...params, key, forceGoogleTranslate: true})
},
[key, context],
)
const clearTranslation = useCallback(() => {
return context.clearTranslation(key)
}, [key, context])
return {
translationState: context.translationState[key] ?? {
status: 'idle' as const,
},
translate,
clearTranslation,
}
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const ax = useAnalytics()
const googleTranslate = useGoogleTranslate()
const translate = useCallback(
async ({
text,
targetLangCode,
sourceLangCode,
}: {
key: string
text: string
targetLangCode: string
sourceLangCode?: string
}) => {
ax.metric('translate:result', {
method: 'google-translate',
os: 'web',
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await googleTranslate(text, targetLangCode, sourceLangCode)
},
[ax, googleTranslate],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[translate],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+34
View File
@@ -0,0 +1,34 @@
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
export type TranslationState =
| {status: 'idle'}
| {status: 'loading'}
| {
status: 'success'
translatedText: string
sourceLanguage: TranslationTaskResult['sourceLanguage']
targetLanguage: TranslationTaskResult['targetLanguage']
}
| {
status: 'error'
message: string
}
export type TranslationFunctionParams = {
/**
* The text to be translated.
*/
text: string
/**
* The language to translate the text into.
*/
targetLangCode: string
/**
* The source language of the text. Will auto-detect if not provided.
*/
sourceLangCode?: string
}
export type TranslationFunction = (
parameters: TranslationFunctionParams,
) => Promise<void>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import {memo, useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
import {Text as RNText, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
@@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {
getPostLanguage,
getTranslatorLink,
isPostInLanguage,
} from '#/locale/helpers'
import {
POST_TOMBSTONE,
type Shadow,
@@ -26,7 +21,6 @@ import {
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
@@ -44,8 +38,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import {
Provider as TranslateOnDeviceProvider,
useTranslateOnDevice,
} from '#/translation'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
@@ -89,18 +78,16 @@ export function ThreadItemAnchor({
}
return (
<TranslateOnDeviceProvider>
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
</TranslateOnDeviceProvider>
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
)
}
@@ -420,8 +407,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost postText={record.text} hideLoading />
<TranslateLink post={item.value.post} />
<TranslatedPost post={post} postText={record.text} />
{post.embed && (
<View style={[a.py_xs]}>
<Embed
@@ -557,97 +543,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
)
})
function TranslateLink({
post,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
}) {
const t = useTheme()
const ax = useAnalytics()
const {t: l} = useLingui()
const langPrefs = useLanguagePrefs()
const {translate, clearTranslation, translationState} = useTranslateOnDevice()
const needsTranslation = useMemo(
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[post, langPrefs.primaryLanguage],
)
const sourceLanguage = getPostLanguage(post)
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
void translate(
post.record.text || '',
langPrefs.primaryLanguage,
sourceLanguage,
)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
ax.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
return false
},
[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,
@@ -336,6 +336,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
forceGoogleTranslate={true}
/>
<DebugFieldDisplay subject={post} />
</View>
@@ -377,6 +377,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
forceGoogleTranslate={true}
/>
<DebugFieldDisplay subject={post} />
</View>
+30 -38
View File
@@ -33,9 +33,7 @@ import {
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {
type RouteProp,
useFocusEffect,
@@ -451,7 +449,7 @@ function Feed() {
}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
void fetchNextPage()
}
}}
showsVerticalScrollIndicator={false}
@@ -515,6 +513,7 @@ let VideoItem = ({
}
}
}, [
ax,
active,
post.uri,
post.author.did,
@@ -621,7 +620,7 @@ function ModerationOverlay({
embed: AppBskyEmbedVideo.View
onPressShow: () => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const hider = Hider.useHider()
const {bottom} = useSafeAreaInsets()
@@ -648,7 +647,7 @@ function ModerationOverlay({
<Trans>Hidden by your moderation settings.</Trans>
</Text>
<Button
label={_(msg`Show anyway`)}
label={l`Show anyway`}
size="small"
variant="solid"
color="secondary_inverted"
@@ -676,7 +675,7 @@ function ModerationOverlay({
<Divider style={{borderColor: 'white'}} />
<View>
<Button
label={_(msg`View details`)}
label={l`View details`}
onPress={() => {
hider.showInfoDialog()
}}
@@ -724,7 +723,7 @@ function Overlay({
feedContext: string | undefined
reqId: string | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
@@ -811,11 +810,9 @@ function Overlay({
<Animated.View style={[a.px_md, animatedStyle]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
<Link
label={_(
msg`View ${sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}'s profile`,
)}
label={l`View ${sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}'s profile`}
to={{
screen: 'Profile',
params: {name: post.author.did},
@@ -848,13 +845,11 @@ function Overlay({
<Button
label={
profile.viewer?.following
? _(msg`Following ${handle}`)
: _(msg`Follow ${handle}`)
? l`Following ${handle}`
: l`Follow ${handle}`
}
accessibilityHint={
profile.viewer?.following
? _(msg`Unfollows the user`)
: ''
profile.viewer?.following ? l`Unfollows the user` : ''
}
size="small"
variant="solid"
@@ -862,8 +857,8 @@ function Overlay({
style={[a.mb_xs]}
onPress={() =>
profile.viewer?.following
? queueUnfollow()
: queueFollow()
? void queueUnfollow()
: void queueFollow()
}>
{!!profile.viewer?.following && (
<ButtonIcon icon={CheckIcon} />
@@ -892,6 +887,7 @@ function Overlay({
record={record}
feedContext={feedContext}
logContext="FeedItem"
forceGoogleTranslate={true}
onPressReply={() =>
navigation.navigate('PostThread', {
name: post.author.did,
@@ -947,7 +943,7 @@ function ExpandableRichTextView({
const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
const [constrained, setConstrained] = useState(false)
const [contentHeight, setContentHeight] = useState(0)
const {_} = useLingui()
const {t: l} = useLingui()
const {screenReaderEnabled} = useA11y()
if (expanded && !hasBeenExpanded) {
@@ -988,8 +984,8 @@ function ExpandableRichTextView({
/>
{constrained && !screenReaderEnabled && (
<Pressable
accessibilityHint={_(msg`Expands or collapses post text`)}
accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)}
accessibilityHint={l`Expands or collapses post text`}
accessibilityLabel={expanded ? l`Read less` : l`Read more`}
hitSlop={HITSLOP_20}
onPress={() => setExpanded(prev => !prev)}
style={[a.absolute, a.inset_0]}
@@ -1049,7 +1045,7 @@ function PlayPauseTapArea({
feedContext: string | undefined
reqId: string | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const playHaptic = useHaptics()
// TODO: implement viaRepost -sfn
@@ -1092,7 +1088,7 @@ function PlayPauseTapArea({
clearTimeout(doubleTapRef.current)
doubleTapRef.current = null
playHaptic('Light')
queueLike()
void queueLike()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
@@ -1107,16 +1103,12 @@ function PlayPauseTapArea({
return (
<Button
disabled={!player}
aria-valuetext={
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
}
label={_(
msg`Video from ${sanitizeHandle(
post.author.handle,
'@',
)}. Tap to play or pause the video`,
)}
accessibilityHint={_(msg`Double tap to like`)}
aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
label={l`Video from ${sanitizeHandle(
post.author.handle,
'@',
)}. Tap to play or pause the video`}
accessibilityHint={l`Double tap to like`}
onPress={onPress}
style={[a.absolute, a.inset_0, a.z_10]}>
<View />
@@ -1126,7 +1118,7 @@ function PlayPauseTapArea({
function EndMessage() {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
return (
<View
@@ -1177,8 +1169,8 @@ function EndMessage() {
variant="solid"
color="secondary_inverted"
size="small"
label={_(msg`Go back`)}
accessibilityHint={_(msg`Returns to previous page`)}>
label={l`Go back`}
accessibilityHint={l`Returns to previous page`}>
<ButtonIcon icon={ArrowLeftIcon} />
<ButtonText>
<Trans>Go back</Trans>
-199
View File
@@ -1,199 +0,0 @@
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'
import {IS_WEB} from '#/env'
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}: React.PropsWithChildren<unknown>) {
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) {
if (IS_WEB) {
// Web always opens Google Translate.
ax.metric('translate:result', {
method: 'google-translate',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
} else {
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>
}
+3
View File
@@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover'
@@ -152,6 +153,7 @@ function PostInner({
}, [queryClient, post.author, outerOnBeforePress])
const [hover, setHover] = useState(false)
return (
<Link
href={itemHref}
@@ -215,6 +217,7 @@ function PostInner({
onPress={onPressShowMore}
/>
)}
<TranslatedPost post={post} postText={record.text} />
</View>
) : undefined}
{post.embed ? (
+29 -14
View File
@@ -42,6 +42,7 @@ import {Embed} from '#/components/Post/Embed'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls} from '#/components/PostControls'
import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
import {RichText} from '#/components/RichText'
@@ -450,6 +451,14 @@ let PostContent = ({
: []
}, [post, currentAccount?.did, threadgateHiddenReplies])
const record = useMemo<AppBskyFeedPost.Record | undefined>(
() =>
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
? post.record
: undefined,
[post],
)
const onPressShowMore = useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
@@ -466,20 +475,26 @@ let PostContent = ({
additionalCauses={additionalPostAlerts}
/>
{richText.text ? (
<View style={[a.mb_2xs]}>
<RichText
enableTags
testID="postText"
value={richText}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={postAuthor.handle}
shouldProxyLinks={true}
/>
{limitLines && (
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
)}
</View>
<>
<View style={[a.mb_2xs]}>
<RichText
enableTags
testID="postText"
value={richText}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={postAuthor.handle}
shouldProxyLinks={true}
/>
{limitLines && (
<ShowMoreTextButton
style={[a.text_md]}
onPress={onPressShowMore}
/>
)}
</View>
{record && <TranslatedPost post={post} postText={record.text} />}
</>
) : undefined}
{postEmbed ? (
<View style={[a.pb_xs]}>
+4 -4
View File
@@ -3731,10 +3731,10 @@
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==
"@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/expo-translate-text@^0.2.6":
version "0.2.6"
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.6.tgz#04bf66a3768e742ea193258b65373b3df23daf72"
integrity sha512-VahweznNSg9OJSwrVWSh97mNk3LJw1rkQxw4zf9c9TuEZEws/68ARtrUmxT/j7aP8WpT+PfNop+Eg8ooam806A==
"@bsky.app/react-native-mmkv@2.12.5":
version "2.12.5"