this repo has no description
1import {useCallback, useMemo} from 'react'
2import {Platform, type StyleProp, type TextStyle, View} from 'react-native'
3import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
4import {Trans, useLingui} from '@lingui/react/macro'
5
6import {HITSLOP_30} from '#/lib/constants'
7import {useTranslate} from '#/lib/translation'
8import {
9 type TranslationFunction,
10 type TranslationFunctionParams,
11} from '#/lib/translation'
12import {
13 codeToLanguageName,
14 getPostLanguageTags,
15 isPostInLanguage,
16 languageName,
17} from '#/locale/helpers'
18import {LANGUAGES} from '#/locale/languages'
19import {useLanguagePrefs} from '#/state/preferences'
20import {atoms as a, flatten, native, useTheme, web} from '#/alf'
21import {Button} from '#/components/Button'
22import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
23import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
24import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
25import {createStaticClick, Link} from '#/components/Link'
26import {Loader} from '#/components/Loader'
27import * as Select from '#/components/Select'
28import {Text} from '#/components/Typography'
29import {useAnalytics} from '#/analytics'
30import {IS_WEB} from '#/env'
31import * as bsky from '#/types/bsky'
32
33const X_ICON_OFFSET = 16
34
35export function TranslatedPost({
36 hideTranslateLink = false,
37 post,
38 postTextStyle = a.text_md,
39}: {
40 hideTranslateLink?: boolean
41 post: AppBskyFeedDefs.PostView
42 postTextStyle?: StyleProp<TextStyle>
43}) {
44 const langPrefs = useLanguagePrefs()
45 const {clearTranslation, translate, translationState} = useTranslate({
46 key: post.uri,
47 })
48
49 const record = useMemo<AppBskyFeedPost.Record | undefined>(() => {
50 return bsky.dangerousIsType<AppBskyFeedPost.Record>(
51 post.record,
52 AppBskyFeedPost.isRecord,
53 )
54 ? post.record
55 : undefined
56 }, [post])
57 const initialTranslationParams = useMemo<TranslationFunctionParams>(() => {
58 return {
59 text: record?.text || '',
60 expectedTargetLanguage: langPrefs.primaryLanguage,
61 possibleSourceLanguages: getPostLanguageTags(post),
62 }
63 }, [post, record, langPrefs])
64 const needsTranslation = useMemo(() => {
65 if (hideTranslateLink) return false
66 return !isPostInLanguage(post, [langPrefs.primaryLanguage])
67 }, [hideTranslateLink, post, langPrefs.primaryLanguage])
68
69 switch (translationState.status) {
70 case 'loading':
71 return <TranslationLoading />
72 case 'success':
73 return (
74 <TranslationResult
75 translate={translate}
76 clearTranslation={clearTranslation}
77 initialTranslationParams={initialTranslationParams}
78 postTextStyle={postTextStyle}
79 resultSourceLanguage={
80 translationState.sourceLanguage ?? null // Fallback primarily for iOS
81 }
82 translatedText={translationState.translatedText}
83 />
84 )
85 case 'error':
86 return (
87 <TranslationError
88 translate={translate}
89 clearTranslation={clearTranslation}
90 message={translationState.message}
91 initialTranslationParams={initialTranslationParams}
92 />
93 )
94 default:
95 return (
96 needsTranslation && (
97 <TranslationLink
98 translate={translate}
99 initialTranslationParams={initialTranslationParams}
100 />
101 )
102 )
103 }
104}
105
106function TranslationLoading() {
107 const t = useTheme()
108
109 return (
110 <View style={[a.gap_md, a.mt_sm, a.align_start]}>
111 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
112 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
113 <Trans>Translating</Trans>
114 </Text>
115 <Loader size="xs" fill={t.atoms.text_contrast_medium.color} />
116 </View>
117 </View>
118 )
119}
120
121function TranslationLink({
122 translate,
123 initialTranslationParams,
124}: {
125 translate: TranslationFunction
126 initialTranslationParams: TranslationFunctionParams
127}) {
128 const t = useTheme()
129 const {t: l} = useLingui()
130
131 const handleTranslate = useCallback(() => {
132 void translate(initialTranslationParams)
133 }, [initialTranslationParams, translate])
134
135 return (
136 <View
137 style={[
138 a.gap_md,
139 a.mt_sm,
140 a.align_start,
141 a.flex_row,
142 a.align_center,
143 a.gap_xs,
144 ]}>
145 <Link
146 role={IS_WEB ? 'link' : 'button'}
147 {...createStaticClick(() => {
148 handleTranslate()
149 })}
150 label={l`Translate`}
151 hoverStyle={[
152 native({opacity: 0.5}),
153 web([a.underline, {textDecorationColor: t.palette.primary_500}]),
154 ]}
155 hitSlop={HITSLOP_30}>
156 <Text style={[a.text_sm, {color: t.palette.primary_500}]}>
157 <Trans>Translate</Trans>
158 </Text>
159 </Link>
160 </View>
161 )
162}
163
164function TranslationError({
165 translate,
166 clearTranslation,
167 message,
168 initialTranslationParams,
169}: {
170 translate: TranslationFunction
171 clearTranslation: () => void
172 message: string
173 initialTranslationParams: TranslationFunctionParams
174}) {
175 const t = useTheme()
176 const {t: l} = useLingui()
177
178 const handleFallback = () => {
179 void translate({
180 ...initialTranslationParams,
181 forceGoogleTranslate: true,
182 })
183 }
184
185 return (
186 <View
187 style={[
188 a.p_md,
189 a.mt_sm,
190 a.border,
191 a.rounded_lg,
192 a.gap_xs,
193 t.atoms.border_contrast_high,
194 ]}>
195 <View
196 style={[
197 a.flex_row,
198 a.align_start,
199 a.gap_xs,
200 {
201 paddingRight: X_ICON_OFFSET,
202 },
203 ]}>
204 <WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
205 <Text
206 style={[
207 a.flex_1,
208 a.text_xs,
209 a.leading_snug,
210 t.atoms.text_contrast_high,
211 ]}>
212 {message}
213 </Text>
214
215 <Button
216 label={l`Hide translation`}
217 hitSlop={HITSLOP_30}
218 hoverStyle={native({opacity: 0.5})}
219 style={[a.absolute, a.z_10, {top: 0, right: 0}]}
220 onPress={clearTranslation}>
221 <XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
222 </Button>
223 </View>
224 <View style={[a.flex_row, a.align_center]}>
225 <Link
226 {...createStaticClick(() => {
227 handleFallback()
228 })}
229 label={l`Try Google Translate`}
230 hoverStyle={[
231 native({opacity: 0.5}),
232 web([a.underline, {textDecorationColor: t.palette.primary_500}]),
233 ]}
234 hitSlop={HITSLOP_30}>
235 <Text
236 style={[
237 a.text_xs,
238 a.font_medium,
239 a.leading_snug,
240 {color: t.palette.primary_500},
241 ]}>
242 <Trans>Try Google Translate</Trans>
243 </Text>
244 </Link>
245 </View>
246 </View>
247 )
248}
249
250function TranslationResult({
251 clearTranslation,
252 translate,
253 postTextStyle,
254 resultSourceLanguage,
255 translatedText,
256 initialTranslationParams,
257}: {
258 clearTranslation: () => void
259 translate: TranslationFunction
260 postTextStyle?: StyleProp<TextStyle>
261 resultSourceLanguage: string | null
262 translatedText: string
263 initialTranslationParams: TranslationFunctionParams
264}) {
265 const t = useTheme()
266 const langPrefs = useLanguagePrefs()
267 const {i18n, t: l} = useLingui()
268
269 const langName = resultSourceLanguage
270 ? codeToLanguageName(resultSourceLanguage, i18n.locale)
271 : undefined
272
273 const flattenedStyle = flatten(postTextStyle) ?? {}
274 const fontSize = flattenedStyle.fontSize
275
276 return (
277 <View>
278 <View
279 style={[
280 a.p_md,
281 a.mt_sm,
282 a.border,
283 a.rounded_lg,
284 a.gap_xs,
285 t.atoms.border_contrast_high,
286 ]}>
287 <View
288 style={[
289 a.flex_row,
290 a.align_center,
291 a.flex_wrap,
292 {
293 paddingRight: X_ICON_OFFSET,
294 },
295 ]}>
296 {langName ? (
297 <>
298 <Text
299 style={[
300 a.text_xs,
301 a.leading_snug,
302 t.atoms.text_contrast_medium,
303 ]}>
304 {langName}{' '}
305 </Text>
306 <ArrowRightIcon
307 size="xs"
308 fill={t.atoms.text_contrast_medium.color}
309 />
310 <Text
311 style={[
312 a.text_xs,
313 a.leading_snug,
314 t.atoms.text_contrast_medium,
315 ]}>
316 {' '}
317 {codeToLanguageName(
318 langPrefs.primaryLanguage,
319 langPrefs.appLanguage,
320 )}
321 </Text>
322 </>
323 ) : (
324 <Text
325 style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
326 <Trans>Translated</Trans>
327 </Text>
328 )}
329 {resultSourceLanguage != null && (
330 <>
331 <Text
332 style={[
333 a.text_xs,
334 a.font_medium,
335 a.leading_snug,
336 t.atoms.text_contrast_medium,
337 ]}>
338 {' '}
339 ·{' '}
340 </Text>
341 <TranslationLanguageSelect
342 resultSourceLanguage={resultSourceLanguage}
343 translate={translate}
344 initialTranslationParams={initialTranslationParams}
345 />
346 </>
347 )}
348
349 <Button
350 label={l`Hide translation`}
351 hitSlop={HITSLOP_30}
352 hoverStyle={native({opacity: 0.5})}
353 style={[a.absolute, a.z_10, {top: 0, right: 0}]}
354 onPress={clearTranslation}>
355 <XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
356 </Button>
357 </View>
358 <Text emoji selectable style={[a.leading_snug, {fontSize}]}>
359 {translatedText}
360 </Text>
361 </View>
362 </View>
363 )
364}
365
366function TranslationLanguageSelect({
367 translate,
368 resultSourceLanguage,
369 initialTranslationParams,
370}: {
371 translate: TranslationFunction
372 resultSourceLanguage: string
373 initialTranslationParams: TranslationFunctionParams
374}) {
375 const t = useTheme()
376 const ax = useAnalytics()
377 const {t: l} = useLingui()
378 const langPrefs = useLanguagePrefs()
379
380 const items = useMemo(
381 () =>
382 LANGUAGES.filter(
383 (lang, index, self) =>
384 !langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant
385 index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2)
386 )
387 .sort((a, b) => {
388 // Prioritize sourceLanguage at the top
389 if (a.code2 === resultSourceLanguage) return -1
390 if (b.code2 === resultSourceLanguage) return 1
391 // Localized sort
392 return languageName(a, langPrefs.appLanguage).localeCompare(
393 languageName(b, langPrefs.appLanguage),
394 langPrefs.appLanguage,
395 )
396 })
397 .map(l => ({
398 label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
399 value: l.code2,
400 })),
401 [langPrefs, resultSourceLanguage],
402 )
403
404 const handleChangeTranslationLanguage = (sourceLangCode: string) => {
405 ax.metric('translate:override', {
406 os: Platform.OS,
407 possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
408 expectedSourceLanguage: sourceLangCode,
409 expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
410 resultSourceLanguage,
411 })
412 void translate({
413 text: initialTranslationParams.text,
414 expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
415 expectedSourceLanguage: sourceLangCode,
416 possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
417 })
418 }
419
420 return (
421 <Select.Root
422 value={resultSourceLanguage}
423 onValueChange={handleChangeTranslationLanguage}>
424 <Select.Trigger label={l`Change the source language`}>
425 {({props}) => {
426 return (
427 <Button
428 label={props.accessibilityLabel}
429 {...props}
430 hitSlop={HITSLOP_30}
431 hoverStyle={native({opacity: 0.5})}>
432 <Text
433 style={[
434 a.text_xs,
435 a.font_medium,
436 a.leading_snug,
437 t.atoms.text_contrast_high,
438 ]}>
439 <Trans>Change</Trans>
440 </Text>
441 </Button>
442 )
443 }}
444 </Select.Trigger>
445 <Select.Content
446 label={l`Select the source language`}
447 renderItem={({label, value}) => (
448 <Select.Item value={value} label={label}>
449 <Select.ItemIndicator />
450 <Select.ItemText>{label}</Select.ItemText>
451 </Select.Item>
452 )}
453 items={items}
454 />
455 </Select.Root>
456 )
457}