move translate stuff to a features directory

This commit is contained in:
Samuel Newman
2026-03-03 15:49:47 +02:00
parent f1ee4d2c7c
commit d8aa9ff190
5 changed files with 221 additions and 204 deletions
@@ -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<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' ? (
<Button
label={l`Hide translation`}
onPress={onHideTranslation}
hoverStyle={native({opacity: 0.5})}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Hide translation</Trans>
</Text>
</Button>
) : (
<Button
label={l`Translate`}
onPress={onTranslatePress}
hoverStyle={native({opacity: 0.5})}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Translate</Trans>
</Text>
</Button>
)}
</View>
)
)
}
@@ -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 <TranslationLoading />
}
if (translationState.status === 'success') {
return (
<TranslationResult
postText={postText}
sourceLanguage={translationState.sourceLanguage}
translatedText={translationState.translatedText}
/>
)
}
return null
}
function TranslationLoading() {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
<Loader size="sm" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating</Trans>
</Text>
</View>
)
}
function TranslationResult({
postText,
sourceLanguage,
translatedText,
}: {
postText: string
sourceLanguage: string | null
translatedText: string
}) {
const t = useTheme()
const {i18n} = useLingui()
const langName = sourceLanguage
? codeToLanguageName(sourceLanguage, i18n.locale)
: undefined
return (
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
<View style={[a.flex_row, a.align_center]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{langName ? (
<Trans>Translated from {langName}</Trans>
) : (
<Trans>Translated</Trans>
)}
</Text>
{sourceLanguage != null && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
</Text>
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
postText={postText}
/>
</>
)}
</View>
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText}
</Text>
</View>
)
}
function TranslationLanguageSelect({
export function TranslationLanguageSelect({
postText,
sourceLanguage,
}: {
@@ -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 <TranslationLoading />
}
if (translationState.status === 'success') {
return (
<TranslationSuccess
postText={postText}
sourceLanguage={translationState.sourceLanguage}
translatedText={translationState.translatedText}
/>
)
}
return null
}
function TranslationLoading() {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
<Loader size="sm" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating</Trans>
</Text>
</View>
)
}
function 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 (
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
<View style={[a.flex_row, a.align_center]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{langName ? (
<Trans>Translated from {langName}</Trans>
) : (
<Trans>Translated</Trans>
)}
</Text>
{sourceLanguage != null && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{' '}
&middot;{' '}
</Text>
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
postText={postText}
/>
</>
)}
</View>
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText}
</Text>
</View>
)
}
@@ -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<ReturnType<typeof onTranslateTask>>
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,
@@ -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}
<TranslatedPost postText={record.text} hideLoading />
<TranslateLink post={item.value.post} />
<TranslationResult postText={record.text} hideLoading />
<InlineTranslateButton post={item.value.post} />
{post.embed && (
<View style={[a.py_xs]}>
<Embed
@@ -554,101 +548,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' ? (
<Button
label={l`Hide translation`}
onPress={onHideTranslation}
hoverStyle={native({opacity: 0.5})}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Hide translation</Trans>
</Text>
</Button>
) : (
<Button
label={l`Translate`}
onPress={onTranslatePress}
hoverStyle={native({opacity: 0.5})}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Translate</Trans>
</Text>
</Button>
)}
</View>
)
)
}
function ExpandedPostDetails({
post,
isThreadAuthor,