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 ( diff --git a/src/translation/index.tsx b/src/translation/index.tsx new file mode 100644 index 0000000000..42e62ed6ed --- /dev/null +++ b/src/translation/index.tsx @@ -0,0 +1,188 @@ +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' + +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 + 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}: {children?: React.ReactNode}) { + const [translationState, setTranslationState] = + useState(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) { + 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 {children} +} diff --git a/src/translation/index.web.tsx b/src/translation/index.web.tsx new file mode 100644 index 0000000000..fbeb6a1b89 --- /dev/null +++ b/src/translation/index.web.tsx @@ -0,0 +1,43 @@ +import {useCallback} from 'react' +import {Platform} from 'react-native' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getTranslatorLink} from '#/locale/helpers' +import {useLanguagePrefs} from '#/state/preferences' +import {useAnalytics} from '#/analytics' + +const translationState = {status: 'idle'} // No on-device translations for web. + +const clearTranslation = () => {} // no-op on web + +/** + * Web always opens Google Translate. + */ +export function useTranslateOnDevice() { + const openLink = useOpenLink() + const ax = useAnalytics() + const {primaryLanguage} = useLanguagePrefs() + + const translate = useCallback( + async ( + text: string, + targetLangCode: string = primaryLanguage, + sourceLangCode: string, + ) => { + const translateUrl = getTranslatorLink( + text, + targetLangCode, + sourceLangCode, + ) + ax.metric('translate:result', { + method: 'google-translate', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + await openLink(translateUrl) + }, + [ax, openLink, primaryLanguage], + ) + return {clearTranslation, translate, translationState} +} diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index a14acd6cbf..944a917a73 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import type React from 'react' +import {HITSLOP_10} from '#/lib/constants' import {useKawaiiMode} from '#/state/preferences/kawaii' import {useSession} from '#/state/session' import {useShellLayout} from '#/state/shell/shell-layout' @@ -53,7 +54,7 @@ function HomeHeaderLayoutDesktopAndTablet({