forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}