diff --git a/package.json b/package.json
index a21ca867eb..f01c01b7af 100644
--- a/package.json
+++ b/package.json
@@ -85,6 +85,7 @@
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
+ "@bsky.app/expo-translate-text": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 8f14159c6e..efb002f40c 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -2,6 +2,8 @@
* Do not import runtime code into this file
*/
+import {type Platform} from 'react-native'
+
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -679,6 +681,17 @@ export type Events = {
targetLanguage: string
textLength: number
}
+ 'translate:result': {
+ method: 'on-device' | 'google-translate' | 'fallback-alert'
+ os: Platform['OS']
+ sourceLanguage: string | null
+ targetLanguage: string
+ }
+ 'translate:override': {
+ os: Platform['OS']
+ sourceLanguage: string
+ targetLanguage: string
+ }
'verification:create': {}
'verification:revoke': {}
diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx
new file mode 100644
index 0000000000..6016cb8eee
--- /dev/null
+++ b/src/components/Post/Translated/index.tsx
@@ -0,0 +1,167 @@
+import {useMemo} from 'react'
+import {Platform, View} from 'react-native'
+import {msg} from '@lingui/core/macro'
+import {useLingui} from '@lingui/react'
+import {Trans} from '@lingui/react/macro'
+
+import {codeToLanguageName, languageName} from '#/locale/helpers'
+import {LANGUAGES} from '#/locale/languages'
+import {useLanguagePrefs} from '#/state/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import * as Select from '#/components/Select'
+import {Text} from '#/components/Typography'
+import {useAnalytics} from '#/analytics'
+import {useTranslateOnDevice} from '#/translation'
+
+export function TranslatedPost({
+ postText,
+ hideLoading = false,
+}: {
+ postText: string
+ hideLoading: boolean
+}) {
+ const {translationState} = useTranslateOnDevice()
+
+ if (translationState.status === 'loading' && !hideLoading) {
+ return
+ }
+
+ if (translationState.status === 'success') {
+ return (
+
+ )
+ }
+
+ return null
+}
+
+function TranslationLoading() {
+ const t = useTheme()
+
+ return (
+
+
+
+ Translating…
+
+
+ )
+}
+
+function TranslationResult({
+ postText,
+ sourceLanguage,
+ translatedText,
+}: {
+ postText: string
+ sourceLanguage: string | null
+ translatedText: string
+}) {
+ const t = useTheme()
+ const {i18n} = useLingui()
+
+ const langName = sourceLanguage
+ ? codeToLanguageName(sourceLanguage, i18n.locale)
+ : undefined
+
+ return (
+
+
+ {langName ? (
+ Translated from {langName}
+ ) : (
+ Translated
+ )}
+ {sourceLanguage != null && (
+ <>
+
+ {' '}
+ ·
+ {' '}
+
+ >
+ )}
+
+
+ {translatedText}
+
+
+ )
+}
+
+function TranslationLanguageSelect({
+ postText,
+ sourceLanguage,
+}: {
+ postText: string
+ sourceLanguage: string
+}) {
+ const ax = useAnalytics()
+ const {_} = useLingui()
+ const langPrefs = useLanguagePrefs()
+ const {translate} = useTranslateOnDevice()
+
+ const items = useMemo(
+ () =>
+ LANGUAGES.filter(
+ (lang, index, self) =>
+ !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)
+ )
+ .sort(
+ (a, b) =>
+ languageName(a, langPrefs.appLanguage).localeCompare(
+ languageName(b, langPrefs.appLanguage),
+ langPrefs.appLanguage,
+ ), // Localized sort
+ )
+ .map(l => ({
+ label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
+ value: l.code2,
+ })),
+ [langPrefs],
+ )
+
+ const handleChangeTranslationLanguage = (sourceLangCode: string) => {
+ ax.metric('translate:override', {
+ os: Platform.OS,
+ sourceLanguage: sourceLangCode,
+ targetLanguage: langPrefs.primaryLanguage,
+ })
+ void translate(postText, langPrefs.primaryLanguage, sourceLangCode)
+ }
+
+ return (
+
+
+ {({props}) => {
+ return (
+
+ Edit
+
+ )
+ }}
+
+ (
+
+
+ {label}
+
+ )}
+ items={items}
+ />
+
+ )
+}
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
index 57dcaee71f..13168bd2a3 100644
--- a/src/components/PostControls/PostMenu/PostMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -219,7 +219,7 @@ let PostMenuItems = ({
const onToggleThreadMute = () => {
try {
if (isThreadMuted) {
- unmuteThread()
+ void unmuteThread()
ax.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -228,7 +228,7 @@ let PostMenuItems = ({
})
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
- muteThread()
+ void muteThread()
ax.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
@@ -239,7 +239,8 @@ let PostMenuItems = ({
_(msg`You will no longer receive notifications for this thread`),
)
}
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(
@@ -253,12 +254,12 @@ let PostMenuItems = ({
const onCopyPostText = () => {
const str = richTextToString(richText, true)
- Clipboard.setStringAsync(str)
+ void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
const onPressTranslate = () => {
- translate(record.text, langPrefs.primaryLanguage)
+ void translate(record.text, langPrefs.primaryLanguage)
if (
bsky.dangerousIsType(
@@ -343,7 +344,8 @@ let PostMenuItems = ({
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
)
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
Toast.show(
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
)
@@ -380,7 +382,8 @@ let PostMenuItems = ({
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
)
- } catch (e: any) {
+ } catch (err) {
+ const e = err as Error
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
@@ -409,7 +412,7 @@ let PostMenuItems = ({
const onPressPin = () => {
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
- pinPostMutate({
+ void pinPostMutate({
postUri,
postCid,
action: isPinned ? 'unpin' : 'pin',
@@ -420,7 +423,8 @@ let PostMenuItems = ({
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
- } catch (e: any) {
+ } 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')
@@ -433,7 +437,8 @@ let PostMenuItems = ({
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
- } catch (e: any) {
+ } 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')
@@ -443,7 +448,8 @@ let PostMenuItems = ({
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
- } catch (e: any) {
+ } 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')
@@ -456,7 +462,7 @@ let PostMenuItems = ({
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
href,
)}`
- openLink(url)
+ void openLink(url)
}
const onSignIn = () => requireSignIn(() => {})
@@ -687,7 +693,7 @@ let PostMenuItems = ({
? _(msg`Unmute account`)
: _(msg`Mute account`)
}
- onPress={onMuteAuthor}>
+ onPress={() => void onMuteAuthor()}>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
@@ -796,7 +802,7 @@ let PostMenuItems = ({
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
- onConfirm={onToggleQuotePostAttachment}
+ onConfirm={() => void onToggleQuotePostAttachment()}
confirmButtonCta={_(msg`Yes, detach`)}
/>
@@ -806,7 +812,7 @@ let PostMenuItems = ({
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.`,
)}
- onConfirm={onToggleReplyVisibility}
+ onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={_(msg`Yes, hide`)}
/>
@@ -816,7 +822,7 @@ let PostMenuItems = ({
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
- onConfirm={onBlockAuthor}
+ onConfirm={() => void onBlockAuthor()}
confirmButtonCta={_(msg`Block`)}
confirmButtonColor="negative"
/>
diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx
index b7c10ed895..0438e10fcb 100644
--- a/src/components/Select/index.tsx
+++ b/src/components/Select/index.tsx
@@ -70,7 +70,7 @@ export function Root({children, value, onValueChange, disabled}: RootProps) {
)
}
-export function Trigger({children, label}: TriggerProps) {
+export function Trigger({children, hitSlop, label}: TriggerProps) {
const {control} = useSelectContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
@@ -100,6 +100,7 @@ export function Trigger({children, label}: TriggerProps) {
} else {
return (