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 {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}