Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 262 lines 7.7 kB view raw
1import React from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 moderateProfile, 6 type ModerationOpts, 7} from '@atproto/api' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10import {Plural, Trans} from '@lingui/react/macro' 11 12import {makeProfileLink} from '#/lib/routes/links' 13import {sanitizeDisplayName} from '#/lib/strings/display-names' 14import {UserAvatar} from '#/view/com/util/UserAvatar' 15import {atoms as a, useTheme} from '#/alf' 16import {Link, type LinkProps} from '#/components/Link' 17import {Text} from '#/components/Typography' 18import type * as bsky from '#/types/bsky' 19 20const AVI_SIZE = 30 21const AVI_SIZE_SMALL = 20 22const AVI_BORDER = 1 23 24/** 25 * Shared logic to determine if `KnownFollowers` should be shown. 26 * 27 * Checks the # of actual returned users instead of the `count` value, because 28 * `count` includes blocked users and `followers` does not. 29 */ 30export function shouldShowKnownFollowers( 31 knownFollowers?: AppBskyActorDefs.KnownFollowers, 32) { 33 return knownFollowers && knownFollowers.followers.length > 0 34} 35 36export function KnownFollowers({ 37 profile, 38 moderationOpts, 39 onLinkPress, 40 minimal, 41 showIfEmpty, 42}: { 43 profile: bsky.profile.AnyProfileView 44 moderationOpts: ModerationOpts 45 onLinkPress?: LinkProps['onPress'] 46 minimal?: boolean 47 showIfEmpty?: boolean 48}) { 49 const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( 50 new Map(), 51 ) 52 53 /* 54 * Results for `knownFollowers` are not sorted consistently, so when 55 * revalidating we can see a flash of this data updating. This cache prevents 56 * this happening for screens that remain in memory. When pushing a new 57 * screen, or once this one is popped, this cache is empty, so new data is 58 * displayed. 59 */ 60 if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) { 61 cache.current.set(profile.did, profile.viewer.knownFollowers) 62 } 63 64 const cachedKnownFollowers = cache.current.get(profile.did) 65 66 if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) { 67 return ( 68 <KnownFollowersInner 69 profile={profile} 70 cachedKnownFollowers={cachedKnownFollowers} 71 moderationOpts={moderationOpts} 72 onLinkPress={onLinkPress} 73 minimal={minimal} 74 showIfEmpty={showIfEmpty} 75 /> 76 ) 77 } 78 79 return <EmptyFallback show={showIfEmpty} /> 80} 81 82function KnownFollowersInner({ 83 profile, 84 moderationOpts, 85 cachedKnownFollowers, 86 onLinkPress, 87 minimal, 88 showIfEmpty, 89}: { 90 profile: bsky.profile.AnyProfileView 91 moderationOpts: ModerationOpts 92 cachedKnownFollowers: AppBskyActorDefs.KnownFollowers 93 onLinkPress?: LinkProps['onPress'] 94 minimal?: boolean 95 showIfEmpty?: boolean 96}) { 97 const t = useTheme() 98 const {_} = useLingui() 99 100 const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] 101 102 const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { 103 const moderation = moderateProfile(f, moderationOpts) 104 return { 105 profile: { 106 ...f, 107 displayName: sanitizeDisplayName( 108 f.displayName || f.handle, 109 moderation.ui('displayName'), 110 ), 111 }, 112 moderation, 113 } 114 }) 115 116 // Does not have blocks applied. Always >= slices.length 117 const serverCount = cachedKnownFollowers.count 118 119 /* 120 * We check above too, but here for clarity and a reminder to _check for 121 * valid indices_ 122 */ 123 if (slice.length === 0) return <EmptyFallback show={showIfEmpty} /> 124 125 const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE 126 127 return ( 128 <Link 129 label={_( 130 msg`Press to view followers of this account that you also follow`, 131 )} 132 onPress={onLinkPress} 133 to={makeProfileLink(profile, 'known-followers')} 134 style={[ 135 a.max_w_full, 136 a.flex_row, 137 minimal ? a.gap_sm : a.gap_md, 138 a.align_center, 139 {marginLeft: -AVI_BORDER}, 140 ]}> 141 {({hovered, pressed}) => ( 142 <> 143 <View 144 style={[ 145 a.flex_row, 146 { 147 height: SIZE, 148 }, 149 pressed && { 150 opacity: 0.5, 151 }, 152 ]}> 153 {slice.map(({profile: prof, moderation}, i) => ( 154 <View 155 key={prof.did} 156 style={[ 157 a.rounded_full, 158 { 159 borderWidth: AVI_BORDER, 160 borderColor: t.atoms.bg.backgroundColor, 161 width: SIZE + AVI_BORDER * 2, 162 height: SIZE + AVI_BORDER * 2, 163 zIndex: AVI_BORDER - i, 164 marginLeft: i > 0 ? -8 : 0, 165 }, 166 ]}> 167 <UserAvatar 168 size={SIZE} 169 avatar={prof.avatar} 170 moderation={moderation.ui('avatar')} 171 type={prof.associated?.labeler ? 'labeler' : 'user'} 172 noBorder 173 /> 174 </View> 175 ))} 176 </View> 177 178 <Text 179 style={[ 180 a.flex_shrink, 181 textStyle, 182 hovered && { 183 textDecorationLine: 'underline', 184 textDecorationColor: t.atoms.text_contrast_medium.color, 185 }, 186 pressed && { 187 opacity: 0.5, 188 }, 189 ]} 190 numberOfLines={2}> 191 {slice.length >= 2 ? ( 192 // 2-n followers, including blocks 193 serverCount > 2 ? ( 194 <Trans> 195 Followed by{' '} 196 <Text emoji key={slice[0].profile.did} style={textStyle}> 197 {slice[0].profile.displayName} 198 </Text> 199 ,{' '} 200 <Text emoji key={slice[1].profile.did} style={textStyle}> 201 {slice[1].profile.displayName} 202 </Text> 203 , and{' '} 204 <Plural 205 value={serverCount - 2} 206 one="# other" 207 other="# others" 208 /> 209 </Trans> // only 2 210 ) : ( 211 <Trans> 212 Followed by{' '} 213 <Text emoji key={slice[0].profile.did} style={textStyle}> 214 {slice[0].profile.displayName} 215 </Text>{' '} 216 and{' '} 217 <Text emoji key={slice[1].profile.did} style={textStyle}> 218 {slice[1].profile.displayName} 219 </Text> 220 </Trans> 221 ) 222 ) : serverCount > 1 ? ( 223 // 1-n followers, including blocks 224 <Trans> 225 Followed by{' '} 226 <Text emoji key={slice[0].profile.did} style={textStyle}> 227 {slice[0].profile.displayName} 228 </Text>{' '} 229 and{' '} 230 <Plural 231 value={serverCount - 1} 232 one="# other" 233 other="# others" 234 /> 235 </Trans> 236 ) : ( 237 // only 1 238 <Trans> 239 Followed by{' '} 240 <Text emoji key={slice[0].profile.did} style={textStyle}> 241 {slice[0].profile.displayName} 242 </Text> 243 </Trans> 244 )} 245 </Text> 246 </> 247 )} 248 </Link> 249 ) 250} 251 252function EmptyFallback({show}: {show?: boolean}) { 253 const t = useTheme() 254 255 if (!show) return null 256 257 return ( 258 <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 259 <Trans>Not followed by anyone you're following</Trans> 260 </Text> 261 ) 262}