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} 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}