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
@@ -13,13 +13,12 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {
@@ -28,6 +27,7 @@ import {
} from '#/lib/routes/types'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTranslate} from '#/lib/translation'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -106,6 +106,7 @@ let PostMenuItems = ({
threadgateRecord,
onShowLess,
logContext,
forceGoogleTranslate,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -120,9 +121,10 @@ let PostMenuItems = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
forceGoogleTranslate: boolean
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
@@ -133,7 +135,10 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const translate = useTranslate()
const {clearTranslation, translate, translationState} = useTranslate({
key: post.uri,
forceGoogleTranslate,
})
const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
@@ -191,7 +196,7 @@ let PostMenuItems = ({
const onDeletePost = () => {
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())
if (route.name === 'PostThread') {
@@ -211,7 +216,7 @@ let PostMenuItems = ({
},
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,
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 {
void muteThread()
ax.metric('post:mute', {
@@ -235,18 +240,13 @@ let PostMenuItems = ({
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
Toast.show(l`You will no longer receive notifications for this thread`)
}
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(
_(msg`Failed to toggle thread mute, please try again`),
'xmark',
)
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
}
}
}
@@ -255,11 +255,14 @@ let PostMenuItems = ({
const str = richTextToString(richText, true)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
Toast.show(l`Copied to clipboard`, 'clipboard-check')
}
const onPressTranslate = () => {
void translate(record.text, langPrefs.primaryLanguage)
void translate({
text: record.text,
targetLangCode: langPrefs.primaryLanguage,
})
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -297,9 +300,7 @@ let PostMenuItems = ({
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
)
Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'}))
}
const onPressShowLess = () => {
@@ -322,7 +323,7 @@ let PostMenuItems = ({
})
} else {
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(
isDetach
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
? l`Quote post was successfully detached`
: l`Quote post was re-attached`,
)
} catch (err) {
const e = err as Error
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})
}
@@ -379,31 +380,27 @@ let PostMenuItems = ({
Toast.show(
isHide
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
? l`Reply was successfully hidden`
: l({message: 'Reply visibility updated', context: 'toast'}),
)
} catch (err) {
const e = err as Error
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
),
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
)
} else if (e instanceof InvalidInteractionSettingsError) {
Toast.show(
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
l({message: 'Invalid interaction settings.', context: 'toast'}),
)
} else {
Toast.show(
_(
msg({
message: 'Updating reply visibility failed',
context: 'toast',
}),
),
l({
message: 'Updating reply visibility failed',
context: 'toast',
}),
)
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
}
@@ -422,12 +419,12 @@ let PostMenuItems = ({
const onBlockAuthor = async () => {
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
Toast.show(l({message: 'Account blocked', context: 'toast'}))
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
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) {
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
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 {
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
Toast.show(l({message: 'Account muted', context: 'toast'}))
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
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 onPressHideTranslation = () => clearTranslation()
const isDiscoverDebugUser =
IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
@@ -481,16 +480,12 @@ let PostMenuItems = ({
<Menu.Item
testID="pinPostBtn"
label={
isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)
isPinned ? l`Unpin from profile` : l`Pin to your profile`
}
disabled={isPinPending}
onPress={onPressPin}>
<Menu.ItemText>
{isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)}
{isPinned ? l`Unpin from profile` : l`Pin to your profile`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isPinPending ? Loader : PinIcon}
@@ -505,28 +500,46 @@ let PostMenuItems = ({
<Menu.Group>
{!hideInPWI || hasSession ? (
<>
<Menu.Item
testID="postDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
{translationState.status === 'loading' ? (
<Menu.Item
testID="postDropdownTranslateBtn"
label={l`Translating…`}
onPress={() => {}}>
<Menu.ItemText>{l`Translating…`}</Menu.ItemText>
<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
testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)}
label={l`Copy post text`}
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.Item>
</>
) : (
<Menu.Item
testID="postDropdownSignInBtn"
label={_(msg`Sign in to view post`)}
label={l`Sign in to view post`}
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.Item>
)}
@@ -538,17 +551,17 @@ let PostMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownShowMoreBtn"
label={_(msg`Show more like this`)}
label={l`Show more like this`}
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.Item>
<Menu.Item
testID="postDropdownShowLessBtn"
label={_(msg`Show less like this`)}
label={l`Show less like this`}
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.Item>
</Menu.Group>
@@ -560,9 +573,9 @@ let PostMenuItems = ({
<Menu.Divider />
<Menu.Item
testID="postDropdownReportMisclassificationBtn"
label={_(msg`Assign topic for algo`)}
label={l`Assign topic for algo`}
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.Item>
</>
@@ -574,12 +587,10 @@ let PostMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownMuteThreadBtn"
label={
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}
label={isThreadMuted ? l`Unmute thread` : l`Mute thread`}
onPress={onToggleThreadMute}>
<Menu.ItemText>
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
{isThreadMuted ? l`Unmute thread` : l`Mute thread`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isThreadMuted ? Unmute : Mute}
@@ -589,9 +600,9 @@ let PostMenuItems = ({
<Menu.Item
testID="postDropdownMuteWordsBtn"
label={_(msg`Mute words & tags`)}
label={l`Mute words & tags`}
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.Item>
</Menu.Group>
@@ -606,16 +617,10 @@ let PostMenuItems = ({
{canHidePostForMe && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
label={isReply ? l`Hide reply for me` : l`Hide post for me`}
onPress={() => hidePromptControl.open()}>
<Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
{isReply ? l`Hide reply for me` : l`Hide post for me`}
</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
@@ -625,8 +630,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn"
label={
isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)
? l`Show reply for everyone`
: l`Hide reply for everyone`
}
onPress={
isReplyHiddenByThreadgate
@@ -635,8 +640,8 @@ let PostMenuItems = ({
}>
<Menu.ItemText>
{isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)}
? l`Show reply for everyone`
: l`Hide reply for everyone`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
@@ -651,8 +656,8 @@ let PostMenuItems = ({
testID="postDropdownHideBtn"
label={
quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)
? l`Re-attach quote`
: l`Detach quote`
}
onPress={
quoteEmbed.isDetached
@@ -661,8 +666,8 @@ let PostMenuItems = ({
}>
<Menu.ItemText>
{quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)}
? l`Re-attach quote`
: l`Detach quote`}
</Menu.ItemText>
<Menu.ItemIcon
icon={
@@ -690,14 +695,14 @@ let PostMenuItems = ({
testID="postDropdownMuteBtn"
label={
postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)
? l`Unmute account`
: l`Mute account`
}
onPress={() => void onMuteAuthor()}>
<Menu.ItemText>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)}
? l`Unmute account`
: l`Mute account`}
</Menu.ItemText>
<Menu.ItemIcon
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
@@ -708,18 +713,18 @@ let PostMenuItems = ({
{!postAuthor.viewer?.blocking && (
<Menu.Item
testID="postDropdownBlockBtn"
label={_(msg`Block account`)}
label={l`Block account`}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
<Menu.ItemText>{l`Block account`}</Menu.ItemText>
<Menu.ItemIcon icon={PersonX} position="right" />
</Menu.Item>
)}
<Menu.Item
testID="postDropdownReportBtn"
label={_(msg`Report post`)}
label={l`Report post`}
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
<Menu.ItemText>{l`Report post`}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
</>
@@ -729,7 +734,7 @@ let PostMenuItems = ({
<>
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
label={l`Edit interaction settings`}
onPress={() => postInteractionSettingsDialogControl.open()}
{...(isAuthor
? Platform.select({
@@ -742,15 +747,15 @@ let PostMenuItems = ({
})
: {})}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
{l`Edit interaction settings`}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
label={l`Delete post`}
onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemText>{l`Delete post`}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
@@ -759,28 +764,21 @@ let PostMenuItems = ({
</>
)}
</Menu.Outer>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
title={l`Delete this post?`}
description={l`If you remove this post, you won't be able to recover it.`}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
description={_(
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
)}
title={isReply ? l`Hide this reply?` : l`Hide this post?`}
description={l`This post will be hidden from feeds and threads. This cannot be undone.`}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
confirmButtonCta={l`Hide`}
/>
<ReportDialog
control={reportDialogControl}
subject={{
@@ -788,42 +786,32 @@ let PostMenuItems = ({
$type: 'app.bsky.feed.defs#postView',
}}
/>
<PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl}
postUri={post.uri}
rootPostUri={rootUri}
initialThreadgateView={post.threadgate}
/>
<Prompt.Basic
control={quotePostDetachConfirmControl}
title={_(msg`Detach quote post?`)}
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
title={l`Detach quote post?`}
description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`}
onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)}
confirmButtonCta={l`Yes, detach`}
/>
<Prompt.Basic
control={hideReplyConfirmControl}
title={_(msg`Hide this reply?`)}
description={_(
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.`,
)}
title={l`Hide this reply?`}
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.`}
onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)}
confirmButtonCta={l`Yes, hide`}
/>
<Prompt.Basic
control={blockPromptControl}
title={_(msg`Block Account?`)}
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
title={l`Block Account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
</>
@@ -6,8 +6,7 @@ import {
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
@@ -30,6 +29,7 @@ let PostMenuButton = ({
onShowLess,
hitSlop,
logContext,
forceGoogleTranslate,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -43,8 +43,9 @@ let PostMenuButton = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
forceGoogleTranslate: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {t: l} = useLingui()
const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false)
@@ -63,7 +64,7 @@ let PostMenuButton = ({
return (
<EventStopper onKeyDown={false}>
<Menu.Root control={lazyMenuControl}>
<Menu.Trigger label={_(msg`Open post options menu`)}>
<Menu.Trigger label={l`Open post options menu`}>
{({props}) => {
return (
<PostControlButton
@@ -90,6 +91,7 @@ let PostMenuButton = ({
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
logContext={logContext}
forceGoogleTranslate={forceGoogleTranslate}
/>
)}
</Menu.Root>