New SP card

This commit is contained in:
Eric Bailey
2025-04-02 15:27:24 -05:00
parent a6b3d741c9
commit 101114e384
5 changed files with 286 additions and 139 deletions
+10 -4
View File
@@ -6,22 +6,24 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile' import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function AvatarStack({ export function AvatarStack({
profiles, profiles,
size = 26, size = 26,
numPending, numPending,
backgroundColor, backgroundColor,
spacing = 'normal',
}: { }: {
profiles: bsky.profile.AnyProfileView[] profiles: bsky.profile.AnyProfileView[]
size?: number size?: number
numPending?: number numPending?: number
backgroundColor?: string backgroundColor?: string
spacing?: 'normal' | 'loose'
}) { }) {
const halfSize = size / 2
const t = useTheme() const t = useTheme()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const translateX = size / getSpacingValue(spacing)
const isPending = (numPending && profiles.length === 0) || !moderationOpts const isPending = (numPending && profiles.length === 0) || !moderationOpts
@@ -43,7 +45,7 @@ export function AvatarStack({
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.relative, a.relative,
{width: size + (items.length - 1) * halfSize}, {width: size + (items.length - 1) * translateX},
]}> ]}>
{items.map((item, i) => ( {items.map((item, i) => (
<View <View
@@ -54,7 +56,7 @@ export function AvatarStack({
{ {
width: size, width: size,
height: size, height: size,
left: i * -halfSize, left: i * -translateX,
borderWidth: 1, borderWidth: 1,
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor, borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999, borderRadius: 999,
@@ -75,6 +77,10 @@ export function AvatarStack({
) )
} }
export function getSpacingValue(spacing: 'normal' | 'loose') {
return spacing === 'normal' ? 2 : 4
}
export function AvatarStackWithFetch({ export function AvatarStackWithFetch({
profiles, profiles,
size, size,
+27 -1
View File
@@ -13,7 +13,7 @@ import {precacheStarterPack} from '#/state/queries/starter-packs'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link' import {Link as BaseLink, type LinkProps as BaseLinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -104,6 +104,32 @@ export function Card({
) )
} }
export function useStarterPackLink({
view,
}: {
view: bsky.starterPack.AnyStarterPackView
}) {
const {_} = useLingui()
const qc = useQueryClient()
const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(view.uri).rkey
const {creator} = view
return {rkey, handleOrDid: creator.handle || creator.did}
}, [view])
const precache = () => {
precacheResolvedUri(qc, view.creator.handle, view.creator.did)
precacheStarterPack(qc, view)
}
return {
to: `/starter-pack/${handleOrDid}/${rkey}`,
label: AppBskyGraphStarterpack.isRecord(view.record)
? _(msg`Navigate to ${view.record.name}`)
: _(msg`Navigate to starter pack`),
precache,
}
}
export function Link({ export function Link({
starterPack, starterPack,
children, children,
+14 -13
View File
@@ -1,6 +1,10 @@
import {useCallback, useMemo, useRef, useState} from 'react' import {useCallback, useMemo, useRef, useState} from 'react'
import {View, type ViewabilityConfig, type ViewToken} from 'react-native' import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
import {type AppBskyActorDefs, type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api' import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -13,19 +17,20 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import { import {
FeedFeedLoadingPlaceholder, FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder, ProfileCardFeedLoadingPlaceholder,
} from '#/view/com/util/LoadingPlaceholder' } from '#/view/com/util/LoadingPlaceholder'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -40,7 +45,6 @@ import {
SuggestedProfileCard, SuggestedProfileCard,
useLoadEnoughProfiles, useLoadEnoughProfiles,
} from './modules/ExploreSuggestedAccounts' } from './modules/ExploreSuggestedAccounts'
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme() const t = useTheme()
@@ -156,9 +160,9 @@ type ExploreScreenItems =
error: string error: string
} }
| { | {
type: 'starterPack', type: 'starterPack'
key: string key: string
view: AppBskyGraphDefs.StarterPackViewBasic view: AppBskyGraphDefs.StarterPackView
} }
export function Explore({ export function Explore({
@@ -465,7 +469,7 @@ export function Explore({
// error: cleanError(suggestedSPsError), // error: cleanError(suggestedSPsError),
// }) // })
} else { } else {
suggestedSPs.map(s => { suggestedSPs.starterPacks.map(s => {
i.push({ i.push({
type: 'starterPack', type: 'starterPack',
key: s.uri, key: s.uri,
@@ -512,6 +516,9 @@ export function Explore({
gate, gate,
moderationOpts, moderationOpts,
hasPressedLoadMoreFeeds, hasPressedLoadMoreFeeds,
suggestedSPs,
isLoadingSuggestedSPs,
suggestedSPsError,
]) ])
const renderItem = useCallback( const renderItem = useCallback(
@@ -593,13 +600,7 @@ export function Explore({
} }
case 'starterPack': { case 'starterPack': {
return ( return (
<View <View style={[a.px_lg, a.py_sm]}>
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<StarterPackCard view={item.view} /> <StarterPackCard view={item.view} />
</View> </View>
) )
+232 -36
View File
@@ -1,57 +1,253 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyGraphDefs} from '@atproto/api' import {
import {Image} from 'expo-image' type AppBskyGraphDefs,
AppBskyGraphStarterpack,
moderateProfile,
} from '@atproto/api'
import {msg,Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {sanitizeHandle} from '#/lib/strings/handles'
import * as Card from '#/components/StarterPack/StarterPackCard' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme, useBreakpoints} from '#/alf' import {useSession} from '#/state/session'
import {Button, ButtonText} from '#/components/Button' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ButtonText} from '#/components/Button'
import {PlusLarge_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.StarterPackViewBasic}) { export function StarterPackCard({
view,
}: {
view: AppBskyGraphDefs.StarterPackView
}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const imageUri = getStarterPackOgCard(view) const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints() 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 ( return (
<View <View
style={[ style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border, a.border,
a.rounded_sm, a.rounded_sm,
a.overflow_hidden, a.overflow_hidden,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
<Card.Link starterPack={view}> <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
<View style={[a.w_full, gtPhone && [ <Link
a.flex_row, a.gap_lg, to={link.to}
]]}> label={link.label}
<View style={[gtPhone && { style={[a.absolute, a.inset_0]}
width: '50%', onHoverIn={link.precache}
}]}> onPress={link.precache}>
<Image <View />
source={imageUri} </Link>
style={[a.w_full, {aspectRatio: 1.91}]} </View>
accessibilityIgnoresInvertColors={true}
/> <AvatarStack
</View> profiles={profiles ?? []}
<View style={[a.flex_1, a.py_md, a.pr_md, !gtPhone && [a.px_md, a.flex_row, a.gap_sm, a.align_start]]}> numPending={profileCount}
<View style={[a.flex_1]}> total={view.list?.listItemCount}
<Card.Card starterPack={view} noIcon /> />
</View>
<Button <View
label={_(msg`Open pack`)} style={[
variant='solid' a.w_full,
color='secondary' a.flex_row,
size='small' a.align_start,
> a.gap_lg,
<ButtonText><Trans>Open pack</Trans></ButtonText> web({
</Button> position: 'static',
</View> 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.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> </View>
</Card.Link> <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 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,
},
]}>
{total ? (
<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">
+{total}
</Trans>
</Text>
) : (
<Plus fill="white" />
)}
</View>
</View>
</View>
</View>
</View> </View>
) )
} }
@@ -1,5 +1,4 @@
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {AppBskyGraphDefs} from '@atproto/api'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@@ -13,90 +12,9 @@ export function useSuggestedStarterPacksQuery() {
return useQuery({ return useQuery({
queryKey: createSuggestedStarterPacksQueryKey(), queryKey: createSuggestedStarterPacksQueryKey(),
async queryFn() { async queryFn() {
// // @ts-expect-error // TODO interests
// const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks() const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks()
// const {starterPacks} = data return data
const {starterPacks} = {starterPacks: [temp]}
return starterPacks as AppBskyGraphDefs.StarterPackViewBasic[]
}, },
}) })
} }
const temp = {
uri: 'at://did:plc:dpajgwmnecpdyjyqzjzm6bnb/app.bsky.graph.starterpack/3l3bhtqkjfo2m',
cid: 'bafyreifvtjkrvuq3qcyz32p6lacdxbwhcmux5amnhokfputfarlpuyqqhu',
record: {
$type: 'app.bsky.graph.starterpack',
createdAt: '2024-09-03T18:44:43.158Z',
feeds: [
{
avatar:
'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreieppnjpayqgjbyqnbeqzwj3pzqtiwbe5a4m6whzqnp3k7gpqs2gai@jpeg',
cid: 'bafyreigreonzn577vy6i4qh2so7aqfjztqrrj4jpssg2jczc27p6x4y6wi',
creator: {
associated: {
chat: {
allowIncoming: 'none',
},
},
avatar:
'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4jdijhsoze@jpeg',
createdAt: '2023-04-12T04:53:57.057Z',
description:
'official Bluesky account (check username👆)\n\nBugs, feature requests, feedback: support@bsky.app',
did: 'did:plc:z72i7hdynmk6r22z27h6tvur',
displayName: 'Bluesky',
handle: 'bsky.app',
indexedAt: '2024-12-18T07:43:57.513Z',
labels: [
{
cts: '2024-05-31T04:01:47.907Z',
src: 'did:plc:z3yk2cflhmn6vmzo3f5ixqh4',
uri: 'did:plc:z72i7hdynmk6r22z27h6tvur',
val: 'cornerstone',
ver: 1,
},
],
viewer: {
blockedBy: false,
muted: false,
},
},
description:
'A mix of popular content from accounts you follow and content that your follows like.',
did: 'did:web:discover.bsky.app',
displayName: 'Popular With Friends',
indexedAt: '2023-05-19T23:19:21.076Z',
labels: [],
likeCount: 33616,
uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends',
viewer: {},
},
],
list: 'at://did:plc:dpajgwmnecpdyjyqzjzm6bnb/app.bsky.graph.list/3l3bhtqgyrn2e',
name: "Tom Sawyeeeeeee's Starter Pack",
updatedAt: '2024-12-19T22:44:58.411Z',
},
creator: {
did: 'did:plc:dpajgwmnecpdyjyqzjzm6bnb',
handle: 'test.esb.lol',
displayName: 'e',
avatar:
'https://cdn.bsky.app/img/avatar/plain/did:plc:dpajgwmnecpdyjyqzjzm6bnb/bafkreia6dx7fhoi6fxwfpgm7jrxijpqci7ap53wpilkpazojwvqlmgud2m@jpeg',
associated: {
chat: {
allowIncoming: 'all',
},
},
viewer: {
muted: false,
blockedBy: false,
},
labels: [],
createdAt: '2023-07-17T15:57:52.014Z',
},
joinedAllTimeCount: 1,
joinedWeekCount: 0,
labels: [],
indexedAt: '2024-12-19T22:45:06.322Z',
}