diff options
Diffstat (limited to 'src/screens/Search/components/StarterPackCard.tsx')
-rw-r--r-- | src/screens/Search/components/StarterPackCard.tsx | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx new file mode 100644 index 000000000..9520dd5a7 --- /dev/null +++ b/src/screens/Search/components/StarterPackCard.tsx @@ -0,0 +1,296 @@ +import React from 'react' +import {View} from 'react-native' +import { + type AppBskyGraphDefs, + AppBskyGraphStarterpack, + moderateProfile, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {ButtonText} from '#/components/Button' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Link} from '#/components/Link' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard' +import {Text} from '#/components/Typography' +import * as bsky from '#/types/bsky' + +export function StarterPackCard({ + view, +}: { + view: AppBskyGraphDefs.StarterPackView +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {gtPhone} = useBreakpoints() + const link = useStarterPackLink({view}) + + if ( + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( + view.record, + AppBskyGraphStarterpack.isRecord, + ) + ) { + return null + } + + const profileCount = gtPhone ? 11 : 8 + const profiles = view.listItemsSample + ?.slice(0, profileCount) + .map(item => item.subject) + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}> + <Link + to={link.to} + label={link.label} + style={[a.absolute, a.inset_0]} + onHoverIn={link.precache} + onPress={link.precache}> + <View /> + </Link> + </View> + + <AvatarStack + profiles={profiles ?? []} + numPending={profileCount} + total={view.list?.listItemCount} + /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1]}> + <Text + emoji + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {view.record.name} + </Text> + <Text + emoji + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {view.creator?.did === currentAccount?.did + ? _(msg`By you`) + : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} + </Text> + </View> + <Link + to={link.to} + label={link.label} + onHoverIn={link.precache} + onPress={link.precache} + variant="solid" + color="secondary" + size="small" + style={[a.z_50]}> + <ButtonText> + <Trans>Open pack</Trans> + </ButtonText> + </Link> + </View> + </View> + ) +} + +export function AvatarStack({ + profiles, + numPending, + total, +}: { + profiles: bsky.profile.AnyProfileView[] + numPending: number + total?: number +}) { + const t = useTheme() + const {gtPhone} = useBreakpoints() + const moderationOpts = useModerationOpts() + const computedTotal = (total ?? numPending) - numPending + const circlesCount = numPending + 1 // add total at end + const widthPerc = 100 / circlesCount + const [size, setSize] = React.useState<number | null>(null) + + const isPending = (numPending && profiles.length === 0) || !moderationOpts + + const items = isPending + ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.relative, + {width: `${100 - widthPerc * 0.2}%`}, + ]}> + {items.map((item, i) => ( + <View + key={item.key} + style={[ + { + width: `${widthPerc}%`, + zIndex: 100 - i, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + onLayout={e => setSize(e.nativeEvent.layout.width)} + style={[ + a.rounded_full, + t.atoms.bg_contrast_25, + { + paddingTop: '100%', + }, + ]}> + {size && item.profile ? ( + <UserAvatar + size={size} + avatar={item.profile.avatar} + type={item.profile.associated?.labeler ? 'labeler' : 'user'} + moderation={item.moderation.ui('avatar')} + style={[a.absolute, a.inset_0]} + /> + ) : ( + <MediaInsetBorder style={[a.rounded_full]} /> + )} + </View> + </View> + </View> + ))} + <View + style={[ + { + width: `${widthPerc}%`, + zIndex: 1, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + style={[ + { + paddingTop: '100%', + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.rounded_full, + a.align_center, + a.justify_center, + { + backgroundColor: t.atoms.text_contrast_low.color, + }, + ]}> + {computedTotal > 0 ? ( + <Text + style={[ + gtPhone ? a.text_md : a.text_sm, + a.font_bold, + a.leading_snug, + {color: 'white'}, + ]}> + <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12"> + +{computedTotal} + </Trans> + </Text> + ) : ( + <Plus fill="white" /> + )} + </View> + </View> + </View> + </View> + </View> + ) +} + +export function StarterPackCardSkeleton() { + const t = useTheme() + const {gtPhone} = useBreakpoints() + + const profileCount = gtPhone ? 11 : 8 + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <AvatarStack profiles={[]} numPending={profileCount} /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <LoadingPlaceholder width={180} height={18} /> + <LoadingPlaceholder width={120} height={14} /> + </View> + + <LoadingPlaceholder width={100} height={33} /> + </View> + </View> + ) +} |