Refine post translation UX

This commit is contained in:
DS Boyce
2026-03-02 12:30:19 -08:00
parent 8b1c47a499
commit 701b1ed0c6
12 changed files with 474 additions and 408 deletions
+55 -32
View File
@@ -1,38 +1,41 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {Platform, View} from 'react-native' import {Platform, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {useTranslateOnDevice, useTranslationKey} from '#/lib/translation'
import {codeToLanguageName, languageName} from '#/locale/helpers' import {codeToLanguageName, languageName} from '#/locale/helpers'
import {LANGUAGES} from '#/locale/languages' import {LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences' 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 {Loader} from '#/components/Loader'
import * as Select from '#/components/Select' import * as Select from '#/components/Select'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useTranslateOnDevice} from '#/translation'
export function TranslatedPost({ export function TranslatedPost({
translationKey,
postText, postText,
hideLoading = false,
}: { }: {
translationKey: string
postText: string postText: string
hideLoading: boolean
}) { }) {
const {translationState} = useTranslateOnDevice() 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 <TranslationLoading /> return <TranslationLoading />
} }
if (translationState.status === 'success') { if (translationState[translationKey]?.status === 'success') {
return ( return (
<TranslationResult <TranslationResult
translationKey={translationKey}
postText={postText} postText={postText}
sourceLanguage={translationState.sourceLanguage} sourceLanguage={translationState[translationKey]?.sourceLanguage}
translatedText={translationState.translatedText} translatedText={translationState[translationKey]?.translatedText}
/> />
) )
} }
@@ -44,20 +47,24 @@ function TranslationLoading() {
const t = useTheme() const t = useTheme()
return ( return (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}> <View style={[a.gap_md, a.pt_md, a.align_start]}>
<Loader size="sm" /> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Loader size="xs" />
<Trans>Translating…</Trans> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
</Text> <Trans>Translating…</Trans>
</Text>
</View>
</View> </View>
) )
} }
function TranslationResult({ function TranslationResult({
translationKey,
postText, postText,
sourceLanguage, sourceLanguage,
translatedText, translatedText,
}: { }: {
translationKey: string
postText: string postText: string
sourceLanguage: string | null sourceLanguage: string | null
translatedText: string translatedText: string
@@ -71,25 +78,28 @@ function TranslationResult({
return ( return (
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}> <View style={[a.py_xs, a.gap_xs, a.mt_sm]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}> <View style={[a.flex_row, a.align_center]}>
{langName ? ( <Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>Translated from {langName}</Trans> {langName ? (
) : ( <Trans>Translated from {langName}</Trans>
<Trans>Translated</Trans> ) : (
)} <Trans>Translated</Trans>
)}
</Text>
{sourceLanguage != null && ( {sourceLanguage != null && (
<> <>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{' '} {' '}
&middot; &middot;{' '}
</Text>{' '} </Text>
<TranslationLanguageSelect <TranslationLanguageSelect
sourceLanguage={sourceLanguage} sourceLanguage={sourceLanguage}
translationKey={translationKey}
postText={postText} postText={postText}
/> />
</> </>
)} )}
</Text> </View>
<Text emoji selectable style={[a.text_md, a.leading_snug]}> <Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText} {translatedText}
</Text> </Text>
@@ -98,14 +108,16 @@ function TranslationResult({
} }
function TranslationLanguageSelect({ function TranslationLanguageSelect({
translationKey,
postText, postText,
sourceLanguage, sourceLanguage,
}: { }: {
translationKey: string
postText: string postText: string
sourceLanguage: string sourceLanguage: string
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {translate} = useTranslateOnDevice() const {translate} = useTranslateOnDevice()
@@ -136,24 +148,35 @@ function TranslationLanguageSelect({
sourceLanguage: sourceLangCode, sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage, targetLanguage: langPrefs.primaryLanguage,
}) })
void translate(postText, langPrefs.primaryLanguage, sourceLangCode) void translate(
translationKey,
postText,
langPrefs.primaryLanguage,
sourceLangCode,
)
} }
return ( return (
<Select.Root <Select.Root
value={sourceLanguage} value={sourceLanguage}
onValueChange={handleChangeTranslationLanguage}> onValueChange={handleChangeTranslationLanguage}>
<Select.Trigger hitSlop={10} label={_(msg`Change source language`)}> <Select.Trigger label={l`Change the source language`}>
{({props}) => { {({props}) => {
return ( return (
<Text {...props} style={[a.text_xs]}> <Button
<Trans>Change</Trans> label={props.accessibilityLabel}
</Text> {...props}
hitSlop={HITSLOP_30}
hoverStyle={native({opacity: 0.5})}>
<Text style={[a.text_xs]}>
<Trans>Change</Trans>
</Text>
</Button>
) )
}} }}
</Select.Trigger> </Select.Trigger>
<Select.Content <Select.Content
label={_(msg`Select the source language`)} label={l`Select the source language`}
renderItem={({label, value}) => ( renderItem={({label, value}) => (
<Select.Item value={value} label={label}> <Select.Item value={value} label={label}>
<Select.ItemIndicator /> <Select.ItemIndicator />
@@ -13,13 +13,12 @@ import {
AtUri, AtUri,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg, plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {getCurrentRoute} from '#/lib/routes/helpers' import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import { import {
@@ -28,6 +27,7 @@ import {
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTranslateOnDevice} from '#/lib/translation'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -106,6 +106,7 @@ let PostMenuItems = ({
threadgateRecord, threadgateRecord,
onShowLess, onShowLess,
logContext, logContext,
googleTranslate,
}: { }: {
testID: string testID: string
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -120,9 +121,10 @@ let PostMenuItems = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
googleTranslate: boolean
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
@@ -133,7 +135,7 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi() const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext() const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink() const openLink = useOpenLink()
const translate = useTranslate() const {clearTranslation, translate, translationState} = useTranslateOnDevice()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl() const blockPromptControl = useDialogControl()
@@ -188,10 +190,12 @@ let PostMenuItems = ({
return makeProfileLink(postAuthor, 'post', urip.rkey) return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor]) }, [postUri, postAuthor])
const translationKey = post.uri
const onDeletePost = () => { const onDeletePost = () => {
deletePostMutate({uri: postUri}).then( 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()) const route = getCurrentRoute(navigation.getState())
if (route.name === 'PostThread') { if (route.name === 'PostThread') {
@@ -211,7 +215,7 @@ let PostMenuItems = ({
}, },
e => { e => {
logger.error('Failed to delete post', {message: 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, logContext,
feedDescriptor: feedFeedback.feedDescriptor, 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 { } else {
void muteThread() void muteThread()
ax.metric('post:mute', { ax.metric('post:mute', {
@@ -235,18 +239,13 @@ let PostMenuItems = ({
logContext, logContext,
feedDescriptor: feedFeedback.feedDescriptor, feedDescriptor: feedFeedback.feedDescriptor,
}) })
Toast.show( Toast.show(l`You will no longer receive notifications for this thread`)
_(msg`You will no longer receive notifications for this thread`),
)
} }
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e}) logger.error('Failed to toggle thread mute', {message: e})
Toast.show( Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
_(msg`Failed to toggle thread mute, please try again`),
'xmark',
)
} }
} }
} }
@@ -255,11 +254,19 @@ let PostMenuItems = ({
const str = richTextToString(richText, true) const str = richTextToString(richText, true)
void Clipboard.setStringAsync(str) void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') Toast.show(l`Copied to clipboard`, 'clipboard-check')
} }
const onPressTranslate = () => { const onPressTranslate = () => {
void translate(record.text, langPrefs.primaryLanguage) void translate(
translationKey,
record.text,
langPrefs.primaryLanguage,
undefined,
{
googleTranslate,
},
)
if ( if (
bsky.dangerousIsType<AppBskyFeedPost.Record>( bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -297,9 +304,7 @@ let PostMenuItems = ({
logContext, logContext,
feedDescriptor: feedFeedback.feedDescriptor, feedDescriptor: feedFeedback.feedDescriptor,
}) })
Toast.show( Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
)
} }
const onPressShowLess = () => { const onPressShowLess = () => {
@@ -322,7 +327,7 @@ let PostMenuItems = ({
}) })
} else { } else {
Toast.show( 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( Toast.show(
isDetach isDetach
? _(msg`Quote post was successfully detached`) ? l`Quote post was successfully detached`
: _(msg`Quote post was re-attached`), : l`Quote post was re-attached`,
) )
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
Toast.show( 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}) logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
} }
@@ -379,31 +384,27 @@ let PostMenuItems = ({
Toast.show( Toast.show(
isHide isHide
? _(msg`Reply was successfully hidden`) ? l`Reply was successfully hidden`
: _(msg({message: 'Reply visibility updated', context: 'toast'})), : l({message: 'Reply visibility updated', context: 'toast'}),
) )
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e instanceof MaxHiddenRepliesError) { if (e instanceof MaxHiddenRepliesError) {
Toast.show( Toast.show(
_( plural(MAX_HIDDEN_REPLIES, {
plural(MAX_HIDDEN_REPLIES, { other: 'You can hide a maximum of # replies.',
other: 'You can hide a maximum of # replies.', }),
}),
),
) )
} else if (e instanceof InvalidInteractionSettingsError) { } else if (e instanceof InvalidInteractionSettingsError) {
Toast.show( Toast.show(
_(msg({message: 'Invalid interaction settings.', context: 'toast'})), l({message: 'Invalid interaction settings.', context: 'toast'}),
) )
} else { } else {
Toast.show( Toast.show(
_( l({
msg({ message: 'Updating reply visibility failed',
message: 'Updating reply visibility failed', context: 'toast',
context: 'toast', }),
}),
),
) )
logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
} }
@@ -422,12 +423,12 @@ let PostMenuItems = ({
const onBlockAuthor = async () => { const onBlockAuthor = async () => {
try { try {
await queueBlock() await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) Toast.show(l({message: 'Account blocked', context: 'toast'}))
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e}) 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) { if (postAuthor.viewer?.muted) {
try { try {
await queueUnmute() await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) Toast.show(l({message: 'Account unmuted', context: 'toast'}))
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e}) 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 { } else {
try { try {
await queueMute() await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) Toast.show(l({message: 'Account muted', context: 'toast'}))
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e}) 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 onSignIn = () => requireSignIn(() => {})
const onPressHideTranslation = () => clearTranslation(translationKey)
const isDiscoverDebugUser = const isDiscoverDebugUser =
IS_INTERNAL || IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
@@ -481,16 +484,12 @@ let PostMenuItems = ({
<Menu.Item <Menu.Item
testID="pinPostBtn" testID="pinPostBtn"
label={ label={
isPinned isPinned ? l`Unpin from profile` : l`Pin to your profile`
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)
} }
disabled={isPinPending} disabled={isPinPending}
onPress={onPressPin}> onPress={onPressPin}>
<Menu.ItemText> <Menu.ItemText>
{isPinned {isPinned ? l`Unpin from profile` : l`Pin to your profile`}
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={isPinPending ? Loader : PinIcon} icon={isPinPending ? Loader : PinIcon}
@@ -505,28 +504,46 @@ let PostMenuItems = ({
<Menu.Group> <Menu.Group>
{!hideInPWI || hasSession ? ( {!hideInPWI || hasSession ? (
<> <>
<Menu.Item {translationState[translationKey]?.status === 'loading' ? (
testID="postDropdownTranslateBtn" <Menu.Item
label={_(msg`Translate`)} testID="postDropdownTranslateBtn"
onPress={onPressTranslate}> label={l`Translating…`}
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> onPress={() => {}}>
<Menu.ItemIcon icon={Translate} position="right" /> <Menu.ItemText>{l`Translating…`}</Menu.ItemText>
</Menu.Item> <Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
) : translationState[translationKey]?.status === 'success' ? (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Hide translation`}
onPress={onPressHideTranslation}>
<Menu.ItemText>{l`Hide translation`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
) : (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslate}>
<Menu.ItemText>{l`Translate`}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
)}
<Menu.Item <Menu.Item
testID="postDropdownCopyTextBtn" testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)} label={l`Copy post text`}
onPress={onCopyPostText}> onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> <Menu.ItemText>{l`Copy post text`}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" /> <Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item> </Menu.Item>
</> </>
) : ( ) : (
<Menu.Item <Menu.Item
testID="postDropdownSignInBtn" testID="postDropdownSignInBtn"
label={_(msg`Sign in to view post`)} label={l`Sign in to view post`}
onPress={onSignIn}> onPress={onSignIn}>
<Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> <Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText>
<Menu.ItemIcon icon={Eye} position="right" /> <Menu.ItemIcon icon={Eye} position="right" />
</Menu.Item> </Menu.Item>
)} )}
@@ -538,17 +555,17 @@ let PostMenuItems = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="postDropdownShowMoreBtn" testID="postDropdownShowMoreBtn"
label={_(msg`Show more like this`)} label={l`Show more like this`}
onPress={onPressShowMore}> onPress={onPressShowMore}>
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> <Menu.ItemText>{l`Show more like this`}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSmile} position="right" /> <Menu.ItemIcon icon={EmojiSmile} position="right" />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="postDropdownShowLessBtn" testID="postDropdownShowLessBtn"
label={_(msg`Show less like this`)} label={l`Show less like this`}
onPress={onPressShowLess}> onPress={onPressShowLess}>
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> <Menu.ItemText>{l`Show less like this`}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSad} position="right" /> <Menu.ItemIcon icon={EmojiSad} position="right" />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
@@ -560,9 +577,9 @@ let PostMenuItems = ({
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
testID="postDropdownReportMisclassificationBtn" testID="postDropdownReportMisclassificationBtn"
label={_(msg`Assign topic for algo`)} label={l`Assign topic for algo`}
onPress={onReportMisclassification}> onPress={onReportMisclassification}>
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> <Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText>
<Menu.ItemIcon icon={AtomIcon} position="right" /> <Menu.ItemIcon icon={AtomIcon} position="right" />
</Menu.Item> </Menu.Item>
</> </>
@@ -574,12 +591,10 @@ let PostMenuItems = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="postDropdownMuteThreadBtn" testID="postDropdownMuteThreadBtn"
label={ label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}
onPress={onToggleThreadMute}> onPress={onToggleThreadMute}>
<Menu.ItemText> <Menu.ItemText>
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} {isThreadMuted ? l`Unmute thread` : l`Mute thread`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={isThreadMuted ? Unmute : Mute} icon={isThreadMuted ? Unmute : Mute}
@@ -589,9 +604,9 @@ let PostMenuItems = ({
<Menu.Item <Menu.Item
testID="postDropdownMuteWordsBtn" testID="postDropdownMuteWordsBtn"
label={_(msg`Mute words & tags`)} label={l`Mute words & tags`}
onPress={() => mutedWordsDialogControl.open()}> onPress={() => mutedWordsDialogControl.open()}>
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> <Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" /> <Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
@@ -606,16 +621,10 @@ let PostMenuItems = ({
{canHidePostForMe && ( {canHidePostForMe && (
<Menu.Item <Menu.Item
testID="postDropdownHideBtn" testID="postDropdownHideBtn"
label={ label={isReply ? l`Hide reply for me` : l`Hide post for me`}
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
onPress={() => hidePromptControl.open()}> onPress={() => hidePromptControl.open()}>
<Menu.ItemText> <Menu.ItemText>
{isReply {isReply ? l`Hide reply for me` : l`Hide post for me`}
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" /> <Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item> </Menu.Item>
@@ -625,8 +634,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn" testID="postDropdownHideBtn"
label={ label={
isReplyHiddenByThreadgate isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`) ? l`Show reply for everyone`
: _(msg`Hide reply for everyone`) : l`Hide reply for everyone`
} }
onPress={ onPress={
isReplyHiddenByThreadgate isReplyHiddenByThreadgate
@@ -635,8 +644,8 @@ let PostMenuItems = ({
}> }>
<Menu.ItemText> <Menu.ItemText>
{isReplyHiddenByThreadgate {isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`) ? l`Show reply for everyone`
: _(msg`Hide reply for everyone`)} : l`Hide reply for everyone`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
@@ -651,8 +660,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn" testID="postDropdownHideBtn"
label={ label={
quoteEmbed.isDetached quoteEmbed.isDetached
? _(msg`Re-attach quote`) ? l`Re-attach quote`
: _(msg`Detach quote`) : l`Detach quote`
} }
onPress={ onPress={
quoteEmbed.isDetached quoteEmbed.isDetached
@@ -661,8 +670,8 @@ let PostMenuItems = ({
}> }>
<Menu.ItemText> <Menu.ItemText>
{quoteEmbed.isDetached {quoteEmbed.isDetached
? _(msg`Re-attach quote`) ? l`Re-attach quote`
: _(msg`Detach quote`)} : l`Detach quote`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={ icon={
@@ -690,14 +699,14 @@ let PostMenuItems = ({
testID="postDropdownMuteBtn" testID="postDropdownMuteBtn"
label={ label={
postAuthor.viewer?.muted postAuthor.viewer?.muted
? _(msg`Unmute account`) ? l`Unmute account`
: _(msg`Mute account`) : l`Mute account`
} }
onPress={() => void onMuteAuthor()}> onPress={() => void onMuteAuthor()}>
<Menu.ItemText> <Menu.ItemText>
{postAuthor.viewer?.muted {postAuthor.viewer?.muted
? _(msg`Unmute account`) ? l`Unmute account`
: _(msg`Mute account`)} : l`Mute account`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
@@ -708,18 +717,18 @@ let PostMenuItems = ({
{!postAuthor.viewer?.blocking && ( {!postAuthor.viewer?.blocking && (
<Menu.Item <Menu.Item
testID="postDropdownBlockBtn" testID="postDropdownBlockBtn"
label={_(msg`Block account`)} label={l`Block account`}
onPress={() => blockPromptControl.open()}> onPress={() => blockPromptControl.open()}>
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> <Menu.ItemText>{l`Block account`}</Menu.ItemText>
<Menu.ItemIcon icon={PersonX} position="right" /> <Menu.ItemIcon icon={PersonX} position="right" />
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item <Menu.Item
testID="postDropdownReportBtn" testID="postDropdownReportBtn"
label={_(msg`Report post`)} label={l`Report post`}
onPress={() => reportDialogControl.open()}> onPress={() => reportDialogControl.open()}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> <Menu.ItemText>{l`Report post`}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" /> <Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item> </Menu.Item>
</> </>
@@ -729,7 +738,7 @@ let PostMenuItems = ({
<> <>
<Menu.Item <Menu.Item
testID="postDropdownEditPostInteractions" testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)} label={l`Edit interaction settings`}
onPress={() => postInteractionSettingsDialogControl.open()} onPress={() => postInteractionSettingsDialogControl.open()}
{...(isAuthor {...(isAuthor
? Platform.select({ ? Platform.select({
@@ -742,15 +751,15 @@ let PostMenuItems = ({
}) })
: {})}> : {})}>
<Menu.ItemText> <Menu.ItemText>
{_(msg`Edit interaction settings`)} {l`Edit interaction settings`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" /> <Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="postDropdownDeleteBtn" testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)} label={l`Delete post`}
onPress={() => deletePromptControl.open()}> onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> <Menu.ItemText>{l`Delete post`}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item> </Menu.Item>
</> </>
@@ -759,28 +768,21 @@ let PostMenuItems = ({
</> </>
)} )}
</Menu.Outer> </Menu.Outer>
<Prompt.Basic <Prompt.Basic
control={deletePromptControl} control={deletePromptControl}
title={_(msg`Delete this post?`)} title={l`Delete this post?`}
description={_( description={l`If you remove this post, you won't be able to recover it.`}
msg`If you remove this post, you won't be able to recover it.`,
)}
onConfirm={onDeletePost} onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)} confirmButtonCta={l`Delete`}
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
<Prompt.Basic <Prompt.Basic
control={hidePromptControl} control={hidePromptControl}
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} title={isReply ? l`Hide this reply?` : l`Hide this post?`}
description={_( description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
)}
onConfirm={onHidePost} onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)} confirmButtonCta={l`Hide`}
/> />
<ReportDialog <ReportDialog
control={reportDialogControl} control={reportDialogControl}
subject={{ subject={{
@@ -788,42 +790,32 @@ let PostMenuItems = ({
$type: 'app.bsky.feed.defs#postView', $type: 'app.bsky.feed.defs#postView',
}} }}
/> />
<PostInteractionSettingsDialog <PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl} control={postInteractionSettingsDialogControl}
postUri={post.uri} postUri={post.uri}
rootPostUri={rootUri} rootPostUri={rootUri}
initialThreadgateView={post.threadgate} initialThreadgateView={post.threadgate}
/> />
<Prompt.Basic <Prompt.Basic
control={quotePostDetachConfirmControl} control={quotePostDetachConfirmControl}
title={_(msg`Detach quote post?`)} title={l`Detach quote post?`}
description={_( description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
onConfirm={() => void onToggleQuotePostAttachment()} onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)} confirmButtonCta={l`Yes, detach`}
/> />
<Prompt.Basic <Prompt.Basic
control={hideReplyConfirmControl} control={hideReplyConfirmControl}
title={_(msg`Hide this reply?`)} title={l`Hide this reply?`}
description={_( description={l`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`}
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
)}
onConfirm={() => void onToggleReplyVisibility()} onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)} confirmButtonCta={l`Yes, hide`}
/> />
<Prompt.Basic <Prompt.Basic
control={blockPromptControl} control={blockPromptControl}
title={_(msg`Block Account?`)} title={l`Block Account?`}
description={_( description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={() => void onBlockAuthor()} onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)} confirmButtonCta={l`Block`}
confirmButtonColor="negative" confirmButtonColor="negative"
/> />
</> </>
@@ -6,8 +6,7 @@ import {
type AppBskyFeedThreadgate, type AppBskyFeedThreadgate,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper' import {EventStopper} from '#/view/com/util/EventStopper'
@@ -30,6 +29,7 @@ let PostMenuButton = ({
onShowLess, onShowLess,
hitSlop, hitSlop,
logContext, logContext,
googleTranslate,
}: { }: {
testID: string testID: string
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -43,8 +43,9 @@ let PostMenuButton = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
googleTranslate: boolean
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {t: l} = useLingui()
const menuControl = useMenuControl() const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false) const [hasBeenOpen, setHasBeenOpen] = useState(false)
@@ -63,7 +64,7 @@ let PostMenuButton = ({
return ( return (
<EventStopper onKeyDown={false}> <EventStopper onKeyDown={false}>
<Menu.Root control={lazyMenuControl}> <Menu.Root control={lazyMenuControl}>
<Menu.Trigger label={_(msg`Open post options menu`)}> <Menu.Trigger label={l`Open post options menu`}>
{({props}) => { {({props}) => {
return ( return (
<PostControlButton <PostControlButton
@@ -90,6 +91,7 @@ let PostMenuButton = ({
threadgateRecord={threadgateRecord} threadgateRecord={threadgateRecord}
onShowLess={onShowLess} onShowLess={onShowLess}
logContext={logContext} logContext={logContext}
googleTranslate={googleTranslate}
/> />
)} )}
</Menu.Root> </Menu.Root>
+33 -45
View File
@@ -6,8 +6,8 @@ import {
type AppBskyFeedThreadgate, type AppBskyFeedThreadgate,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg, plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel' import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
@@ -55,6 +55,7 @@ let PostControls = ({
onShowLess, onShowLess,
viaRepost, viaRepost,
variant, variant,
googleTranslate = true,
}: { }: {
big?: boolean big?: boolean
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -70,9 +71,10 @@ let PostControls = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
viaRepost?: {uri: string; cid: string} viaRepost?: {uri: string; cid: string}
variant?: 'compact' | 'normal' | 'large' variant?: 'compact' | 'normal' | 'large'
googleTranslate?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext() const {feedDescriptor} = useFeedFeedbackContext()
const [queueLike, queueUnlike] = usePostLikeMutationQueue( const [queueLike, queueUnlike] = usePostLikeMutationQueue(
@@ -104,10 +106,7 @@ let PostControls = ({
const onPressToggleLike = async () => { const onPressToggleLike = async () => {
if (isBlocked) { if (isBlocked) {
Toast.show( Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return return
} }
@@ -135,10 +134,7 @@ let PostControls = ({
const onRepost = async () => { const onRepost = async () => {
if (isBlocked) { if (isBlocked) {
Toast.show( Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return return
} }
@@ -163,10 +159,7 @@ let PostControls = ({
const onQuote = () => { const onQuote = () => {
if (isBlocked) { if (isBlocked) {
Toast.show( Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return return
} }
@@ -238,16 +231,14 @@ let PostControls = ({
}) })
: undefined : undefined
} }
label={_( label={l({
msg({ message: `Reply (${plural(post.replyCount || 0, {
message: `Reply (${plural(post.replyCount || 0, { one: '# reply',
one: '# reply', other: '# replies',
other: '# replies', })})`,
})})`, comment:
comment: 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
'Accessibility label for the reply button, verb form followed by number of replies and noun form', })}
}),
)}
big={big}> big={big}>
<PostControlButtonIcon icon={Bubble} /> <PostControlButtonIcon icon={Bubble} />
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
@@ -274,26 +265,22 @@ let PostControls = ({
onPress={() => requireAuth(() => onPressToggleLike())} onPress={() => requireAuth(() => onPressToggleLike())}
label={ label={
post.viewer?.like post.viewer?.like
? _( ? l({
msg({ message: `Unlike (${plural(post.likeCount || 0, {
message: `Unlike (${plural(post.likeCount || 0, { one: '# like',
one: '# like', other: '# likes',
other: '# likes', })})`,
})})`, comment:
comment: 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
'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',
msg({ other: '# likes',
message: `Like (${plural(post.likeCount || 0, { })})`,
one: '# like', comment:
other: '# likes', 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
})})`, })
comment:
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
}),
)
}> }>
<AnimatedLikeIcon <AnimatedLikeIcon
isLiked={Boolean(post.viewer?.like)} isLiked={Boolean(post.viewer?.like)}
@@ -350,6 +337,7 @@ let PostControls = ({
left: secondaryControlSpacingStyles.gap / 2, left: secondaryControlSpacingStyles.gap / 2,
}} }}
logContext={logContext} logContext={logContext}
googleTranslate={googleTranslate}
/> />
</View> </View>
</View> </View>
+17
View File
@@ -0,0 +1,17 @@
import {createContext} from 'react'
import {type Options, type TranslationState} from './types'
export const Context = createContext<{
translationState: Record<string, TranslationState>
translate: (
key: string,
text: string,
targetLangCode: string,
sourceLangCode?: string,
options?: Options,
) => Promise<void>
clearTranslation: (key: string) => void
acquireTranslation: (key: string) => () => void
} | null>(null)
Context.displayName = 'TranslationContext'
@@ -1,32 +1,16 @@
import React, { import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import {LayoutAnimation, Platform} from 'react-native' import {LayoutAnimation, Platform} from 'react-native'
import {getLocales} from 'expo-localization' import {getLocales} from 'expo-localization'
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' 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 {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {Context} from './context'
import {type Options, type TranslationState} from './types'
type TranslationState =
| {status: 'idle'}
| {status: 'loading'}
| {
status: 'success'
translatedText: string
sourceLanguage: TranslationTaskResult['sourceLanguage']
targetLanguage: TranslationTaskResult['targetLanguage']
}
const IDLE: TranslationState = {status: 'idle'}
/** /**
* Attempts on-device translation via @bsky.app/expo-translate-text. * 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<void>
clearTranslation: () => void
}>({
translationState: IDLE,
translate: async () => {},
clearTranslation: () => {},
})
Context.displayName = 'TranslationContext'
/** /**
* Native translation hook. Attempts on-device translation using Apple * Native translation hook. Attempts on-device translation using Apple
* Translation (iOS 18+) or Google ML Kit (Android). * Translation (iOS 18+) or Google ML Kit (Android).
@@ -120,25 +89,108 @@ export function useTranslateOnDevice() {
return context 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<unknown>) { export function Provider({children}: React.PropsWithChildren<unknown>) {
const [translationState, setTranslationState] = const [translationState, setTranslationState] = useState<
useState<TranslationState>(IDLE) Record<string, TranslationState>
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const openLink = useOpenLink() const openLink = useOpenLink()
const ax = useAnalytics() const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs() 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) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(IDLE) setTranslationState(prev => {
delete prev[key]
return {...prev}
})
}, []) }, [])
const translate = useCallback( const translate = useCallback(
async ( async (
key: string,
text: string, text: string,
targetLangCode: string = primaryLanguage, targetLangCode: string = primaryLanguage,
sourceLangCode?: string, 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 { try {
const result = await attemptTranslation( const result = await attemptTranslation(
text, text,
@@ -152,47 +204,39 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
targetLanguage: result.targetLanguage, targetLanguage: result.targetLanguage,
}) })
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState({ setTranslationState(prev => ({
status: 'success', ...prev,
translatedText: result.translatedText, [key]: {
sourceLanguage: result.sourceLanguage, status: 'success',
targetLanguage: result.targetLanguage, translatedText: result.translatedText,
}) sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
},
}))
} catch (e) { } catch (e) {
if (IS_WEB) { logger.error('Failed to translate post on device', {safeMessage: e})
// Web always opens Google Translate. // On-device translation failed (language pack missing or user
ax.metric('translate:result', { // dismissed the download prompt). Fall back to Google Translate.
method: 'google-translate', ax.metric('translate:result', {
os: Platform.OS, method: 'fallback-alert',
sourceLanguage: sourceLangCode ?? null, os: Platform.OS,
targetLanguage: targetLangCode, sourceLanguage: sourceLangCode ?? null,
}) targetLanguage: targetLangCode,
} else { })
logger.error('Failed to translate post on device', {safeMessage: e}) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
// On-device translation failed (language pack missing or user dismissed setTranslationState(prev => ({
// the download prompt). Fall back to Google Translate. ...prev,
ax.metric('translate:result', { [key]: {status: 'idle'},
method: 'fallback-alert', }))
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
}
setTranslationState({status: 'idle'})
const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLangCode,
)
await openLink(translateUrl) await openLink(translateUrl)
} }
}, },
[ax, openLink, primaryLanguage, setTranslationState], [ax, openLink, primaryLanguage],
) )
const ctx = useMemo( const ctx = useMemo(
() => ({clearTranslation, translate, translationState}), () => ({acquireTranslation, clearTranslation, translate, translationState}),
[clearTranslation, translate, translationState], [acquireTranslation, clearTranslation, translate, translationState],
) )
return <Context.Provider value={ctx}>{children}</Context.Provider> return <Context.Provider value={ctx}>{children}</Context.Provider>
+66
View File
@@ -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<string, TranslationState> = {}
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<unknown>) {
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 <Context.Provider value={ctx}>{children}</Context.Provider>
}
+18
View File
@@ -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
}
@@ -1,5 +1,5 @@
import {memo, useCallback, useMemo} from 'react' 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 { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
@@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {
getPostLanguage,
getTranslatorLink,
isPostInLanguage,
} from '#/locale/helpers'
import { import {
POST_TOMBSTONE, POST_TOMBSTONE,
type Shadow, type Shadow,
@@ -26,7 +21,6 @@ import {
} from '#/state/cache/post-shadow' } from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadItem} from '#/state/queries/usePostThread/types' import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer' import {type OnPostSuccessData} from '#/state/shell/composer'
@@ -44,8 +38,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText, Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
import {WhoCanReply} from '#/components/WhoCanReply' import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import {
Provider as TranslateOnDeviceProvider,
useTranslateOnDevice,
} from '#/translation'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({ export function ThreadItemAnchor({
@@ -89,18 +78,16 @@ export function ThreadItemAnchor({
} }
return ( return (
<TranslateOnDeviceProvider> <ThreadItemAnchorInner
<ThreadItemAnchorInner // Safeguard from clobbering per-post state below:
// Safeguard from clobbering per-post state below: key={postShadow.uri}
key={postShadow.uri} item={item}
item={item} isRoot={isRoot}
isRoot={isRoot} postShadow={postShadow}
postShadow={postShadow} onPostSuccess={onPostSuccess}
onPostSuccess={onPostSuccess} threadgateRecord={threadgateRecord}
threadgateRecord={threadgateRecord} postSource={postSource}
postSource={postSource} />
/>
</TranslateOnDeviceProvider>
) )
} }
@@ -325,6 +312,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
} }
} }
const translationKey = post.uri
return ( return (
<> <>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} /> <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
@@ -420,8 +409,10 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true} shouldProxyLinks={true}
/> />
) : undefined} ) : undefined}
<TranslatedPost postText={record.text} hideLoading /> <TranslatedPost
<TranslateLink post={item.value.post} /> translationKey={translationKey}
postText={record.text}
/>
{post.embed && ( {post.embed && (
<View style={[a.py_xs]}> <View style={[a.py_xs]}>
<Embed <Embed
@@ -547,6 +538,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
feedContext={postSource?.post?.feedContext} feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId} reqId={postSource?.post?.reqId}
viaRepost={viaRepost} viaRepost={viaRepost}
googleTranslate={false}
/> />
</FeedFeedbackProvider> </FeedFeedbackProvider>
</View> </View>
@@ -557,97 +549,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
) )
}) })
function TranslateLink({
post,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
}) {
const t = useTheme()
const ax = useAnalytics()
const {t: l} = useLingui()
const langPrefs = useLanguagePrefs()
const {translate, clearTranslation, translationState} = useTranslateOnDevice()
const needsTranslation = useMemo(
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[post, langPrefs.primaryLanguage],
)
const sourceLanguage = getPostLanguage(post)
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
void translate(
post.record.text || '',
langPrefs.primaryLanguage,
sourceLanguage,
)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
ax.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
return false
},
[ax, sourceLanguage, translate, langPrefs, post],
)
const onHideTranslation = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
clearTranslation()
return false
},
[clearTranslation],
)
return (
needsTranslation && (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
{translationState.status === 'loading' ? (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Loader size="xs" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating…</Trans>
</Text>
</View>
) : translationState.status === 'success' ? (
<InlineLinkText
to="#"
label={l`Hide translation`}
style={[a.text_sm]}
onPress={onHideTranslation}>
<Trans>Hide translation</Trans>
</InlineLinkText>
) : (
<InlineLinkText
to={getTranslatorLink(post.record.text, langPrefs.primaryLanguage)}
label={l`Translate`}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
)}
</View>
)
)
}
function ExpandedPostDetails({ function ExpandedPostDetails({
post, post,
isThreadAuthor, isThreadAuthor,
+9
View File
@@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo' import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls} from '#/components/PostControls' import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
@@ -152,6 +153,9 @@ function PostInner({
}, [queryClient, post.author, outerOnBeforePress]) }, [queryClient, post.author, outerOnBeforePress])
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const translationKey = post.uri
return ( return (
<Link <Link
href={itemHref} href={itemHref}
@@ -215,6 +219,10 @@ function PostInner({
onPress={onPressShowMore} onPress={onPressShowMore}
/> />
)} )}
<TranslatedPost
translationKey={translationKey}
postText={record.text}
/>
</View> </View>
) : undefined} ) : undefined}
{post.embed ? ( {post.embed ? (
@@ -231,6 +239,7 @@ function PostInner({
richText={richText} richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
logContext="Post" logContext="Post"
googleTranslate={false}
/> />
</View> </View>
</View> </View>
+4 -1
View File
@@ -11,6 +11,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler' import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
import {useNotificationsRegistration} from '#/lib/notifications/notifications' import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers' import {isStateAtTabRoot} from '#/lib/routes/helpers'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import {useDialogFullyExpandedCountContext} from '#/state/dialogs' import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
@@ -240,7 +241,9 @@ export function Shell() {
<NoAccessScreen /> <NoAccessScreen />
) : ( ) : (
<RoutesContainer> <RoutesContainer>
<ShellInner /> <TranslateOnDeviceProvider>
<ShellInner />
</TranslateOnDeviceProvider>
</RoutesContainer> </RoutesContainer>
)} )}
+4 -1
View File
@@ -7,6 +7,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
@@ -175,7 +176,9 @@ export function Shell() {
<NoAccessScreen /> <NoAccessScreen />
) : ( ) : (
<RoutesContainer> <RoutesContainer>
<ShellInner /> <TranslateOnDeviceProvider>
<ShellInner />
</TranslateOnDeviceProvider>
</RoutesContainer> </RoutesContainer>
)} )}