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