Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
144
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}