Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 282 lines 8.5 kB view raw
1import {useCallback, useContext, useEffect, useMemo, useState} from 'react' 2import {LayoutAnimation, Platform} from 'react-native' 3import {getLocales} from 'expo-localization' 4import {onTranslateTask} from '@bsky.app/expo-translate-text' 5import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' 6import {useLingui} from '@lingui/react/macro' 7import {useFocusEffect} from '@react-navigation/native' 8 9import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' 10import {logger} from '#/logger' 11import {useAnalytics} from '#/analytics' 12import {HAS_ON_DEVICE_TRANSLATION} from '#/env' 13import {Context} from './context' 14import {type TranslationFunctionParams, type TranslationState} from './types' 15import {guessLanguage} from './utils' 16 17export * from './types' 18export * from './utils' 19 20/** 21 * Attempts on-device translation via @bsky.app/expo-translate-text. 22 * Uses a lazy import to avoid crashing if the native module isn't linked into 23 * the current build. 24 */ 25async function attemptTranslation( 26 input: string, 27 targetLangCodeOriginal: string, 28 sourceLangCodeOriginal?: string, // Auto-detects if not provided 29): Promise<{ 30 translatedText: string 31 targetLanguage: TranslationTaskResult['targetLanguage'] 32 sourceLanguage: TranslationTaskResult['sourceLanguage'] 33}> { 34 // Note that Android only supports two-character language codes and will fail 35 // on other input. 36 // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage 37 let targetLangCode = 38 Platform.OS === 'android' 39 ? targetLangCodeOriginal.split('-')[0] 40 : targetLangCodeOriginal 41 const sourceLangCode = 42 Platform.OS === 'android' 43 ? sourceLangCodeOriginal?.split('-')[0] 44 : sourceLangCodeOriginal 45 46 // Special cases for regional languages since iOS differentiates and missing 47 // language packs must be downloaded and installed. 48 if (Platform.OS === 'ios') { 49 const deviceLocales = getLocales() 50 const primaryLanguageTag = deviceLocales[0]?.languageTag 51 switch (targetLangCodeOriginal) { 52 case 'en': // en-US, en-GB 53 case 'es': // es-419, es-ES 54 case 'pt': // pt-BR, pt-PT 55 case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW 56 if ( 57 primaryLanguageTag && 58 primaryLanguageTag.startsWith(targetLangCodeOriginal) 59 ) { 60 targetLangCode = primaryLanguageTag 61 } 62 break 63 } 64 } 65 66 const result = await onTranslateTask({ 67 input, 68 targetLangCode, 69 sourceLangCode, 70 }) 71 72 // Since `input` is always a string, the result should always be a string. 73 const translatedText = 74 typeof result.translatedTexts === 'string' ? result.translatedTexts : '' 75 76 if (translatedText === input) { 77 throw new Error('Translation result is the same as the source text.') 78 } 79 80 if (translatedText === '') { 81 throw new Error('Translation result is empty.') 82 } 83 84 return { 85 translatedText, 86 targetLanguage: result.targetLanguage, 87 sourceLanguage: 88 result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language 89 } 90} 91 92/** 93 * Native translation hook. Attempts on-device translation using Apple 94 * Translation (iOS 18+) or Google ML Kit (Android). 95 * 96 * Falls back to Google Translate URL if the language pack is unavailable. 97 * 98 * Web uses index.web.ts which always opens Google Translate. 99 */ 100export function useTranslate({ 101 key, 102 forceGoogleTranslate = false, 103}: { 104 key: string 105 forceGoogleTranslate?: boolean 106}) { 107 const context = useContext(Context) 108 if (!context) { 109 throw new Error( 110 'useTranslate must be used within a TranslateOnDeviceProvider', 111 ) 112 } 113 114 useFocusEffect( 115 useCallback(() => { 116 const cleanup = context.acquireTranslation(key) 117 return cleanup 118 }, [key, context]), 119 ) 120 121 const translate = useCallback( 122 async (params: TranslationFunctionParams) => { 123 return context.translate({...params, key, forceGoogleTranslate}) 124 }, 125 [context, forceGoogleTranslate, key], 126 ) 127 128 const clearTranslation = useCallback( 129 () => context.clearTranslation(key), 130 [context, key], 131 ) 132 133 return useMemo( 134 () => ({ 135 translationState: context.translationState[key] ?? { 136 status: 'idle', 137 }, 138 translate, 139 clearTranslation, 140 }), 141 [clearTranslation, context.translationState, key, translate], 142 ) 143} 144 145export function Provider({children}: React.PropsWithChildren<unknown>) { 146 const [translationState, setTranslationState] = useState< 147 Record<string, TranslationState> 148 >({}) 149 const [refCounts, setRefCounts] = useState<Record<string, number>>({}) 150 const ax = useAnalytics() 151 const {t: l} = useLingui() 152 const googleTranslate = useGoogleTranslate() 153 154 useEffect(() => { 155 setTranslationState(prev => { 156 const keysToDelete: string[] = [] 157 158 for (const key of Object.keys(prev)) { 159 if ((refCounts[key] ?? 0) <= 0) { 160 keysToDelete.push(key) 161 } 162 } 163 164 if (keysToDelete.length > 0) { 165 const newState = {...prev} 166 keysToDelete.forEach(key => { 167 delete newState[key] 168 }) 169 return newState 170 } 171 172 return prev 173 }) 174 }, [refCounts]) 175 176 const acquireTranslation = useCallback((key: string) => { 177 setRefCounts(prev => ({ 178 ...prev, 179 [key]: (prev[key] ?? 0) + 1, 180 })) 181 182 return () => { 183 setRefCounts(prev => { 184 const newCount = (prev[key] ?? 1) - 1 185 if (newCount <= 0) { 186 const {[key]: _, ...rest} = prev 187 return rest 188 } 189 return {...prev, [key]: newCount} 190 }) 191 } 192 }, []) 193 194 const clearTranslation = useCallback((key: string) => { 195 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 196 setTranslationState(prev => { 197 delete prev[key] 198 return {...prev} 199 }) 200 }, []) 201 202 const translate = useCallback( 203 async ({ 204 key, 205 text, 206 targetLangCode, 207 sourceLangCode, 208 ...options 209 }: { 210 key: string 211 text: string 212 targetLangCode: string 213 sourceLangCode?: string 214 forceGoogleTranslate?: boolean 215 }) => { 216 if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) { 217 ax.metric('translate:result', { 218 method: 'google-translate', 219 os: Platform.OS, 220 sourceLanguage: sourceLangCode ?? null, 221 targetLanguage: targetLangCode, 222 }) 223 await googleTranslate(text, targetLangCode, sourceLangCode) 224 return 225 } 226 227 // Translate after the next state change. 228 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 229 setTranslationState(prev => ({ 230 ...prev, 231 [key]: {status: 'loading'}, 232 })) 233 try { 234 const result = await attemptTranslation( 235 text, 236 targetLangCode, 237 sourceLangCode, 238 ) 239 ax.metric('translate:result', { 240 method: 'on-device', 241 os: Platform.OS, 242 sourceLanguage: result.sourceLanguage, 243 targetLanguage: result.targetLanguage, 244 }) 245 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 246 setTranslationState(prev => ({ 247 ...prev, 248 [key]: { 249 status: 'success', 250 translatedText: result.translatedText, 251 sourceLanguage: result.sourceLanguage, 252 targetLanguage: result.targetLanguage, 253 }, 254 })) 255 } catch (e) { 256 logger.error('Failed to translate post on device', {safeMessage: e}) 257 // On-device translation failed (language pack missing or user 258 // dismissed the download prompt). Fall back to Google Translate. 259 ax.metric('translate:result', { 260 method: 'fallback-alert', 261 os: Platform.OS, 262 sourceLanguage: sourceLangCode ?? null, 263 targetLanguage: targetLangCode, 264 }) 265 let errorMessage = l`Device failed to translate :(` 266 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 267 setTranslationState(prev => ({ 268 ...prev, 269 [key]: {status: 'error', message: errorMessage}, 270 })) 271 } 272 }, 273 [ax, googleTranslate, l], 274 ) 275 276 const ctx = useMemo( 277 () => ({acquireTranslation, clearTranslation, translate, translationState}), 278 [acquireTranslation, clearTranslation, translate, translationState], 279 ) 280 281 return <Context.Provider value={ctx}>{children}</Context.Provider> 282}