Bluesky app fork with some witchin' additions 馃挮
at main 309 lines 8.5 kB view raw
1import React from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyGraphDefs, 5 AppBskyGraphStarterpack, 6 moderateProfile, 7} from '@atproto/api' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10import {Trans} from '@lingui/react/macro' 11 12import {sanitizeHandle} from '#/lib/strings/handles' 13import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 14import {useModerationOpts} from '#/state/preferences/moderation-opts' 15import {useSession} from '#/state/session' 16import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 17import {UserAvatar} from '#/view/com/util/UserAvatar' 18import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 19import {ButtonText} from '#/components/Button' 20import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 21import {Link} from '#/components/Link' 22import {MediaInsetBorder} from '#/components/MediaInsetBorder' 23import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard' 24import {SubtleHover} from '#/components/SubtleHover' 25import {Text} from '#/components/Typography' 26import * as bsky from '#/types/bsky' 27 28export function StarterPackCard({ 29 view, 30}: { 31 view: AppBskyGraphDefs.StarterPackView 32}) { 33 const t = useTheme() 34 const {_} = useLingui() 35 const {currentAccount} = useSession() 36 const {gtPhone} = useBreakpoints() 37 const link = useStarterPackLink({view}) 38 const record = view.record 39 40 if ( 41 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 42 record, 43 AppBskyGraphStarterpack.isRecord, 44 ) 45 ) { 46 return null 47 } 48 49 const profileCount = gtPhone ? 11 : 8 50 const profiles = view.listItemsSample 51 ?.slice(0, profileCount) 52 .map(item => item.subject) 53 54 return ( 55 <Link 56 to={link.to} 57 label={link.label} 58 onHoverIn={link.precache} 59 onPress={link.precache}> 60 {s => ( 61 <> 62 <SubtleHover hover={s.hovered || s.pressed} /> 63 64 <View 65 style={[ 66 a.w_full, 67 a.p_lg, 68 a.gap_md, 69 a.border, 70 a.rounded_sm, 71 a.overflow_hidden, 72 t.atoms.border_contrast_low, 73 ]}> 74 <AvatarStack 75 profiles={profiles ?? []} 76 numPending={profileCount} 77 total={view.list?.listItemCount} 78 /> 79 80 <View 81 style={[ 82 a.w_full, 83 a.flex_row, 84 a.align_start, 85 a.gap_lg, 86 web({ 87 position: 'static', 88 zIndex: 'unset', 89 }), 90 ]}> 91 <View style={[a.flex_1]}> 92 <Text 93 emoji 94 style={[a.text_md, a.font_semi_bold, a.leading_snug]} 95 numberOfLines={1}> 96 {record.name} 97 </Text> 98 <Text 99 emoji 100 style={[ 101 a.text_sm, 102 a.leading_snug, 103 t.atoms.text_contrast_medium, 104 ]} 105 numberOfLines={1}> 106 {view.creator?.did === currentAccount?.did 107 ? _(msg`By you`) 108 : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} 109 </Text> 110 </View> 111 <Link 112 to={link.to} 113 label={link.label} 114 onHoverIn={link.precache} 115 onPress={link.precache} 116 variant="solid" 117 color="secondary" 118 size="small" 119 style={[a.z_50]}> 120 <ButtonText> 121 <Trans>Open pack</Trans> 122 </ButtonText> 123 </Link> 124 </View> 125 </View> 126 </> 127 )} 128 </Link> 129 ) 130} 131 132export function AvatarStack({ 133 profiles, 134 numPending, 135 total, 136}: { 137 profiles: bsky.profile.AnyProfileView[] 138 numPending: number 139 total?: number 140}) { 141 const t = useTheme() 142 const {gtPhone} = useBreakpoints() 143 const moderationOpts = useModerationOpts() 144 const computedTotal = (total ?? numPending) - numPending 145 const circlesCount = numPending + 1 // add total at end 146 const widthPerc = 100 / circlesCount 147 const [size, setSize] = React.useState<number | null>(null) 148 149 const enableSquareButtons = useEnableSquareButtons() 150 151 const isPending = (numPending && profiles.length === 0) || !moderationOpts 152 153 const items = isPending 154 ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({ 155 key: i, 156 profile: null, 157 moderation: null, 158 })) 159 : profiles.map(item => ({ 160 key: item.did, 161 profile: item, 162 moderation: moderateProfile(item, moderationOpts), 163 })) 164 165 return ( 166 <View 167 style={[ 168 a.w_full, 169 a.flex_row, 170 a.align_center, 171 a.relative, 172 {width: `${100 - widthPerc * 0.2}%`}, 173 ]}> 174 {items.map((item, i) => ( 175 <View 176 key={item.key} 177 style={[ 178 { 179 width: `${widthPerc}%`, 180 zIndex: 100 - i, 181 }, 182 ]}> 183 <View 184 style={[ 185 a.relative, 186 { 187 width: '120%', 188 }, 189 ]}> 190 <View 191 onLayout={e => setSize(e.nativeEvent.layout.width)} 192 style={[ 193 enableSquareButtons ? a.rounded_sm : a.rounded_full, 194 t.atoms.bg_contrast_25, 195 { 196 paddingTop: '100%', 197 }, 198 ]}> 199 {size && item.profile ? ( 200 <UserAvatar 201 size={size} 202 avatar={item.profile.avatar} 203 type={item.profile.associated?.labeler ? 'labeler' : 'user'} 204 moderation={item.moderation.ui('avatar')} 205 style={[a.absolute, a.inset_0]} 206 /> 207 ) : ( 208 <MediaInsetBorder 209 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 210 /> 211 )} 212 </View> 213 </View> 214 </View> 215 ))} 216 <View 217 style={[ 218 { 219 width: `${widthPerc}%`, 220 zIndex: 1, 221 }, 222 ]}> 223 <View 224 style={[ 225 a.relative, 226 { 227 width: '120%', 228 }, 229 ]}> 230 <View 231 style={[ 232 { 233 paddingTop: '100%', 234 }, 235 ]}> 236 <View 237 style={[ 238 a.absolute, 239 a.inset_0, 240 enableSquareButtons ? a.rounded_sm : a.rounded_full, 241 a.align_center, 242 a.justify_center, 243 { 244 backgroundColor: t.atoms.text_contrast_low.color, 245 }, 246 ]}> 247 {computedTotal > 0 ? ( 248 <Text 249 style={[ 250 gtPhone ? a.text_md : a.text_xs, 251 a.font_semi_bold, 252 a.leading_snug, 253 {color: 'white'}, 254 ]}> 255 <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12"> 256 +{computedTotal} 257 </Trans> 258 </Text> 259 ) : ( 260 <Plus fill="white" /> 261 )} 262 </View> 263 </View> 264 </View> 265 </View> 266 </View> 267 ) 268} 269 270export function StarterPackCardSkeleton() { 271 const t = useTheme() 272 const {gtPhone} = useBreakpoints() 273 274 const profileCount = gtPhone ? 11 : 8 275 276 return ( 277 <View 278 style={[ 279 a.w_full, 280 a.p_lg, 281 a.gap_md, 282 a.border, 283 a.rounded_sm, 284 a.overflow_hidden, 285 t.atoms.border_contrast_low, 286 ]}> 287 <AvatarStack profiles={[]} numPending={profileCount} /> 288 289 <View 290 style={[ 291 a.w_full, 292 a.flex_row, 293 a.align_start, 294 a.gap_lg, 295 web({ 296 position: 'static', 297 zIndex: 'unset', 298 }), 299 ]}> 300 <View style={[a.flex_1, a.gap_xs]}> 301 <LoadingPlaceholder width={180} height={18} /> 302 <LoadingPlaceholder width={120} height={14} /> 303 </View> 304 305 <LoadingPlaceholder width={100} height={33} /> 306 </View> 307 </View> 308 ) 309}