this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 457 lines 13 kB view raw
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 &middot;{' '} 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}