diff --git a/src/features/translation/components/InlineTranslateButton.tsx b/src/features/translation/components/InlineTranslateButton.tsx new file mode 100644 index 0000000000..ed00ac7fee --- /dev/null +++ b/src/features/translation/components/InlineTranslateButton.tsx @@ -0,0 +1,111 @@ +import {useCallback, useMemo} from 'react' +import {type GestureResponderEvent, View} from 'react-native' +import {AppBskyFeedPost} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {HITSLOP_30} from '#/lib/constants' +import {getPostLanguage, isPostInLanguage} from '#/locale/helpers' +import {useLanguagePrefs} from '#/state/preferences' +import {type ThreadItem} from '#/state/queries/usePostThread/types' +import {atoms as a, native, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {useTranslateOnDevice} from '#/features/translation' +import * as bsky from '#/types/bsky' + +export function InlineTranslateButton({ + 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' ? ( + + ) : ( + + )} + + ) + ) +} diff --git a/src/components/Post/Translated/index.tsx b/src/features/translation/components/TranslationLanguageSelect.tsx similarity index 54% rename from src/components/Post/Translated/index.tsx rename to src/features/translation/components/TranslationLanguageSelect.tsx index a08aa24064..3398f4effc 100644 --- a/src/components/Post/Translated/index.tsx +++ b/src/features/translation/components/TranslationLanguageSelect.tsx @@ -1,107 +1,21 @@ import {useMemo} from 'react' -import {Platform, View} from 'react-native' +import {Platform} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {HITSLOP_30} from '#/lib/constants' -import {codeToLanguageName, languageName} from '#/locale/helpers' +import {languageName} from '#/locale/helpers' import {LANGUAGES} from '#/locale/languages' import {useLanguagePrefs} from '#/state/preferences' -import {atoms as a, native, useTheme} from '#/alf' +import {atoms as a, native} 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' +import {useTranslateOnDevice} from '#/features/translation' -export function TranslatedPost({ - postText, - hideLoading = false, -}: { - postText: string - hideLoading: boolean -}) { - const {translationState} = useTranslateOnDevice() - - if (translationState.status === 'loading' && !hideLoading) { - return - } - - if (translationState.status === 'success') { - return ( - - ) - } - - return null -} - -function TranslationLoading() { - const t = useTheme() - - return ( - - - - Translating… - - - ) -} - -function TranslationResult({ - postText, - sourceLanguage, - translatedText, -}: { - postText: string - sourceLanguage: string | null - translatedText: string -}) { - const t = useTheme() - const {i18n} = useLingui() - - const langName = sourceLanguage - ? codeToLanguageName(sourceLanguage, i18n.locale) - : undefined - - return ( - - - - {langName ? ( - Translated from {langName} - ) : ( - Translated - )} - - {sourceLanguage != null && ( - <> - - {' '} - ·{' '} - - - - )} - - - {translatedText} - - - ) -} - -function TranslationLanguageSelect({ +export function TranslationLanguageSelect({ postText, sourceLanguage, }: { diff --git a/src/features/translation/components/TranslationResult.tsx b/src/features/translation/components/TranslationResult.tsx new file mode 100644 index 0000000000..f1c8a40bf7 --- /dev/null +++ b/src/features/translation/components/TranslationResult.tsx @@ -0,0 +1,95 @@ +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {codeToLanguageName} from '#/locale/helpers' +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {useTranslateOnDevice} from '#/features/translation' +import {TranslationLanguageSelect} from './TranslationLanguageSelect' + +export function TranslationResult({ + postText, + hideLoading = false, +}: { + postText: string + hideLoading: boolean +}) { + const {translationState} = useTranslateOnDevice() + + if (translationState.status === 'loading' && !hideLoading) { + return + } + + if (translationState.status === 'success') { + return ( + + ) + } + + return null +} + +function TranslationLoading() { + const t = useTheme() + + return ( + + + + Translating… + + + ) +} + +function TranslationSuccess({ + postText, + sourceLanguage, + translatedText, +}: { + postText: string + sourceLanguage: string | null + translatedText: string +}) { + const t = useTheme() + const {i18n} = useLingui() + + const langName = sourceLanguage + ? codeToLanguageName(sourceLanguage, i18n.locale) + : undefined + + return ( + + + + {langName ? ( + Translated from {langName} + ) : ( + Translated + )} + + {sourceLanguage != null && ( + <> + + {' '} + ·{' '} + + + + )} + + + {translatedText} + + + ) +} diff --git a/src/translation/index.tsx b/src/features/translation/index.tsx similarity index 94% rename from src/translation/index.tsx rename to src/features/translation/index.tsx index 7c5f05f33a..49ce2c4111 100644 --- a/src/translation/index.tsx +++ b/src/features/translation/index.tsx @@ -7,7 +7,7 @@ import React, { } 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 {onTranslateTask} from '@bsky.app/expo-translate-text' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {getTranslatorLink} from '#/locale/helpers' @@ -16,6 +16,8 @@ import {useLanguagePrefs} from '#/state/preferences' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' +type TranslationTaskResult = Awaited> + type TranslationState = | {status: 'idle'} | {status: 'loading'} @@ -68,10 +70,6 @@ async function attemptTranslation( } } - 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, diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 2167269c77..5db4069a3d 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, @@ -9,13 +9,11 @@ import { } from '@atproto/api' import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {HITSLOP_30} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 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, isPostInLanguage} from '#/locale/helpers' import { POST_TOMBSTONE, type Shadow, @@ -23,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' @@ -36,19 +33,17 @@ import { OUTER_SPACE, REPLY_LINE_WIDTH, } from '#/screens/PostThread/const' -import {atoms as a, native, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' 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 {Link} from '#/components/Link' -import {Loader} from '#/components/Loader' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' -import {TranslatedPost} from '#/components/Post/Translated' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {useFormatPostStatCount} from '#/components/PostControls/util' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -60,10 +55,9 @@ 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 {Provider as TranslateOnDeviceProvider} from '#/features/translation' +import {InlineTranslateButton} from '#/features/translation/components/InlineTranslateButton' +import {TranslationResult} from '#/features/translation/components/TranslationResult' import * as bsky from '#/types/bsky' export function ThreadItemAnchor({ @@ -417,8 +411,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ shouldProxyLinks={true} /> ) : undefined} - - + + {post.embed && ( ['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' ? ( - - ) : ( - - )} - - ) - ) -} - function ExpandedPostDetails({ post, isThreadAuthor,