Bluesky app fork with some witchin' additions 💫
at main 415 lines 12 kB view raw
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 &middot;{' '} 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}