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