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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user