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 = ({ )} - - 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() { ) : ( - + + + )}