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