Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 575 lines 19 kB view raw
1import React from 'react' 2import {View} from 'react-native' 3import {AtUri} from '@atproto/api' 4import {msg, Plural, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {useHaptics} from '#/lib/haptics' 8import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links' 9import {shareUrl} from '#/lib/sharing' 10import {sanitizeHandle} from '#/lib/strings/handles' 11import {toShareUrl} from '#/lib/strings/url-helpers' 12import {logger} from '#/logger' 13import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 14import {type FeedSourceFeedInfo} from '#/state/queries/feed' 15import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 16import { 17 useAddSavedFeedsMutation, 18 usePreferencesQuery, 19 useRemoveFeedMutation, 20 useUpdateSavedFeedsMutation, 21} from '#/state/queries/preferences' 22import {useSession} from '#/state/session' 23import {formatCount} from '#/view/com/util/numeric/format' 24import * as Toast from '#/view/com/util/Toast' 25import {UserAvatar} from '#/view/com/util/UserAvatar' 26import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 27import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28import * as Dialog from '#/components/Dialog' 29import {Divider} from '#/components/Divider' 30import {useRichText} from '#/components/hooks/useRichText' 31import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 32import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 33import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 34import { 35 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 36 Heart2_Stroke2_Corner0_Rounded as Heart, 37} from '#/components/icons/Heart2' 38import { 39 Pin_Filled_Corner0_Rounded as PinFilled, 40 Pin_Stroke2_Corner0_Rounded as Pin, 41} from '#/components/icons/Pin' 42import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 43import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 44import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 45import * as Layout from '#/components/Layout' 46import {InlineLinkText} from '#/components/Link' 47import * as Menu from '#/components/Menu' 48import { 49 ReportDialog, 50 useReportDialogControl, 51} from '#/components/moderation/ReportDialog' 52import {RichText} from '#/components/RichText' 53import {Text} from '#/components/Typography' 54import {useAnalytics} from '#/analytics' 55import {IS_WEB} from '#/env' 56 57export function ProfileFeedHeaderSkeleton() { 58 const t = useTheme() 59 const enableSquareButtons = useEnableSquareButtons() 60 61 return ( 62 <Layout.Header.Outer> 63 <Layout.Header.BackButton /> 64 <Layout.Header.Content> 65 <View 66 style={[a.w_full, a.rounded_sm, t.atoms.bg_contrast_25, {height: 40}]} 67 /> 68 </Layout.Header.Content> 69 <Layout.Header.Slot> 70 <View 71 style={[ 72 a.justify_center, 73 a.align_center, 74 enableSquareButtons ? a.rounded_sm : a.rounded_full, 75 t.atoms.bg_contrast_25, 76 { 77 height: 34, 78 width: 34, 79 }, 80 ]}> 81 <Pin size="lg" fill={t.atoms.text_contrast_low.color} /> 82 </View> 83 </Layout.Header.Slot> 84 </Layout.Header.Outer> 85 ) 86} 87 88export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { 89 const t = useTheme() 90 const {_, i18n} = useLingui() 91 const ax = useAnalytics() 92 const {hasSession} = useSession() 93 const {gtMobile} = useBreakpoints() 94 const infoControl = Dialog.useDialogControl() 95 const playHaptic = useHaptics() 96 97 const {data: preferences} = usePreferencesQuery() 98 99 const [likeUri, setLikeUri] = React.useState(info.likeUri || '') 100 const likeCount = 101 (info.likeCount || 0) + 102 (likeUri && !info.likeUri ? 1 : !likeUri && info.likeUri ? -1 : 0) 103 104 const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 105 useAddSavedFeedsMutation() 106 const {mutateAsync: removeFeed, isPending: isRemovePending} = 107 useRemoveFeedMutation() 108 const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = 109 useUpdateSavedFeedsMutation() 110 111 const isFeedStateChangePending = 112 isAddSavedFeedPending || isRemovePending || isUpdateFeedPending 113 const savedFeedConfig = preferences?.savedFeeds?.find( 114 f => f.value === info.uri, 115 ) 116 const isSaved = Boolean(savedFeedConfig) 117 const isPinned = Boolean(savedFeedConfig?.pinned) 118 119 const onToggleSaved = async () => { 120 try { 121 playHaptic() 122 123 if (savedFeedConfig) { 124 await removeFeed(savedFeedConfig) 125 Toast.show(_(msg`Removed from your feeds`)) 126 ax.metric('feed:unsave', {feedUrl: info.uri}) 127 } else { 128 await addSavedFeeds([ 129 { 130 type: 'feed', 131 value: info.uri, 132 pinned: false, 133 }, 134 ]) 135 Toast.show(_(msg`Saved to your feeds`)) 136 ax.metric('feed:save', {feedUrl: info.uri}) 137 } 138 } catch (err) { 139 Toast.show( 140 _( 141 msg`There was an issue updating your feeds, please check your internet connection and try again.`, 142 ), 143 'xmark', 144 ) 145 logger.error('Failed to update feeds', {message: err}) 146 } 147 } 148 149 const onTogglePinned = async () => { 150 try { 151 playHaptic() 152 153 if (savedFeedConfig) { 154 const pinned = !savedFeedConfig.pinned 155 await updateSavedFeeds([ 156 { 157 ...savedFeedConfig, 158 pinned, 159 }, 160 ]) 161 162 if (pinned) { 163 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 164 ax.metric('feed:pin', {feedUrl: info.uri}) 165 } else { 166 Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) 167 ax.metric('feed:unpin', {feedUrl: info.uri}) 168 } 169 } else { 170 await addSavedFeeds([ 171 { 172 type: 'feed', 173 value: info.uri, 174 pinned: true, 175 }, 176 ]) 177 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 178 ax.metric('feed:pin', {feedUrl: info.uri}) 179 } 180 } catch (e) { 181 Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 182 logger.error('Failed to toggle pinned feed', {message: e}) 183 } 184 } 185 186 return ( 187 <> 188 <Layout.Center 189 style={[t.atoms.bg, a.z_10, web([a.sticky, a.z_10, {top: 0}])]}> 190 <Layout.Header.Outer> 191 <Layout.Header.BackButton /> 192 <Layout.Header.Content align="left"> 193 <Button 194 label={_(msg`Open feed info screen`)} 195 style={[ 196 a.justify_start, 197 { 198 paddingVertical: IS_WEB ? 2 : 4, 199 paddingRight: 8, 200 }, 201 ]} 202 onPress={() => { 203 playHaptic() 204 infoControl.open() 205 }}> 206 {({hovered, pressed}) => ( 207 <> 208 <View 209 style={[ 210 a.absolute, 211 a.inset_0, 212 a.rounded_sm, 213 a.transition_all, 214 t.atoms.bg_contrast_25, 215 { 216 opacity: 0, 217 left: IS_WEB ? -2 : -4, 218 right: 0, 219 }, 220 pressed && { 221 opacity: 1, 222 }, 223 hovered && { 224 opacity: 1, 225 transform: [{scaleX: 1.01}, {scaleY: 1.1}], 226 }, 227 ]} 228 /> 229 230 <View 231 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 232 {info.avatar && ( 233 <UserAvatar size={36} type="algo" avatar={info.avatar} /> 234 )} 235 236 <View style={[a.flex_1]}> 237 <Text 238 style={[ 239 a.text_md, 240 a.font_bold, 241 a.leading_snug, 242 gtMobile && a.text_lg, 243 ]} 244 numberOfLines={2} 245 emoji> 246 {info.displayName} 247 </Text> 248 <View style={[a.flex_row, {gap: 6}]}> 249 <Text 250 style={[ 251 a.flex_shrink, 252 a.text_sm, 253 a.leading_snug, 254 t.atoms.text_contrast_medium, 255 ]} 256 numberOfLines={1}> 257 {sanitizeHandle(info.creatorHandle, '@')} 258 </Text> 259 <View style={[a.flex_row, a.align_center, {gap: 2}]}> 260 <HeartFilled 261 size="xs" 262 fill={ 263 likeUri 264 ? t.palette.like 265 : t.atoms.text_contrast_low.color 266 } 267 /> 268 <Text 269 style={[ 270 a.text_sm, 271 a.leading_snug, 272 t.atoms.text_contrast_medium, 273 ]} 274 numberOfLines={1}> 275 {formatCount(i18n, likeCount)} 276 </Text> 277 </View> 278 </View> 279 </View> 280 281 <Ellipsis 282 size="md" 283 fill={t.atoms.text_contrast_low.color} 284 /> 285 </View> 286 </> 287 )} 288 </Button> 289 </Layout.Header.Content> 290 291 {hasSession && ( 292 <Layout.Header.Slot> 293 {isPinned ? ( 294 <Menu.Root> 295 <Menu.Trigger label={_(msg`Open feed options menu`)}> 296 {({props}) => { 297 return ( 298 <Button 299 {...props} 300 label={_(msg`Open feed options menu`)} 301 size="small" 302 variant="ghost" 303 shape="square" 304 color="secondary"> 305 <PinFilled size="lg" fill={t.palette.primary_500} /> 306 </Button> 307 ) 308 }} 309 </Menu.Trigger> 310 311 <Menu.Outer> 312 <Menu.Item 313 disabled={isFeedStateChangePending} 314 label={_(msg`Unpin from home`)} 315 onPress={onTogglePinned}> 316 <Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText> 317 <Menu.ItemIcon icon={X} position="right" /> 318 </Menu.Item> 319 <Menu.Item 320 disabled={isFeedStateChangePending} 321 label={ 322 isSaved 323 ? _(msg`Remove from my feeds`) 324 : _(msg`Save to my feeds`) 325 } 326 onPress={onToggleSaved}> 327 <Menu.ItemText> 328 {isSaved 329 ? _(msg`Remove from my feeds`) 330 : _(msg`Save to my feeds`)} 331 </Menu.ItemText> 332 <Menu.ItemIcon 333 icon={isSaved ? Trash : Plus} 334 position="right" 335 /> 336 </Menu.Item> 337 </Menu.Outer> 338 </Menu.Root> 339 ) : ( 340 <Button 341 label={_(msg`Pin to Home`)} 342 size="small" 343 variant="ghost" 344 shape="square" 345 color="secondary" 346 onPress={onTogglePinned}> 347 <ButtonIcon icon={Pin} size="lg" /> 348 </Button> 349 )} 350 </Layout.Header.Slot> 351 )} 352 </Layout.Header.Outer> 353 </Layout.Center> 354 355 <Dialog.Outer control={infoControl}> 356 <Dialog.Handle /> 357 <Dialog.ScrollableInner 358 label={_(msg`Feed menu`)} 359 style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}> 360 <DialogInner 361 info={info} 362 likeUri={likeUri} 363 setLikeUri={setLikeUri} 364 likeCount={likeCount} 365 isPinned={isPinned} 366 onTogglePinned={onTogglePinned} 367 isFeedStateChangePending={isFeedStateChangePending} 368 /> 369 </Dialog.ScrollableInner> 370 </Dialog.Outer> 371 </> 372 ) 373} 374 375function DialogInner({ 376 info, 377 likeUri, 378 setLikeUri, 379 likeCount, 380 isPinned, 381 onTogglePinned, 382 isFeedStateChangePending, 383}: { 384 info: FeedSourceFeedInfo 385 likeUri: string 386 setLikeUri: (uri: string) => void 387 likeCount: number 388 isPinned: boolean 389 onTogglePinned: () => void 390 isFeedStateChangePending: boolean 391}) { 392 const t = useTheme() 393 const {_} = useLingui() 394 const ax = useAnalytics() 395 const {hasSession} = useSession() 396 const playHaptic = useHaptics() 397 const control = Dialog.useDialogContext() 398 const reportDialogControl = useReportDialogControl() 399 const [rt] = useRichText(info.description.text) 400 const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() 401 const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = 402 useUnlikeMutation() 403 404 const isLiked = !!likeUri 405 const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri]) 406 407 const enableSquareButtons = useEnableSquareButtons() 408 409 const onToggleLiked = async () => { 410 try { 411 playHaptic() 412 413 if (isLiked && likeUri) { 414 await unlikeFeed({uri: likeUri}) 415 setLikeUri('') 416 ax.metric('feed:unlike', {feedUrl: info.uri}) 417 } else { 418 const res = await likeFeed({uri: info.uri, cid: info.cid}) 419 setLikeUri(res.uri) 420 ax.metric('feed:like', {feedUrl: info.uri}) 421 } 422 } catch (err) { 423 Toast.show( 424 _( 425 msg`There was an issue contacting the server, please check your internet connection and try again.`, 426 ), 427 'xmark', 428 ) 429 logger.error('Failed to toggle like', {message: err}) 430 } 431 } 432 433 const onPressShare = React.useCallback(() => { 434 playHaptic() 435 const url = toShareUrl(info.route.href) 436 shareUrl(url) 437 ax.metric('feed:share', {feedUrl: info.uri}) 438 }, [info, playHaptic]) 439 440 const onPressReport = React.useCallback(() => { 441 reportDialogControl.open() 442 }, [reportDialogControl]) 443 444 return ( 445 <View style={[a.gap_md]}> 446 <View style={[a.flex_row, a.align_center, a.gap_md]}> 447 <UserAvatar type="algo" size={48} avatar={info.avatar} /> 448 449 <View style={[a.flex_1, a.gap_2xs]}> 450 <Text 451 style={[a.text_2xl, a.font_bold, a.leading_tight]} 452 numberOfLines={2} 453 emoji> 454 {info.displayName} 455 </Text> 456 <Text 457 style={[a.text_sm, a.leading_relaxed, t.atoms.text_contrast_medium]} 458 numberOfLines={1}> 459 <Trans> 460 By{' '} 461 <InlineLinkText 462 label={_(msg`View ${info.creatorHandle}'s profile`)} 463 to={makeProfileLink({ 464 did: info.creatorDid, 465 handle: info.creatorHandle, 466 })} 467 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]} 468 numberOfLines={1} 469 onPress={() => control.close()}> 470 {sanitizeHandle(info.creatorHandle, '@')} 471 </InlineLinkText> 472 </Trans> 473 </Text> 474 </View> 475 476 <Button 477 label={_(msg`Share this feed`)} 478 size="small" 479 variant="ghost" 480 color="secondary" 481 shape={enableSquareButtons ? 'square' : 'round'} 482 onPress={onPressShare}> 483 <ButtonIcon icon={Share} size="lg" /> 484 </Button> 485 </View> 486 487 <RichText value={rt} style={[a.text_md]} /> 488 489 <View style={[a.flex_row, a.gap_sm, a.align_center]}> 490 {typeof likeCount === 'number' && ( 491 <InlineLinkText 492 label={_(msg`View users who like this feed`)} 493 to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')} 494 style={[a.underline, t.atoms.text_contrast_medium]} 495 onPress={() => control.close()}> 496 <Trans> 497 Liked by <Plural value={likeCount} one="# user" other="# users" /> 498 </Trans> 499 </InlineLinkText> 500 )} 501 </View> 502 503 {hasSession && ( 504 <> 505 <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}> 506 <Button 507 disabled={isLikePending || isUnlikePending} 508 label={_(msg`Like this feed`)} 509 size="small" 510 variant="solid" 511 color="secondary" 512 onPress={onToggleLiked} 513 style={[a.flex_1]}> 514 {isLiked ? ( 515 <HeartFilled size="sm" fill={t.palette.like} /> 516 ) : ( 517 <ButtonIcon icon={Heart} position="left" /> 518 )} 519 520 <ButtonText> 521 {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>} 522 </ButtonText> 523 </Button> 524 <Button 525 disabled={isFeedStateChangePending} 526 label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)} 527 size="small" 528 variant="solid" 529 color={isPinned ? 'secondary' : 'primary'} 530 onPress={onTogglePinned} 531 style={[a.flex_1]}> 532 <ButtonText> 533 {isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>} 534 </ButtonText> 535 <ButtonIcon icon={Pin} position="right" /> 536 </Button> 537 </View> 538 539 <View style={[a.pt_xs, a.gap_lg]}> 540 <Divider /> 541 542 <View 543 style={[a.flex_row, a.align_center, a.gap_sm, a.justify_between]}> 544 <Text style={[a.italic, t.atoms.text_contrast_medium]}> 545 <Trans>Something wrong? Let us know.</Trans> 546 </Text> 547 548 <Button 549 label={_(msg`Report feed`)} 550 size="small" 551 variant="solid" 552 color="secondary" 553 onPress={onPressReport}> 554 <ButtonText> 555 <Trans>Report feed</Trans> 556 </ButtonText> 557 <ButtonIcon icon={CircleInfo} position="right" /> 558 </Button> 559 </View> 560 561 {info.view && ( 562 <ReportDialog 563 control={reportDialogControl} 564 subject={{ 565 ...info.view, 566 $type: 'app.bsky.feed.defs#generatorView', 567 }} 568 /> 569 )} 570 </View> 571 </> 572 )} 573 </View> 574 ) 575}