Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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