diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx
index 68176577a0..b650153dfc 100644
--- a/src/components/Post/Translated/index.tsx
+++ b/src/components/Post/Translated/index.tsx
@@ -1,38 +1,41 @@
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 {Trans, useLingui} from '@lingui/react/macro'
+import {HITSLOP_30} from '#/lib/constants'
+import {useTranslateOnDevice, useTranslationKey} 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} from '#/alf'
+import {Button} from '#/components/Button'
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({
+ translationKey,
postText,
- hideLoading = false,
}: {
+ translationKey: string
postText: string
- hideLoading: boolean
}) {
const {translationState} = useTranslateOnDevice()
+ // Register this component as using this translation key with focus-based cleanup
+ useTranslationKey(translationKey)
- if (translationState.status === 'loading' && !hideLoading) {
+ if (translationState[translationKey]?.status === 'loading') {
return
}
- if (translationState.status === 'success') {
+ if (translationState[translationKey]?.status === 'success') {
return (
)
}
@@ -44,20 +47,24 @@ function TranslationLoading() {
const t = useTheme()
return (
-
-
-
- Translating…
-
+
+
+
+
+ Translating…
+
+
)
}
function TranslationResult({
+ translationKey,
postText,
sourceLanguage,
translatedText,
}: {
+ translationKey: string
postText: string
sourceLanguage: string | null
translatedText: string
@@ -71,25 +78,28 @@ function TranslationResult({
return (
-
- {langName ? (
- Translated from {langName}
- ) : (
- Translated
- )}
+
+
+ {langName ? (
+ Translated from {langName}
+ ) : (
+ Translated
+ )}
+
{sourceLanguage != null && (
<>
{' '}
- ·
- {' '}
+ ·{' '}
+
>
)}
-
+
{translatedText}
@@ -98,14 +108,16 @@ function TranslationResult({
}
function TranslationLanguageSelect({
+ translationKey,
postText,
sourceLanguage,
}: {
+ translationKey: string
postText: string
sourceLanguage: string
}) {
const ax = useAnalytics()
- const {_} = useLingui()
+ const {t: l} = useLingui()
const langPrefs = useLanguagePrefs()
const {translate} = useTranslateOnDevice()
@@ -136,24 +148,35 @@ function TranslationLanguageSelect({
sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage,
})
- void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
+ void translate(
+ translationKey,
+ postText,
+ langPrefs.primaryLanguage,
+ sourceLangCode,
+ )
}
return (
-
+
{({props}) => {
return (
-
- Change
-
+
)
}}
(
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
index 13168bd2a3..3985df01e2 100644
--- a/src/components/PostControls/PostMenu/PostMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -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 {useTranslateOnDevice} 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,
+ googleTranslate,
}: {
testID: string
post: Shadow
@@ -120,9 +121,10 @@ let PostMenuItems = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ googleTranslate: 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,7 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
- const translate = useTranslate()
+ const {clearTranslation, translate, translationState} = useTranslateOnDevice()
const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
@@ -188,10 +190,12 @@ let PostMenuItems = ({
return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor])
+ const translationKey = post.uri
+
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 +215,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 +230,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 +239,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 +254,19 @@ 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(
+ translationKey,
+ record.text,
+ langPrefs.primaryLanguage,
+ undefined,
+ {
+ googleTranslate,
+ },
+ )
if (
bsky.dangerousIsType(
@@ -297,9 +304,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 +327,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 +346,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 +384,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 +423,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 +437,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 +468,8 @@ let PostMenuItems = ({
const onSignIn = () => requireSignIn(() => {})
+ const onPressHideTranslation = () => clearTranslation(translationKey)
+
const isDiscoverDebugUser =
IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
@@ -481,16 +484,12 @@ let PostMenuItems = ({
- {isPinned
- ? _(msg`Unpin from profile`)
- : _(msg`Pin to your profile`)}
+ {isPinned ? l`Unpin from profile` : l`Pin to your profile`}
{!hideInPWI || hasSession ? (
<>
-
- {_(msg`Translate`)}
-
-
+ {translationState[translationKey]?.status === 'loading' ? (
+ {}}>
+ {l`Translating…`}
+
+
+ ) : translationState[translationKey]?.status === 'success' ? (
+
+ {l`Hide translation`}
+
+
+ ) : (
+
+ {l`Translate`}
+
+
+ )}
- {_(msg`Copy post text`)}
+ {l`Copy post text`}
>
) : (
- {_(msg`Sign in to view post`)}
+ {l`Sign in to view post`}
)}
@@ -538,17 +555,17 @@ let PostMenuItems = ({
- {_(msg`Show more like this`)}
+ {l`Show more like this`}
- {_(msg`Show less like this`)}
+ {l`Show less like this`}
@@ -560,9 +577,9 @@ let PostMenuItems = ({
- {_(msg`Assign topic for algo`)}
+ {l`Assign topic for algo`}
>
@@ -574,12 +591,10 @@ let PostMenuItems = ({
- {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
+ {isThreadMuted ? l`Unmute thread` : l`Mute thread`}
mutedWordsDialogControl.open()}>
- {_(msg`Mute words & tags`)}
+ {l`Mute words & tags`}
@@ -606,16 +621,10 @@ let PostMenuItems = ({
{canHidePostForMe && (
hidePromptControl.open()}>
- {isReply
- ? _(msg`Hide reply for me`)
- : _(msg`Hide post for me`)}
+ {isReply ? l`Hide reply for me` : l`Hide post for me`}
@@ -625,8 +634,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 +644,8 @@ let PostMenuItems = ({
}>
{isReplyHiddenByThreadgate
- ? _(msg`Show reply for everyone`)
- : _(msg`Hide reply for everyone`)}
+ ? l`Show reply for everyone`
+ : l`Hide reply for everyone`}
{quoteEmbed.isDetached
- ? _(msg`Re-attach quote`)
- : _(msg`Detach quote`)}
+ ? l`Re-attach quote`
+ : l`Detach quote`}
void onMuteAuthor()}>
{postAuthor.viewer?.muted
- ? _(msg`Unmute account`)
- : _(msg`Mute account`)}
+ ? l`Unmute account`
+ : l`Mute account`}
blockPromptControl.open()}>
- {_(msg`Block account`)}
+ {l`Block account`}
)}
reportDialogControl.open()}>
- {_(msg`Report post`)}
+ {l`Report post`}
>
@@ -729,7 +738,7 @@ let PostMenuItems = ({
<>
postInteractionSettingsDialogControl.open()}
{...(isAuthor
? Platform.select({
@@ -742,15 +751,15 @@ let PostMenuItems = ({
})
: {})}>
- {_(msg`Edit interaction settings`)}
+ {l`Edit interaction settings`}
deletePromptControl.open()}>
- {_(msg`Delete post`)}
+ {l`Delete post`}
>
@@ -759,28 +768,21 @@ let PostMenuItems = ({
>
)}
-
-
-
-
-
void onToggleQuotePostAttachment()}
- confirmButtonCta={_(msg`Yes, detach`)}
+ confirmButtonCta={l`Yes, detach`}
/>
-
void onToggleReplyVisibility()}
- confirmButtonCta={_(msg`Yes, hide`)}
+ confirmButtonCta={l`Yes, hide`}
/>
-
void onBlockAuthor()}
- confirmButtonCta={_(msg`Block`)}
+ confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
>
diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx
index dbf0744990..ff4bf167b1 100644
--- a/src/components/PostControls/PostMenu/index.tsx
+++ b/src/components/PostControls/PostMenu/index.tsx
@@ -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,
+ googleTranslate,
}: {
testID: string
post: Shadow
@@ -43,8 +43,9 @@ let PostMenuButton = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+ googleTranslate: boolean
}): React.ReactNode => {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false)
@@ -63,7 +64,7 @@ let PostMenuButton = ({
return (
-
+
{({props}) => {
return (
)}
diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx
index a27ca75b47..b915174121 100644
--- a/src/components/PostControls/index.tsx
+++ b/src/components/PostControls/index.tsx
@@ -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,
+ googleTranslate = true,
}: {
big?: boolean
post: Shadow
@@ -70,9 +71,10 @@ let PostControls = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
viaRepost?: {uri: string; cid: string}
variant?: 'compact' | 'normal' | 'large'
+ googleTranslate?: 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
}
@@ -135,10 +134,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
}
@@ -163,10 +159,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 +231,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}>
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
@@ -274,26 +265,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',
+ })
}>
diff --git a/src/lib/translation/context.ts b/src/lib/translation/context.ts
new file mode 100644
index 0000000000..98f8dabd75
--- /dev/null
+++ b/src/lib/translation/context.ts
@@ -0,0 +1,17 @@
+import {createContext} from 'react'
+
+import {type Options, type TranslationState} from './types'
+
+export const Context = createContext<{
+ translationState: Record
+ translate: (
+ key: string,
+ text: string,
+ targetLangCode: string,
+ sourceLangCode?: string,
+ options?: Options,
+ ) => Promise
+ clearTranslation: (key: string) => void
+ acquireTranslation: (key: string) => () => void
+} | null>(null)
+Context.displayName = 'TranslationContext'
diff --git a/src/translation/index.tsx b/src/lib/translation/index.tsx
similarity index 53%
rename from src/translation/index.tsx
rename to src/lib/translation/index.tsx
index 7c5f05f33a..98a3f3e93c 100644
--- a/src/translation/index.tsx
+++ b/src/lib/translation/index.tsx
@@ -1,32 +1,16 @@
-import React, {
- createContext,
- useCallback,
- useContext,
- useMemo,
- useState,
-} from 'react'
+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 {useFocusEffect} from '@react-navigation/native'
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'}
+import {Context} from './context'
+import {type Options, type TranslationState} from './types'
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
@@ -87,21 +71,6 @@ async function attemptTranslation(
}
}
-const Context = createContext<{
- translationState: TranslationState
- translate: (
- text: string,
- targetLangCode: string,
- sourceLangCode?: string,
- ) => Promise
- 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).
@@ -120,25 +89,108 @@ export function useTranslateOnDevice() {
return context
}
+/**
+ * Hook to register a component as using a translation key.
+ * Automatically handles ref counting with screen focus management
+ * via useFocusEffect and cleans up the translation when the component
+ * loses focus.
+ */
+export function useTranslationKey(key: string) {
+ const {acquireTranslation} = useTranslateOnDevice()
+
+ useFocusEffect(
+ useCallback(() => {
+ const cleanup = acquireTranslation(key)
+ return cleanup
+ }, [key, acquireTranslation]),
+ )
+}
+
export function Provider({children}: React.PropsWithChildren) {
- const [translationState, setTranslationState] =
- useState(IDLE)
+ const [translationState, setTranslationState] = useState<
+ Record
+ >({})
+ const [refCounts, setRefCounts] = useState>({})
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
- const clearTranslation = useCallback(() => {
+ 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(IDLE)
+ setTranslationState(prev => {
+ delete prev[key]
+ return {...prev}
+ })
}, [])
const translate = useCallback(
async (
+ key: string,
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode?: string,
+ options?: Options,
) => {
- setTranslationState({status: 'loading'})
+ const translateUrl = getTranslatorLink(
+ text,
+ targetLangCode,
+ sourceLangCode,
+ )
+ if (options?.googleTranslate) {
+ ax.metric('translate:result', {
+ method: 'google-translate',
+ os: Platform.OS,
+ sourceLanguage: sourceLangCode ?? null,
+ targetLanguage: targetLangCode,
+ })
+ await openLink(translateUrl)
+ return
+ }
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setTranslationState(prev => ({
+ ...prev,
+ [key]: {status: 'loading'},
+ }))
try {
const result = await attemptTranslation(
text,
@@ -152,47 +204,39 @@ export function Provider({children}: React.PropsWithChildren) {
targetLanguage: result.targetLanguage,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
- setTranslationState({
- status: 'success',
- translatedText: result.translatedText,
- sourceLanguage: result.sourceLanguage,
- targetLanguage: result.targetLanguage,
- })
+ setTranslationState(prev => ({
+ ...prev,
+ [key]: {
+ 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,
- )
+ 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,
+ })
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setTranslationState(prev => ({
+ ...prev,
+ [key]: {status: 'idle'},
+ }))
await openLink(translateUrl)
}
},
- [ax, openLink, primaryLanguage, setTranslationState],
+ [ax, openLink, primaryLanguage],
)
const ctx = useMemo(
- () => ({clearTranslation, translate, translationState}),
- [clearTranslation, translate, translationState],
+ () => ({acquireTranslation, clearTranslation, translate, translationState}),
+ [acquireTranslation, clearTranslation, translate, translationState],
)
return {children}
diff --git a/src/lib/translation/index.web.tsx b/src/lib/translation/index.web.tsx
new file mode 100644
index 0000000000..e5090972e2
--- /dev/null
+++ b/src/lib/translation/index.web.tsx
@@ -0,0 +1,66 @@
+import {useCallback, useContext, useMemo} from 'react'
+
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {getTranslatorLink} from '#/locale/helpers'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useAnalytics} from '#/analytics'
+import {Context} from './context'
+import {type Options, type TranslationState} from './types'
+
+const translationState: Record = {}
+const acquireTranslation = (_key: string) => {
+ return () => {}
+}
+const clearTranslation = (_key: string) => {}
+
+export function useTranslationKey(_key: string) {}
+
+/**
+ * Web 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) {
+ const openLink = useOpenLink()
+ const ax = useAnalytics()
+ const {primaryLanguage} = useLanguagePrefs()
+
+ const translate = useCallback(
+ async (
+ _key: string,
+ text: string,
+ targetLangCode: string = primaryLanguage,
+ sourceLangCode?: string,
+ _options?: Options,
+ ) => {
+ ax.metric('translate:result', {
+ method: 'google-translate',
+ os: 'web',
+ sourceLanguage: sourceLangCode ?? null,
+ targetLanguage: targetLangCode,
+ })
+ const translateUrl = getTranslatorLink(
+ text,
+ targetLangCode,
+ sourceLangCode,
+ )
+ await openLink(translateUrl)
+ },
+ [ax, openLink, primaryLanguage],
+ )
+
+ const ctx = useMemo(
+ () => ({acquireTranslation, clearTranslation, translate, translationState}),
+ [translate],
+ )
+
+ return {children}
+}
diff --git a/src/lib/translation/types.ts b/src/lib/translation/types.ts
new file mode 100644
index 0000000000..f43b881605
--- /dev/null
+++ b/src/lib/translation/types.ts
@@ -0,0 +1,18 @@
+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']
+ }
+
+export type Options = {
+ /**
+ * Whether to force the use of Google Translate. Default is false.
+ */
+ googleTranslate?: boolean
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index 38a339f540..7211f82c23 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -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 (
-
-
-
+
)
}
@@ -325,6 +312,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
}
}
+ const translationKey = post.uri
+
return (
<>
@@ -420,8 +409,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true}
/>
) : undefined}
-
-
+
{post.embed && (
@@ -557,97 +549,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
)
})
-function TranslateLink({
- post,
-}: {
- post: Extract['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(
- 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 && (
-
- {translationState.status === 'loading' ? (
-
-
-
- Translating…
-
-
- ) : translationState.status === 'success' ? (
-
- Hide translation
-
- ) : (
-
- Translate
-
- )}
-
- )
- )
-}
-
function ExpandedPostDetails({
post,
isThreadAuthor,
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 67eebc5ef8..9887658a67 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -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,9 @@ function PostInner({
}, [queryClient, post.author, outerOnBeforePress])
const [hover, setHover] = useState(false)
+
+ const translationKey = post.uri
+
return (
)}
+
) : undefined}
{post.embed ? (
@@ -231,6 +239,7 @@ function PostInner({
richText={richText}
onPressReply={onPressReply}
logContext="Post"
+ googleTranslate={false}
/>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index d15980ba4d..5a65631683 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -11,6 +11,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers'
+import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
import {useSession} from '#/state/session'
import {
@@ -240,7 +241,9 @@ export function Shell() {
) : (
-
+
+
+
)}
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index a7f255570e..d371b4f9e1 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -7,6 +7,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {type NavigationProp} from '#/lib/routes/types'
+import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import {useSession} from '#/state/session'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
@@ -175,7 +176,9 @@ export function Shell() {
) : (
-
+
+
+
)}