Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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