Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}