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