Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Add follow button to feed item avatar (#3560)

* add follow button to feed item avatar

* remove confirmation

* add confirmation (just system alert)

* Shrink the avi follow indicator a smidge

* gate the follow button

* remove from your own posts

* add to post thread item

* hide the follow button locally to component

* Use native dropdown

* Add follow btn to notifications and search

* UI tweaks

* Hide on PWI

* Add toast for confirmation

* Check gate last

* compiler

* Rm unused

* Use names

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Samuel Newman
Paul Frazee
Eric Bailey
Dan Abramov
and committed by
GitHub
8569e2e3 98791594

+177 -36
+1
assets/icons/personPlus_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
+1 -1
src/components/Button.tsx
··· 64 64 65 65 export type ButtonProps = Pick< 66 66 PressableProps, 67 - 'disabled' | 'onPress' | 'testID' | 'onLongPress' 67 + 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop' 68 68 > & 69 69 AccessibilityProps & 70 70 VariantProps & {
+5 -3
src/components/dms/ConvoMenu.tsx
··· 26 26 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 27 27 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 28 28 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 29 - import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 30 - import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 31 - import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 29 + import { 30 + Person_Stroke2_Corner0_Rounded as Person, 31 + PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, 32 + PersonX_Stroke2_Corner0_Rounded as PersonX, 33 + } from '#/components/icons/Person' 32 34 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 33 35 import * as Menu from '#/components/Menu' 34 36 import * as Prompt from '#/components/Prompt'
+12
src/components/icons/Person.tsx
··· 3 3 export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z', 5 5 }) 6 + 7 + export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z', 9 + }) 10 + 11 + export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ 12 + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z', 13 + }) 14 + 15 + export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ 16 + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', 17 + })
-5
src/components/icons/PersonCheck.tsx
··· 1 - import {createSinglePathSVG} from './TEMPLATE' 2 - 3 - export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z', 5 - })
-5
src/components/icons/PersonX.tsx
··· 1 - import {createSinglePathSVG} from './TEMPLATE' 2 - 3 - export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z', 5 - })
+2
src/lib/statsig/events.ts
··· 115 115 | 'ProfileHeaderSuggestedFollows' 116 116 | 'ProfileMenu' 117 117 | 'ProfileHoverCard' 118 + | 'AvatarButton' 118 119 } 119 120 'profile:unfollow': { 120 121 logContext: ··· 126 127 | 'ProfileMenu' 127 128 | 'ProfileHoverCard' 128 129 | 'Chat' 130 + | 'AvatarButton' 129 131 } 130 132 'chat:create': { 131 133 logContext: 'ProfileHeader' | 'NewChatDialog'
+1
src/lib/statsig/gates.ts
··· 1 1 export type Gate = 2 2 // Keep this alphabetic please. 3 3 | 'request_notifications_permission_after_onboarding_v2' 4 + | 'show_avi_follow_button' 4 5 | 'show_follow_back_label_v2'
+11 -6
src/view/com/post-thread/PostThreadItem.tsx
··· 40 40 import {PostAlerts} from '../../../components/moderation/PostAlerts' 41 41 import {PostHider} from '../../../components/moderation/PostHider' 42 42 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 43 + import {AviFollowButton} from '../posts/AviFollowButton' 43 44 import {WhoCanReply} from '../threadgate/WhoCanReply' 44 45 import {ErrorMessage} from '../util/error/ErrorMessage' 45 46 import {Link, TextLink} from '../util/Link' ··· 470 471 {/* If we are in threaded mode, the avatar is rendered in PostMeta */} 471 472 {!isThreadedChild && ( 472 473 <View style={styles.layoutAvi}> 473 - <PreviewableUserAvatar 474 - size={38} 475 - profile={post.author} 476 - moderation={moderation.ui('avatar')} 477 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 478 - /> 474 + <AviFollowButton author={post.author} moderation={moderation}> 475 + <PreviewableUserAvatar 476 + size={38} 477 + profile={post.author} 478 + moderation={moderation.ui('avatar')} 479 + type={ 480 + post.author.associated?.labeler ? 'labeler' : 'user' 481 + } 482 + /> 483 + </AviFollowButton> 479 484 480 485 {showChildReplyLine && ( 481 486 <View
+10 -7
src/view/com/post/Post.tsx
··· 22 22 import {countLines} from 'lib/strings/helpers' 23 23 import {colors, s} from 'lib/styles' 24 24 import {precacheProfile} from 'state/queries/profile' 25 + import {AviFollowButton} from '#/view/com/posts/AviFollowButton' 25 26 import {atoms as a} from '#/alf' 26 27 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 27 28 import {RichText} from '#/components/RichText' ··· 146 147 {showReplyLine && <View style={styles.replyLine} />} 147 148 <View style={styles.layout}> 148 149 <View style={styles.layoutAvi}> 149 - <PreviewableUserAvatar 150 - size={52} 151 - profile={post.author} 152 - moderation={moderation.ui('avatar')} 153 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 154 - /> 150 + <AviFollowButton author={post.author} moderation={moderation}> 151 + <PreviewableUserAvatar 152 + size={52} 153 + profile={post.author} 154 + moderation={moderation.ui('avatar')} 155 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 156 + /> 157 + </AviFollowButton> 155 158 </View> 156 159 <View style={styles.layoutContent}> 157 160 <PostMeta ··· 245 248 }, 246 249 layout: { 247 250 flexDirection: 'row', 251 + gap: 10, 248 252 }, 249 253 layoutAvi: { 250 - width: 70, 251 254 paddingLeft: 8, 252 255 }, 253 256 layoutContent: {
+115
src/view/com/posts/AviFollowButton.tsx
··· 1 + import React, {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useNavigation} from '@react-navigation/native' 7 + 8 + import {createHitslop} from '#/lib/constants' 9 + import {NavigationProp} from '#/lib/routes/types' 10 + import {useGate} from '#/lib/statsig/statsig' 11 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 12 + import {useProfileShadow} from '#/state/cache/profile-shadow' 13 + import {useSession} from '#/state/session' 14 + import { 15 + DropdownItem, 16 + NativeDropdown, 17 + } from '#/view/com/util/forms/NativeDropdown' 18 + import * as Toast from '#/view/com/util/Toast' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {Button} from '#/components/Button' 21 + import {useFollowMethods} from '#/components/hooks/useFollowMethods' 22 + import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 23 + 24 + export function AviFollowButton({ 25 + author, 26 + moderation, 27 + children, 28 + }: { 29 + author: AppBskyActorDefs.ProfileViewBasic 30 + moderation: ModerationDecision 31 + children: React.ReactNode 32 + }) { 33 + const {_} = useLingui() 34 + const t = useTheme() 35 + const profile = useProfileShadow(author) 36 + const {follow} = useFollowMethods({ 37 + profile: profile, 38 + logContext: 'AvatarButton', 39 + }) 40 + const gate = useGate() 41 + const {currentAccount, hasSession} = useSession() 42 + const [followed, setFollowed] = useState<string | null>(null) 43 + const navigation = useNavigation<NavigationProp>() 44 + 45 + const name = sanitizeDisplayName( 46 + profile.displayName || profile.handle, 47 + moderation.ui('displayName'), 48 + ) 49 + const isFollowing = 50 + profile.viewer?.following || 51 + profile.did === followed || 52 + profile.did === currentAccount?.did 53 + 54 + function onPress() { 55 + follow() 56 + setFollowed(profile.did) 57 + Toast.show(_(msg`Following ${name}`)) 58 + } 59 + 60 + const items: DropdownItem[] = [ 61 + { 62 + label: _(msg`View profile`), 63 + onPress: () => { 64 + navigation.navigate('Profile', {name: profile.did}) 65 + }, 66 + icon: { 67 + ios: { 68 + name: 'arrow.up.right.square', 69 + }, 70 + android: '', 71 + web: ['far', 'arrow-up-right-from-square'], 72 + }, 73 + }, 74 + { 75 + label: _(msg`Follow ${name}`), 76 + onPress: onPress, 77 + icon: { 78 + ios: { 79 + name: 'person.badge.plus', 80 + }, 81 + android: '', 82 + web: ['far', 'user-plus'], 83 + }, 84 + }, 85 + ] 86 + 87 + return hasSession && gate('show_avi_follow_button') ? ( 88 + <View style={a.relative}> 89 + {children} 90 + 91 + {!isFollowing && ( 92 + <Button 93 + label={_(msg`Open ${name} profile shortcut menu`)} 94 + hitSlop={createHitslop(3)} 95 + style={[ 96 + a.rounded_full, 97 + t.atoms.bg_contrast_975, 98 + a.absolute, 99 + { 100 + bottom: -2, 101 + right: -2, 102 + borderWidth: 1, 103 + borderColor: t.atoms.bg.backgroundColor, 104 + }, 105 + ]}> 106 + <NativeDropdown items={items}> 107 + <Plus size="sm" fill={t.atoms.bg.backgroundColor} /> 108 + </NativeDropdown> 109 + </Button> 110 + )} 111 + </View> 112 + ) : ( 113 + children 114 + ) 115 + }
+1
src/view/com/posts/AviFollowButton.web.tsx
··· 1 + export {Fragment as AviFollowButton} from 'react'
+14 -7
src/view/com/posts/FeedItem.tsx
··· 41 41 import {PostMeta} from '../util/PostMeta' 42 42 import {Text} from '../util/text/Text' 43 43 import {PreviewableUserAvatar} from '../util/UserAvatar' 44 + import {AviFollowButton} from './AviFollowButton' 44 45 45 46 interface FeedItemProps { 46 47 record: AppBskyFeedPost.Record ··· 284 285 285 286 <View style={styles.layout}> 286 287 <View style={styles.layoutAvi}> 287 - <PreviewableUserAvatar 288 - size={52} 289 - profile={post.author} 290 - moderation={moderation.ui('avatar')} 291 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 292 - onBeforePress={onOpenAuthor} 293 - /> 288 + <AviFollowButton author={post.author} moderation={moderation}> 289 + <PreviewableUserAvatar 290 + size={52} 291 + profile={post.author} 292 + moderation={moderation.ui('avatar')} 293 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 294 + onBeforePress={onOpenAuthor} 295 + /> 296 + </AviFollowButton> 294 297 {isThreadParent && ( 295 298 <View 296 299 style={[ ··· 470 473 }, 471 474 layoutAvi: { 472 475 paddingLeft: 8, 476 + position: 'relative', 477 + zIndex: 999, 473 478 }, 474 479 layoutContent: { 480 + position: 'relative', 475 481 flex: 1, 482 + zIndex: 0, 476 483 }, 477 484 alert: { 478 485 marginTop: 6,
+4 -2
src/view/com/profile/ProfileMenu.tsx
··· 30 30 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 31 31 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 32 32 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 33 - import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 34 - import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 33 + import { 34 + PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, 35 + PersonX_Stroke2_Corner0_Rounded as PersonX, 36 + } from '#/components/icons/Person' 35 37 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 36 38 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 37 39 import * as Menu from '#/components/Menu'