forked from
jollywhoppers.com/witchsky.app
fork
Configure Feed
Select the types of activity you want to include in your feed.
Bluesky app fork with some witchin' additions 馃挮
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}