Refine UX for on-device translation for posts (#9987)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
DS Boyce
2026-03-04 18:37:44 -08:00
committed by GitHub
parent 1782a65174
commit afbade6f6f
25 changed files with 1014 additions and 1639 deletions
+1 -1
View File
@@ -85,7 +85,7 @@
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7", "@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.4", "@bsky.app/expo-translate-text": "^0.2.7",
"@bsky.app/react-native-mmkv": "2.12.5", "@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
+6 -3
View File
@@ -19,6 +19,7 @@ import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottom
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y' import {Provider as A11yProvider} from '#/state/a11y'
@@ -179,9 +180,11 @@ function InnerApp() {
style={s.h100pct}> style={s.h100pct}>
<GlobalGestureEventsProvider> <GlobalGestureEventsProvider>
<IntentDialogProvider> <IntentDialogProvider>
<TestCtrls /> <TranslateOnDeviceProvider>
<Shell /> <TestCtrls />
<ToastOutlet /> <Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider> </IntentDialogProvider>
</GlobalGestureEventsProvider> </GlobalGestureEventsProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
+5 -2
View File
@@ -10,6 +10,7 @@ import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y' import {Provider as A11yProvider} from '#/state/a11y'
@@ -154,8 +155,10 @@ function InnerApp() {
<EmailVerificationProvider> <EmailVerificationProvider>
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
<IntentDialogProvider> <IntentDialogProvider>
<Shell /> <TranslateOnDeviceProvider>
<ToastOutlet /> <Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider> </IntentDialogProvider>
</HideBottomBarBorderProvider> </HideBottomBarBorderProvider>
</EmailVerificationProvider> </EmailVerificationProvider>
+314 -68
View File
@@ -1,113 +1,345 @@
import {useMemo} from 'react' import {useCallback, useMemo} from 'react'
import {Platform, View} from 'react-native' import {Platform, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {type AppBskyFeedDefs} from '@atproto/api'
import {useLingui} from '@lingui/react' import {Trans, useLingui} from '@lingui/react/macro'
import {Trans} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {guessLanguage, useTranslate} from '#/lib/translation'
import {type TranslationFunction} 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, web} from '#/alf'
import {Button} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {createStaticClick, Link} from '#/components/Link'
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' import {IS_WEB} from '#/env'
export function TranslatedPost({ export function TranslatedPost({
hideTranslateLink = false,
post,
postText, postText,
hideLoading = false,
}: { }: {
hideTranslateLink?: boolean
post: AppBskyFeedDefs.PostView
postText: string postText: string
hideLoading: boolean
}) { }) {
const {translationState} = useTranslateOnDevice() const langPrefs = useLanguagePrefs()
const {clearTranslation, translate, translationState} = useTranslate({
key: post.uri,
})
if (translationState.status === 'loading' && !hideLoading) { const postLanguage = useMemo(() => guessLanguage(postText), [postText])
return <TranslationLoading /> const needsTranslation = postLanguage !== langPrefs.primaryLanguage
switch (translationState.status) {
case 'loading':
return <TranslationLoading />
case 'success':
return (
<TranslationResult
clearTranslation={clearTranslation}
translate={translate}
postText={postText}
sourceLanguage={
translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS
}
translatedText={translationState.translatedText}
/>
)
case 'error':
return (
<TranslationError
clearTranslation={clearTranslation}
message={translationState.message}
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
/>
)
default:
return (
!hideTranslateLink &&
needsTranslation && (
<TranslationLink
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
sourceLanguage={postLanguage}
translate={translate}
/>
)
)
} }
if (translationState.status === 'success') {
return (
<TranslationResult
postText={postText}
sourceLanguage={translationState.sourceLanguage}
translatedText={translationState.translatedText}
/>
)
}
return null
} }
function TranslationLoading() { 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>
)
}
function TranslationLink({
postText,
primaryLanguage,
sourceLanguage,
translate,
}: {
postText: string
primaryLanguage: string
sourceLanguage: string | null
translate: TranslationFunction
}) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const handleTranslate = useCallback(() => {
void translate({
text: postText,
targetLangCode: primaryLanguage,
})
ax.metric('translate', {
sourceLanguages: sourceLanguage ? [sourceLanguage] : [],
targetLanguage: primaryLanguage,
textLength: postText.length,
})
}, [ax, postText, primaryLanguage, translate, sourceLanguage])
return (
<View
style={[
a.gap_md,
a.pt_md,
a.align_start,
a.flex_row,
a.align_center,
a.gap_xs,
]}>
<Link
role={IS_WEB ? 'link' : 'button'}
{...createStaticClick(() => {
handleTranslate()
})}
label={l`Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
]}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Trans>Translate</Trans>
</Text>
</Link>
</View>
)
}
function TranslationError({
clearTranslation,
message,
postText,
primaryLanguage,
}: {
clearTranslation: () => void
message: string
postText: string
primaryLanguage: string
}) {
const t = useTheme()
const {t: l} = useLingui()
const translate = useGoogleTranslate()
const handleFallback = () => {
void translate(postText, primaryLanguage)
}
return (
<View
style={[
a.px_lg,
a.pt_sm,
a.pb_md,
a.mt_sm,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}>
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
{message}
</Text>
</View>
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
<Button
label={l`Hide translation`}
hitSlop={HITSLOP_30}
hoverStyle={{opacity: 0.5}}
onPress={clearTranslation}>
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
</Button>
</View>
</View>
<View style={[a.flex_row, a.align_center]}>
<Link
{...createStaticClick(() => {
handleFallback()
})}
label={l`Try Google Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
]}
hitSlop={HITSLOP_30}>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}>
<Trans>Try Google Translate</Trans>
</Text>
</Link>
</View>
</View> </View>
) )
} }
function TranslationResult({ function TranslationResult({
clearTranslation,
translate,
postText, postText,
sourceLanguage, sourceLanguage,
translatedText, translatedText,
}: { }: {
clearTranslation: () => void
translate: TranslationFunction
postText: string postText: string
sourceLanguage: string | null sourceLanguage: string | null
translatedText: string translatedText: string
}) { }) {
const t = useTheme() const t = useTheme()
const {i18n} = useLingui() const langPrefs = useLanguagePrefs()
const {i18n, t: l} = useLingui()
const langName = sourceLanguage const langName = sourceLanguage
? codeToLanguageName(sourceLanguage, i18n.locale) ? codeToLanguageName(sourceLanguage, i18n.locale)
: undefined : undefined
return ( return (
<View style={[a.py_xs, a.gap_xs, a.mt_sm]}> <View>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}> <View
{langName ? ( style={[
<Trans>Translated from {langName}</Trans> a.px_lg,
) : ( a.pt_sm,
<Trans>Translated</Trans> a.pb_md,
)} a.mt_sm,
{sourceLanguage != null && ( a.border,
<> a.rounded_lg,
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> t.atoms.border_contrast_high,
{' '} ]}>
&middot; <View style={[a.flex_row, a.align_center, a.mb_xs]}>
</Text>{' '} {langName ? (
<TranslationLanguageSelect <View style={[a.flex_row, a.align_center]}>
sourceLanguage={sourceLanguage} <Text
postText={postText} style={[
/> a.text_xs,
</> a.font_medium,
)} t.atoms.text_contrast_medium,
</Text> ]}>
<Text emoji selectable style={[a.text_md, a.leading_snug]}> {langName}{' '}
{translatedText} </Text>
</Text> <View style={[a.mt_2xs]}>
<ArrowRightIcon
size="xs"
fill={t.atoms.text_contrast_medium.color}
/>
</View>
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
]}>
{' '}
{codeToLanguageName(
langPrefs.primaryLanguage,
langPrefs.appLanguage,
)}
</Text>
</View>
) : (
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_xs,
]}>
<Trans>Translated</Trans>
</Text>
)}
{sourceLanguage != null && (
<>
<Text
style={[
a.text_xs,
a.font_medium,
t.atoms.text_contrast_medium,
]}>
{' '}
&middot;{' '}
</Text>
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
translate={translate}
postText={postText}
/>
</>
)}
</View>
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
{translatedText}
</Text>
<Button
label={l`Hide translation`}
hitSlop={HITSLOP_30}
hoverStyle={native({opacity: 0.5})}
style={[a.absolute, a.z_10, {top: 12, right: 14}]}
onPress={clearTranslation}>
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
</Button>
</View>
</View> </View>
) )
} }
function TranslationLanguageSelect({ function TranslationLanguageSelect({
translate,
postText, postText,
sourceLanguage, sourceLanguage,
}: { }: {
translate: TranslationFunction
postText: string postText: string
sourceLanguage: string sourceLanguage: string
}) { }) {
const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {translate} = useTranslateOnDevice()
const items = useMemo( const items = useMemo(
() => () =>
@@ -116,18 +348,21 @@ function TranslationLanguageSelect({
!langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant !langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2) index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
) )
.sort( .sort((a, b) => {
(a, b) => // Prioritize sourceLanguage at the top
languageName(a, langPrefs.appLanguage).localeCompare( if (a.code2 === sourceLanguage) return -1
languageName(b, langPrefs.appLanguage), if (b.code2 === sourceLanguage) return 1
langPrefs.appLanguage, // Localized sort
), // Localized sort return languageName(a, langPrefs.appLanguage).localeCompare(
) languageName(b, langPrefs.appLanguage),
langPrefs.appLanguage,
)
})
.map(l => ({ .map(l => ({
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
value: l.code2, value: l.code2,
})), })),
[langPrefs], [langPrefs, sourceLanguage],
) )
const handleChangeTranslationLanguage = (sourceLangCode: string) => { const handleChangeTranslationLanguage = (sourceLangCode: string) => {
@@ -136,24 +371,35 @@ function TranslationLanguageSelect({
sourceLanguage: sourceLangCode, sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage, targetLanguage: langPrefs.primaryLanguage,
}) })
void translate(postText, langPrefs.primaryLanguage, sourceLangCode) void translate({
text: postText,
targetLangCode: 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, a.font_medium, t.atoms.text_contrast_high]}>
<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 {useTranslate} 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,
forceGoogleTranslate,
}: { }: {
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'
forceGoogleTranslate: 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,10 @@ 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} = useTranslate({
key: post.uri,
forceGoogleTranslate,
})
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl() const blockPromptControl = useDialogControl()
@@ -191,7 +196,7 @@ let PostMenuItems = ({
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 +216,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 +231,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 +240,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 +255,14 @@ 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({
text: record.text,
targetLangCode: langPrefs.primaryLanguage,
})
if ( if (
bsky.dangerousIsType<AppBskyFeedPost.Record>( bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -297,9 +300,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 +323,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 +342,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 +380,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 +419,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 +433,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 +464,8 @@ let PostMenuItems = ({
const onSignIn = () => requireSignIn(() => {}) const onSignIn = () => requireSignIn(() => {})
const onPressHideTranslation = () => clearTranslation()
const isDiscoverDebugUser = const isDiscoverDebugUser =
IS_INTERNAL || IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
@@ -481,16 +480,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 +500,46 @@ let PostMenuItems = ({
<Menu.Group> <Menu.Group>
{!hideInPWI || hasSession ? ( {!hideInPWI || hasSession ? (
<> <>
<Menu.Item {translationState.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.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 +551,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 +573,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 +587,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 +600,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 +617,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 +630,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 +640,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 +656,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 +666,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 +695,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 +713,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 +734,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 +747,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 +764,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 +786,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,
forceGoogleTranslate,
}: { }: {
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'
forceGoogleTranslate: 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}
forceGoogleTranslate={forceGoogleTranslate}
/> />
)} )}
</Menu.Root> </Menu.Root>
+38 -48
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,
forceGoogleTranslate = false,
}: { }: {
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'
forceGoogleTranslate?: 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
} }
@@ -126,7 +125,8 @@ let PostControls = ({
} else { } else {
await queueUnlike() await queueUnlike()
} }
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
throw e throw e
} }
@@ -135,10 +135,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
} }
@@ -154,7 +151,8 @@ let PostControls = ({
} else { } else {
await queueUnrepost() await queueUnrepost()
} }
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
throw e throw e
} }
@@ -163,10 +161,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 +233,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 && (
@@ -261,7 +254,7 @@ let PostControls = ({
<RepostButton <RepostButton
isReposted={!!post.viewer?.repost} isReposted={!!post.viewer?.repost}
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
onRepost={onRepost} onRepost={() => void onRepost()}
onQuote={onQuote} onQuote={onQuote}
big={big} big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
@@ -274,26 +267,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 +339,7 @@ let PostControls = ({
left: secondaryControlSpacingStyles.gap / 2, left: secondaryControlSpacingStyles.gap / 2,
}} }}
logContext={logContext} logContext={logContext}
forceGoogleTranslate={forceGoogleTranslate}
/> />
</View> </View>
</View> </View>
+4 -4
View File
@@ -6,7 +6,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useTranslate} from '#/lib/hooks/useTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
@@ -44,7 +44,7 @@ export let MessageContextMenu = ({
const reportControl = usePromptControl() const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl() const blockOrDeleteControl = usePromptControl()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useTranslate() const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
@@ -57,12 +57,12 @@ export let MessageContextMenu = ({
true, true,
) )
Clipboard.setStringAsync(str) void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets]) }, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => { const onPressTranslateMessage = useCallback(() => {
translate(message.text, langPrefs.primaryLanguage) void translate(message.text, langPrefs.primaryLanguage)
ax.metric('translate', { ax.metric('translate', {
sourceLanguages: [], sourceLanguages: [],
+3
View File
@@ -49,3 +49,6 @@ export const IS_WEB_FIREFOX: boolean = false
export const IS_HIGH_DPI: boolean = true export const IS_HIGH_DPI: boolean = true
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26 export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
// So we can avoid attempting on-device translation when we know it's unsupported.
export const HAS_ON_DEVICE_TRANSLATION: boolean =
(IS_IOS && iOSMajorVersion >= 18) || IS_ANDROID
+1
View File
@@ -48,3 +48,4 @@ export const IS_HIGH_DPI: boolean = window.matchMedia(
'(min-resolution: 2dppx)', '(min-resolution: 2dppx)',
).matches ).matches
export const IS_LIQUID_GLASS: boolean = false export const IS_LIQUID_GLASS: boolean = false
export const HAS_ON_DEVICE_TRANSLATION: boolean = false
@@ -6,10 +6,9 @@ import {getTranslatorLink} from '#/locale/helpers'
import {IS_ANDROID} from '#/env' import {IS_ANDROID} from '#/env'
/** /**
* Will always link out to Google Translate. If inline translation is desired, * @deprecated Will always link out to Google Translate. Prefer `useTranslate`.
* use `useTranslateOnDevice`
*/ */
export function useTranslate() { export function useGoogleTranslate() {
const openLink = useOpenLink() const openLink = useOpenLink()
return useCallback( return useCallback(
+16
View File
@@ -0,0 +1,16 @@
import {createContext} from 'react'
import {type TranslationFunctionParams, type TranslationState} from './types'
export const Context = createContext<{
translationState: Record<string, TranslationState>
translate: (
parameters: TranslationFunctionParams & {
key: string
forceGoogleTranslate: boolean
},
) => Promise<void>
clearTranslation: (key: string) => void
acquireTranslation: (key: string) => () => void
} | null>(null)
Context.displayName = 'TranslationContext'
+282
View File
@@ -0,0 +1,282 @@
import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {onTranslateTask} from '@bsky.app/expo-translate-text'
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
import {useLingui} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {logger} from '#/logger'
import {useAnalytics} from '#/analytics'
import {HAS_ON_DEVICE_TRANSLATION} from '#/env'
import {Context} from './context'
import {type TranslationFunctionParams, type TranslationState} from './types'
import {guessLanguage} from './utils'
export * from './types'
export * from './utils'
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
* Uses a lazy import to avoid crashing if the native module isn't linked into
* the current build.
*/
async function attemptTranslation(
input: string,
targetLangCodeOriginal: string,
sourceLangCodeOriginal?: string, // Auto-detects if not provided
): Promise<{
translatedText: string
targetLanguage: TranslationTaskResult['targetLanguage']
sourceLanguage: TranslationTaskResult['sourceLanguage']
}> {
// Note that Android only supports two-character language codes and will fail
// on other input.
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
let targetLangCode =
Platform.OS === 'android'
? targetLangCodeOriginal.split('-')[0]
: targetLangCodeOriginal
const sourceLangCode =
Platform.OS === 'android'
? sourceLangCodeOriginal?.split('-')[0]
: sourceLangCodeOriginal
// Special cases for regional languages since iOS differentiates and missing
// language packs must be downloaded and installed.
if (Platform.OS === 'ios') {
const deviceLocales = getLocales()
const primaryLanguageTag = deviceLocales[0]?.languageTag
switch (targetLangCodeOriginal) {
case 'en': // en-US, en-GB
case 'es': // es-419, es-ES
case 'pt': // pt-BR, pt-PT
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
if (
primaryLanguageTag &&
primaryLanguageTag.startsWith(targetLangCodeOriginal)
) {
targetLangCode = primaryLanguageTag
}
break
}
}
const result = await onTranslateTask({
input,
targetLangCode,
sourceLangCode,
})
// Since `input` is always a string, the result should always be a string.
const translatedText =
typeof result.translatedTexts === 'string' ? result.translatedTexts : ''
if (translatedText === input) {
throw new Error('Translation result is the same as the source text.')
}
if (translatedText === '') {
throw new Error('Translation result is empty.')
}
return {
translatedText,
targetLanguage: result.targetLanguage,
sourceLanguage:
result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language
}
}
/**
* Native translation hook. Attempts on-device translation using Apple
* Translation (iOS 18+) or Google ML Kit (Android).
*
* Falls back to Google Translate URL if the language pack is unavailable.
*
* Web uses index.web.ts which always opens Google Translate.
*/
export function useTranslate({
key,
forceGoogleTranslate = false,
}: {
key: string
forceGoogleTranslate?: boolean
}) {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslate must be used within a TranslateOnDeviceProvider',
)
}
useFocusEffect(
useCallback(() => {
const cleanup = context.acquireTranslation(key)
return cleanup
}, [key, context]),
)
const translate = useCallback(
async (params: TranslationFunctionParams) => {
return context.translate({...params, key, forceGoogleTranslate})
},
[context, forceGoogleTranslate, key],
)
const clearTranslation = useCallback(
() => context.clearTranslation(key),
[context, key],
)
return useMemo(
() => ({
translationState: context.translationState[key] ?? {
status: 'idle',
},
translate,
clearTranslation,
}),
[clearTranslation, context.translationState, key, translate],
)
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const [translationState, setTranslationState] = useState<
Record<string, TranslationState>
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const ax = useAnalytics()
const {t: l} = useLingui()
const googleTranslate = useGoogleTranslate()
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(prev => {
delete prev[key]
return {...prev}
})
}, [])
const translate = useCallback(
async ({
key,
text,
targetLangCode,
sourceLangCode,
...options
}: {
key: string
text: string
targetLangCode: string
sourceLangCode?: string
forceGoogleTranslate?: boolean
}) => {
if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) {
ax.metric('translate:result', {
method: 'google-translate',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await googleTranslate(text, targetLangCode, sourceLangCode)
return
}
// Translate after the next state change.
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {status: 'loading'},
}))
try {
const result = await attemptTranslation(
text,
targetLangCode,
sourceLangCode,
)
ax.metric('translate:result', {
method: 'on-device',
os: Platform.OS,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {
status: 'success',
translatedText: result.translatedText,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
},
}))
} catch (e) {
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,
})
let errorMessage = l`Device failed to translate :(`
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {status: 'error', message: errorMessage},
}))
}
},
[ax, googleTranslate, l],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[acquireTranslation, clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+86
View File
@@ -0,0 +1,86 @@
import {useCallback, useContext, useMemo} from 'react'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {useAnalytics} from '#/analytics'
import {Context} from './context'
import {type TranslationFunctionParams, type TranslationState} from './types'
export * from './types'
export * from './utils'
const translationState: Record<string, TranslationState> = {}
const acquireTranslation = (_key: string) => {
return () => {}
}
const clearTranslation = (_key: string) => {}
/**
* Web always opens Google Translate.
*/
export function useTranslate({
key,
}: {
key: string
forceGoogleTranslate?: boolean
}) {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslate must be used within a TranslateOnDeviceProvider',
)
}
// Always call hooks in consistent order
const translate = useCallback(
async (params: TranslationFunctionParams) => {
return context.translate({...params, key, forceGoogleTranslate: true})
},
[key, context],
)
const clearTranslation = useCallback(() => {
return context.clearTranslation(key)
}, [key, context])
return {
translationState: context.translationState[key] ?? {
status: 'idle' as const,
},
translate,
clearTranslation,
}
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const ax = useAnalytics()
const googleTranslate = useGoogleTranslate()
const translate = useCallback(
async ({
text,
targetLangCode,
sourceLangCode,
}: {
key: string
text: string
targetLangCode: string
sourceLangCode?: string
}) => {
ax.metric('translate:result', {
method: 'google-translate',
os: 'web',
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await googleTranslate(text, targetLangCode, sourceLangCode)
},
[ax, googleTranslate],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[translate],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+34
View File
@@ -0,0 +1,34 @@
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']
}
| {
status: 'error'
message: string
}
export type TranslationFunctionParams = {
/**
* The text to be translated.
*/
text: string
/**
* The language to translate the text into.
*/
targetLangCode: string
/**
* The source language of the text. Will auto-detect if not provided.
*/
sourceLangCode?: string
}
export type TranslationFunction = (
parameters: TranslationFunctionParams,
) => Promise<void>
+13
View File
@@ -0,0 +1,13 @@
import lande from 'lande'
import {code3ToCode2Strict} from '#/locale/helpers'
// TODO: Replace with expo-guess-language
export function guessLanguage(text: string): string | null {
const results = lande(text)
// only return high-confidence results
if (results[0] && results[0][1] > 0.97) {
return code3ToCode2Strict(results[0][0]) ?? null
}
return null
}
File diff suppressed because it is too large Load Diff
@@ -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>
) )
} }
@@ -420,8 +407,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true} shouldProxyLinks={true}
/> />
) : undefined} ) : undefined}
<TranslatedPost postText={record.text} hideLoading /> <TranslatedPost post={post} postText={record.text} />
<TranslateLink post={item.value.post} />
{post.embed && ( {post.embed && (
<View style={[a.py_xs]}> <View style={[a.py_xs]}>
<Embed <Embed
@@ -557,97 +543,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,
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills' import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
@@ -320,6 +321,11 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
)} )}
</View> </View>
) : undefined} ) : undefined}
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
{post.embed && ( {post.embed && (
<View style={[a.pb_xs]}> <View style={[a.pb_xs]}>
<Embed <Embed
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills' import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
@@ -360,6 +361,11 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
)} )}
</View> </View>
) : null} ) : null}
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
{post.embed && ( {post.embed && (
<View style={[a.pb_xs]}> <View style={[a.pb_xs]}>
<Embed <Embed
+30 -38
View File
@@ -33,9 +33,7 @@ import {
type ModerationDecision, type ModerationDecision,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
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 { import {
type RouteProp, type RouteProp,
useFocusEffect, useFocusEffect,
@@ -451,7 +449,7 @@ function Feed() {
} }
onEndReached={() => { onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) { if (hasNextPage && !isFetchingNextPage) {
fetchNextPage() void fetchNextPage()
} }
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -515,6 +513,7 @@ let VideoItem = ({
} }
} }
}, [ }, [
ax,
active, active,
post.uri, post.uri,
post.author.did, post.author.did,
@@ -621,7 +620,7 @@ function ModerationOverlay({
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
onPressShow: () => void onPressShow: () => void
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const hider = Hider.useHider() const hider = Hider.useHider()
const {bottom} = useSafeAreaInsets() const {bottom} = useSafeAreaInsets()
@@ -648,7 +647,7 @@ function ModerationOverlay({
<Trans>Hidden by your moderation settings.</Trans> <Trans>Hidden by your moderation settings.</Trans>
</Text> </Text>
<Button <Button
label={_(msg`Show anyway`)} label={l`Show anyway`}
size="small" size="small"
variant="solid" variant="solid"
color="secondary_inverted" color="secondary_inverted"
@@ -676,7 +675,7 @@ function ModerationOverlay({
<Divider style={{borderColor: 'white'}} /> <Divider style={{borderColor: 'white'}} />
<View> <View>
<Button <Button
label={_(msg`View details`)} label={l`View details`}
onPress={() => { onPress={() => {
hider.showInfoDialog() hider.showInfoDialog()
}} }}
@@ -724,7 +723,7 @@ function Overlay({
feedContext: string | undefined feedContext: string | undefined
reqId: string | undefined reqId: string | undefined
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -811,11 +810,9 @@ function Overlay({
<Animated.View style={[a.px_md, animatedStyle]}> <Animated.View style={[a.px_md, animatedStyle]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}> <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
<Link <Link
label={_( label={l`View ${sanitizeDisplayName(
msg`View ${sanitizeDisplayName( post.author.displayName || post.author.handle,
post.author.displayName || post.author.handle, )}'s profile`}
)}'s profile`,
)}
to={{ to={{
screen: 'Profile', screen: 'Profile',
params: {name: post.author.did}, params: {name: post.author.did},
@@ -848,13 +845,11 @@ function Overlay({
<Button <Button
label={ label={
profile.viewer?.following profile.viewer?.following
? _(msg`Following ${handle}`) ? l`Following ${handle}`
: _(msg`Follow ${handle}`) : l`Follow ${handle}`
} }
accessibilityHint={ accessibilityHint={
profile.viewer?.following profile.viewer?.following ? l`Unfollows the user` : ''
? _(msg`Unfollows the user`)
: ''
} }
size="small" size="small"
variant="solid" variant="solid"
@@ -862,8 +857,8 @@ function Overlay({
style={[a.mb_xs]} style={[a.mb_xs]}
onPress={() => onPress={() =>
profile.viewer?.following profile.viewer?.following
? queueUnfollow() ? void queueUnfollow()
: queueFollow() : void queueFollow()
}> }>
{!!profile.viewer?.following && ( {!!profile.viewer?.following && (
<ButtonIcon icon={CheckIcon} /> <ButtonIcon icon={CheckIcon} />
@@ -892,6 +887,7 @@ function Overlay({
record={record} record={record}
feedContext={feedContext} feedContext={feedContext}
logContext="FeedItem" logContext="FeedItem"
forceGoogleTranslate={true}
onPressReply={() => onPressReply={() =>
navigation.navigate('PostThread', { navigation.navigate('PostThread', {
name: post.author.did, name: post.author.did,
@@ -947,7 +943,7 @@ function ExpandableRichTextView({
const [hasBeenExpanded, setHasBeenExpanded] = useState(false) const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
const [constrained, setConstrained] = useState(false) const [constrained, setConstrained] = useState(false)
const [contentHeight, setContentHeight] = useState(0) const [contentHeight, setContentHeight] = useState(0)
const {_} = useLingui() const {t: l} = useLingui()
const {screenReaderEnabled} = useA11y() const {screenReaderEnabled} = useA11y()
if (expanded && !hasBeenExpanded) { if (expanded && !hasBeenExpanded) {
@@ -988,8 +984,8 @@ function ExpandableRichTextView({
/> />
{constrained && !screenReaderEnabled && ( {constrained && !screenReaderEnabled && (
<Pressable <Pressable
accessibilityHint={_(msg`Expands or collapses post text`)} accessibilityHint={l`Expands or collapses post text`}
accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)} accessibilityLabel={expanded ? l`Read less` : l`Read more`}
hitSlop={HITSLOP_20} hitSlop={HITSLOP_20}
onPress={() => setExpanded(prev => !prev)} onPress={() => setExpanded(prev => !prev)}
style={[a.absolute, a.inset_0]} style={[a.absolute, a.inset_0]}
@@ -1049,7 +1045,7 @@ function PlayPauseTapArea({
feedContext: string | undefined feedContext: string | undefined
reqId: string | undefined reqId: string | undefined
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null) const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const playHaptic = useHaptics() const playHaptic = useHaptics()
// TODO: implement viaRepost -sfn // TODO: implement viaRepost -sfn
@@ -1092,7 +1088,7 @@ function PlayPauseTapArea({
clearTimeout(doubleTapRef.current) clearTimeout(doubleTapRef.current)
doubleTapRef.current = null doubleTapRef.current = null
playHaptic('Light') playHaptic('Light')
queueLike() void queueLike()
sendInteraction({ sendInteraction({
item: post.uri, item: post.uri,
event: 'app.bsky.feed.defs#interactionLike', event: 'app.bsky.feed.defs#interactionLike',
@@ -1107,16 +1103,12 @@ function PlayPauseTapArea({
return ( return (
<Button <Button
disabled={!player} disabled={!player}
aria-valuetext={ aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`) label={l`Video from ${sanitizeHandle(
} post.author.handle,
label={_( '@',
msg`Video from ${sanitizeHandle( )}. Tap to play or pause the video`}
post.author.handle, accessibilityHint={l`Double tap to like`}
'@',
)}. Tap to play or pause the video`,
)}
accessibilityHint={_(msg`Double tap to like`)}
onPress={onPress} onPress={onPress}
style={[a.absolute, a.inset_0, a.z_10]}> style={[a.absolute, a.inset_0, a.z_10]}>
<View /> <View />
@@ -1126,7 +1118,7 @@ function PlayPauseTapArea({
function EndMessage() { function EndMessage() {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
return ( return (
<View <View
@@ -1177,8 +1169,8 @@ function EndMessage() {
variant="solid" variant="solid"
color="secondary_inverted" color="secondary_inverted"
size="small" size="small"
label={_(msg`Go back`)} label={l`Go back`}
accessibilityHint={_(msg`Returns to previous page`)}> accessibilityHint={l`Returns to previous page`}>
<ButtonIcon icon={ArrowLeftIcon} /> <ButtonIcon icon={ArrowLeftIcon} />
<ButtonText> <ButtonText>
<Trans>Go back</Trans> <Trans>Go back</Trans>
-199
View File
@@ -1,199 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
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 {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'}
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
* Uses a lazy import to avoid crashing if the native module isn't linked into
* the current build.
*/
async function attemptTranslation(
input: string,
targetLangCodeOriginal: string,
sourceLangCodeOriginal?: string, // Auto-detects if not provided
): Promise<{
translatedText: string
targetLanguage: TranslationTaskResult['targetLanguage']
sourceLanguage: TranslationTaskResult['sourceLanguage']
}> {
// Note that Android only supports two-character language codes and will fail
// on other input.
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
let targetLangCode =
Platform.OS === 'android'
? targetLangCodeOriginal.split('-')[0]
: targetLangCodeOriginal
const sourceLangCode =
Platform.OS === 'android'
? sourceLangCodeOriginal?.split('-')[0]
: sourceLangCodeOriginal
// Special cases for regional languages
if (Platform.OS !== 'android') {
const deviceLocales = getLocales()
const primaryLanguageTag = deviceLocales[0]?.languageTag
switch (targetLangCodeOriginal) {
case 'en': // en-US, en-GB
case 'es': // es-419, es-ES
case 'pt': // pt-BR, pt-PT
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal
break
}
}
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,
sourceLangCode,
})
// Since `input` is always a string, the result should always be a string.
return {
translatedText:
typeof result.translatedTexts === 'string' ? result.translatedTexts : '',
targetLanguage: result.targetLanguage,
sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // iOS doesn't return the source language
}
}
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
* Translation (iOS 18+) or Google ML Kit (Android).
*
* Falls back to Google Translate URL if the language pack is unavailable.
*
* Web uses index.web.ts which 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 [translationState, setTranslationState] =
useState<TranslationState>(IDLE)
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
const clearTranslation = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(IDLE)
}, [])
const translate = useCallback(
async (
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode?: string,
) => {
setTranslationState({status: 'loading'})
try {
const result = await attemptTranslation(
text,
targetLangCode,
sourceLangCode,
)
ax.metric('translate:result', {
method: 'on-device',
os: Platform.OS,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState({
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,
)
await openLink(translateUrl)
}
},
[ax, openLink, primaryLanguage, setTranslationState],
)
const ctx = useMemo(
() => ({clearTranslation, translate, translationState}),
[clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+7
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,7 @@ function PostInner({
}, [queryClient, post.author, outerOnBeforePress]) }, [queryClient, post.author, outerOnBeforePress])
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
return ( return (
<Link <Link
href={itemHref} href={itemHref}
@@ -217,6 +219,11 @@ function PostInner({
)} )}
</View> </View>
) : undefined} ) : undefined}
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
{post.embed ? ( {post.embed ? (
<Embed <Embed
embed={post.embed} embed={post.embed}
+16
View File
@@ -42,6 +42,7 @@ import {Embed} from '#/components/Post/Embed'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
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 {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
@@ -450,6 +451,14 @@ let PostContent = ({
: [] : []
}, [post, currentAccount?.did, threadgateHiddenReplies]) }, [post, currentAccount?.did, threadgateHiddenReplies])
const record = useMemo<AppBskyFeedPost.Record | undefined>(
() =>
bsky.validate(post.record, AppBskyFeedPost.validateRecord)
? post.record
: undefined,
[post],
)
const onPressShowMore = useCallback(() => { const onPressShowMore = useCallback(() => {
setLimitLines(false) setLimitLines(false)
}, [setLimitLines]) }, [setLimitLines])
@@ -481,6 +490,13 @@ let PostContent = ({
)} )}
</View> </View>
) : undefined} ) : undefined}
{record && (
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
)}
{postEmbed ? ( {postEmbed ? (
<View style={[a.pb_xs]}> <View style={[a.pb_xs]}>
<Embed <Embed
+4 -4
View File
@@ -3791,10 +3791,10 @@
resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31"
integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg== integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg==
"@bsky.app/expo-translate-text@^0.2.4": "@bsky.app/expo-translate-text@^0.2.7":
version "0.2.4" version "0.2.7"
resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.4.tgz#6e7f20f286111ee4d550c0c84f57393fc215a675" resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.7.tgz#e34811d0f0300f8808762e5676aa50790ca5d5e8"
integrity sha512-7mvFggNfkJEufI5A3WnjfjdN3H9P6Dpx7CpDkA9npWqA8Cb2icXq3k3nz3MaXGrVKTYiJnytAffYtos7mPoeOg== integrity sha512-J9zctP9hLxX0eustTKk5CBnCkk6cEdlu1s7GzUnpT65qkCSNbYqbbUCpcU2Z2S2dN/1+w6L/iHb+vmCEbZMOaQ==
"@bsky.app/react-native-mmkv@2.12.5": "@bsky.app/react-native-mmkv@2.12.5":
version "2.12.5" version "2.12.5"