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,5 +1,5 @@
import {memo, useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
import {Text as RNText, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
@@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {
getPostLanguage,
getTranslatorLink,
isPostInLanguage,
} from '#/locale/helpers'
import {
POST_TOMBSTONE,
type Shadow,
@@ -26,7 +21,6 @@ import {
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
@@ -44,8 +38,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe
import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import {
Provider as TranslateOnDeviceProvider,
useTranslateOnDevice,
} from '#/translation'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
@@ -89,18 +78,16 @@ export function ThreadItemAnchor({
}
return (
<TranslateOnDeviceProvider>
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
</TranslateOnDeviceProvider>
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
)
}
@@ -420,8 +407,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost postText={record.text} hideLoading />
<TranslateLink post={item.value.post} />
<TranslatedPost post={post} postText={record.text} />
{post.embed && (
<View style={[a.py_xs]}>
<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({
post,
isThreadAuthor,
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
@@ -320,6 +321,11 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
)}
</View>
) : undefined}
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
{post.embed && (
<View style={[a.pb_xs]}>
<Embed
@@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
@@ -360,6 +361,11 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
)}
</View>
) : null}
<TranslatedPost
hideTranslateLink={true}
post={post}
postText={record.text}
/>
{post.embed && (
<View style={[a.pb_xs]}>
<Embed
+30 -38
View File
@@ -33,9 +33,7 @@ import {
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {
type RouteProp,
useFocusEffect,
@@ -451,7 +449,7 @@ function Feed() {
}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
void fetchNextPage()
}
}}
showsVerticalScrollIndicator={false}
@@ -515,6 +513,7 @@ let VideoItem = ({
}
}
}, [
ax,
active,
post.uri,
post.author.did,
@@ -621,7 +620,7 @@ function ModerationOverlay({
embed: AppBskyEmbedVideo.View
onPressShow: () => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const hider = Hider.useHider()
const {bottom} = useSafeAreaInsets()
@@ -648,7 +647,7 @@ function ModerationOverlay({
<Trans>Hidden by your moderation settings.</Trans>
</Text>
<Button
label={_(msg`Show anyway`)}
label={l`Show anyway`}
size="small"
variant="solid"
color="secondary_inverted"
@@ -676,7 +675,7 @@ function ModerationOverlay({
<Divider style={{borderColor: 'white'}} />
<View>
<Button
label={_(msg`View details`)}
label={l`View details`}
onPress={() => {
hider.showInfoDialog()
}}
@@ -724,7 +723,7 @@ function Overlay({
feedContext: string | undefined
reqId: string | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
@@ -811,11 +810,9 @@ function Overlay({
<Animated.View style={[a.px_md, animatedStyle]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}>
<Link
label={_(
msg`View ${sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}'s profile`,
)}
label={l`View ${sanitizeDisplayName(
post.author.displayName || post.author.handle,
)}'s profile`}
to={{
screen: 'Profile',
params: {name: post.author.did},
@@ -848,13 +845,11 @@ function Overlay({
<Button
label={
profile.viewer?.following
? _(msg`Following ${handle}`)
: _(msg`Follow ${handle}`)
? l`Following ${handle}`
: l`Follow ${handle}`
}
accessibilityHint={
profile.viewer?.following
? _(msg`Unfollows the user`)
: ''
profile.viewer?.following ? l`Unfollows the user` : ''
}
size="small"
variant="solid"
@@ -862,8 +857,8 @@ function Overlay({
style={[a.mb_xs]}
onPress={() =>
profile.viewer?.following
? queueUnfollow()
: queueFollow()
? void queueUnfollow()
: void queueFollow()
}>
{!!profile.viewer?.following && (
<ButtonIcon icon={CheckIcon} />
@@ -892,6 +887,7 @@ function Overlay({
record={record}
feedContext={feedContext}
logContext="FeedItem"
forceGoogleTranslate={true}
onPressReply={() =>
navigation.navigate('PostThread', {
name: post.author.did,
@@ -947,7 +943,7 @@ function ExpandableRichTextView({
const [hasBeenExpanded, setHasBeenExpanded] = useState(false)
const [constrained, setConstrained] = useState(false)
const [contentHeight, setContentHeight] = useState(0)
const {_} = useLingui()
const {t: l} = useLingui()
const {screenReaderEnabled} = useA11y()
if (expanded && !hasBeenExpanded) {
@@ -988,8 +984,8 @@ function ExpandableRichTextView({
/>
{constrained && !screenReaderEnabled && (
<Pressable
accessibilityHint={_(msg`Expands or collapses post text`)}
accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)}
accessibilityHint={l`Expands or collapses post text`}
accessibilityLabel={expanded ? l`Read less` : l`Read more`}
hitSlop={HITSLOP_20}
onPress={() => setExpanded(prev => !prev)}
style={[a.absolute, a.inset_0]}
@@ -1049,7 +1045,7 @@ function PlayPauseTapArea({
feedContext: string | undefined
reqId: string | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const playHaptic = useHaptics()
// TODO: implement viaRepost -sfn
@@ -1092,7 +1088,7 @@ function PlayPauseTapArea({
clearTimeout(doubleTapRef.current)
doubleTapRef.current = null
playHaptic('Light')
queueLike()
void queueLike()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
@@ -1107,16 +1103,12 @@ function PlayPauseTapArea({
return (
<Button
disabled={!player}
aria-valuetext={
isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`)
}
label={_(
msg`Video from ${sanitizeHandle(
post.author.handle,
'@',
)}. Tap to play or pause the video`,
)}
accessibilityHint={_(msg`Double tap to like`)}
aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`}
label={l`Video from ${sanitizeHandle(
post.author.handle,
'@',
)}. Tap to play or pause the video`}
accessibilityHint={l`Double tap to like`}
onPress={onPress}
style={[a.absolute, a.inset_0, a.z_10]}>
<View />
@@ -1126,7 +1118,7 @@ function PlayPauseTapArea({
function EndMessage() {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
return (
<View
@@ -1177,8 +1169,8 @@ function EndMessage() {
variant="solid"
color="secondary_inverted"
size="small"
label={_(msg`Go back`)}
accessibilityHint={_(msg`Returns to previous page`)}>
label={l`Go back`}
accessibilityHint={l`Returns to previous page`}>
<ButtonIcon icon={ArrowLeftIcon} />
<ButtonText>
<Trans>Go back</Trans>