Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useCallback, useEffect, useMemo} from 'react'
2import {type GestureResponderEvent, View} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 type AppBskyGraphDefs,
6 AtUri,
7 RichText as RichTextApi,
8} from '@atproto/api'
9import {Plural, Trans, useLingui} from '@lingui/react/macro'
10import {useQueryClient} from '@tanstack/react-query'
11
12import {sanitizeHandle} from '#/lib/strings/handles'
13import {logger} from '#/logger'
14import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
15import {
16 useAddSavedFeedsMutation,
17 usePreferencesQuery,
18 useRemoveFeedMutation,
19} from '#/state/queries/preferences'
20import {useSession} from '#/state/session'
21import * as Toast from '#/view/com/util/Toast'
22import {UserAvatar} from '#/view/com/util/UserAvatar'
23import {atoms as a, select, useTheme} from '#/alf'
24import {
25 Button,
26 ButtonIcon,
27 type ButtonProps,
28 ButtonText,
29} from '#/components/Button'
30import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
31import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
32import {Link as InternalLink, type LinkProps} from '#/components/Link'
33import {Loader} from '#/components/Loader'
34import * as Prompt from '#/components/Prompt'
35import {RichText, type RichTextProps} from '#/components/RichText'
36import {Text} from '#/components/Typography'
37import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
38import type * as bsky from '#/types/bsky'
39import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
40
41type Props = {
42 view: AppBskyFeedDefs.GeneratorView
43 onPress?: () => void
44}
45
46export function Default(props: Props) {
47 const {view} = props
48 return (
49 <Link {...props}>
50 <Outer>
51 <Header>
52 <Avatar src={view.avatar} />
53 <TitleAndByline
54 title={view.displayName}
55 creator={view.creator}
56 uri={view.uri}
57 />
58 <SaveButton view={view} pin />
59 </Header>
60 <Description description={view.description} />
61 <Likes count={view.likeCount || 0} />
62 </Outer>
63 </Link>
64 )
65}
66
67export function Link({
68 view,
69 children,
70 ...props
71}: Props & Omit<LinkProps, 'to' | 'label'>) {
72 const queryClient = useQueryClient()
73
74 const href = useMemo(() => {
75 return createProfileFeedHref({feed: view})
76 }, [view])
77
78 useEffect(() => {
79 precacheFeedFromGeneratorView(queryClient, view)
80 }, [view, queryClient])
81
82 return (
83 <InternalLink
84 label={view.displayName}
85 to={href}
86 style={[a.flex_col]}
87 {...props}>
88 {children}
89 </InternalLink>
90 )
91}
92
93export function Outer({children}: {children: React.ReactNode}) {
94 return <View style={[a.w_full, a.gap_sm]}>{children}</View>
95}
96
97export function Header({children}: {children: React.ReactNode}) {
98 return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
99}
100
101export type AvatarProps = {src: string | undefined; size?: number}
102
103export function Avatar({src, size = 40}: AvatarProps) {
104 return <UserAvatar type="algo" size={size} avatar={src} />
105}
106
107export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
108 const t = useTheme()
109 return (
110 <View
111 style={[
112 t.atoms.bg_contrast_25,
113 {
114 width: size,
115 height: size,
116 borderRadius: 8,
117 },
118 ]}
119 />
120 )
121}
122
123export function TitleAndByline({
124 title,
125 creator,
126 uri,
127}: {
128 title: string
129 creator?: bsky.profile.AnyProfileView
130 uri?: string
131}) {
132 const t = useTheme()
133 const activeLiveEvents = useActiveLiveEventFeedUris()
134 const liveColor = useMemo(
135 () =>
136 select(t.name, {
137 dark: t.palette.negative_600,
138 dim: t.palette.negative_600,
139 light: t.palette.negative_500,
140 }),
141 [t],
142 )
143
144 return (
145 <View style={[a.flex_1]}>
146 {uri && activeLiveEvents.has(uri) && (
147 <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
148 <LiveIcon size="xs" fill={liveColor} />
149 <Text
150 style={[
151 a.text_2xs,
152 a.font_medium,
153 a.leading_snug,
154 {color: liveColor},
155 ]}>
156 <Trans>Happening now</Trans>
157 </Text>
158 </View>
159 )}
160 <Text
161 emoji
162 style={[a.text_md, a.font_semi_bold, a.leading_snug]}
163 numberOfLines={1}>
164 {title}
165 </Text>
166 {creator && (
167 <Text
168 style={[a.leading_snug, t.atoms.text_contrast_medium]}
169 numberOfLines={1}>
170 <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
171 </Text>
172 )}
173 </View>
174 )
175}
176
177export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
178 const t = useTheme()
179
180 return (
181 <View style={[a.flex_1, a.gap_xs]}>
182 <View
183 style={[
184 a.rounded_xs,
185 t.atoms.bg_contrast_50,
186 {
187 width: '60%',
188 height: 14,
189 },
190 ]}
191 />
192
193 {creator && (
194 <View
195 style={[
196 a.rounded_xs,
197 t.atoms.bg_contrast_25,
198 {
199 width: '40%',
200 height: 10,
201 },
202 ]}
203 />
204 )}
205 </View>
206 )
207}
208
209export function Description({
210 description,
211 ...rest
212}: {description?: string} & Partial<RichTextProps>) {
213 const rt = useMemo(() => {
214 if (!description) return
215 const rt = new RichTextApi({text: description || ''})
216 rt.detectFacetsWithoutResolution()
217 return rt
218 }, [description])
219 if (!rt) return null
220 return <RichText value={rt} disableLinks {...rest} />
221}
222
223export function DescriptionPlaceholder() {
224 const t = useTheme()
225 return (
226 <View style={[a.gap_xs]}>
227 <View
228 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
229 />
230 <View
231 style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
232 />
233 <View
234 style={[
235 a.rounded_xs,
236 a.w_full,
237 t.atoms.bg_contrast_50,
238 {height: 12, width: 100},
239 ]}
240 />
241 </View>
242 )
243}
244
245export function Likes({count}: {count: number}) {
246 const t = useTheme()
247 return (
248 <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_semi_bold]}>
249 <Trans>
250 Liked by <Plural value={count || 0} one="# user" other="# users" />
251 </Trans>
252 </Text>
253 )
254}
255
256export function SaveButton({
257 view,
258 pin,
259 ...props
260}: {
261 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
262 pin?: boolean
263 text?: boolean
264} & Partial<ButtonProps>) {
265 const {hasSession} = useSession()
266 if (!hasSession) return null
267 return <SaveButtonInner view={view} pin={pin} {...props} />
268}
269
270function SaveButtonInner({
271 view,
272 pin,
273 text = true,
274 ...buttonProps
275}: {
276 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
277 pin?: boolean
278 text?: boolean
279} & Partial<ButtonProps>) {
280 const {t: l} = useLingui()
281 const {data: preferences} = usePreferencesQuery()
282 const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
283 useAddSavedFeedsMutation()
284 const {isPending: isRemovePending, mutateAsync: removeFeed} =
285 useRemoveFeedMutation()
286
287 const uri = view.uri
288 const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
289
290 const savedFeedConfig = useMemo(() => {
291 return preferences?.savedFeeds?.find(feed => feed.value === uri)
292 }, [preferences?.savedFeeds, uri])
293 const removePromptControl = Prompt.usePromptControl()
294 const isPending = isAddSavedFeedPending || isRemovePending
295
296 const toggleSave = useCallback(
297 async (e: GestureResponderEvent) => {
298 e.preventDefault()
299 e.stopPropagation()
300
301 try {
302 if (savedFeedConfig) {
303 await removeFeed(savedFeedConfig)
304 } else {
305 await saveFeeds([
306 {
307 type,
308 value: uri,
309 pinned: pin || false,
310 },
311 ])
312 }
313 Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
314 } catch (err: any) {
315 logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
316 Toast.show(l`Failed to update feeds`, 'xmark')
317 }
318 },
319 [l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
320 )
321
322 const onPromptRemoveFeed = useCallback(
323 (e: GestureResponderEvent) => {
324 e.preventDefault()
325 e.stopPropagation()
326
327 removePromptControl.open()
328 },
329 [removePromptControl],
330 )
331
332 return (
333 <>
334 <Button
335 disabled={isPending}
336 label={l`Add this feed to your feeds`}
337 size="small"
338 variant="solid"
339 color={savedFeedConfig ? 'secondary' : 'primary'}
340 onPress={(e: GestureResponderEvent) =>
341 savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e)
342 }
343 {...buttonProps}>
344 {savedFeedConfig ? (
345 <>
346 {isPending ? (
347 <ButtonIcon size="md" icon={Loader} />
348 ) : (
349 !text && <ButtonIcon size="md" icon={TrashIcon} />
350 )}
351 {text && (
352 <ButtonText>
353 <Trans>Unpin feed</Trans>
354 </ButtonText>
355 )}
356 </>
357 ) : (
358 <>
359 <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
360 {text && (
361 <ButtonText>
362 <Trans>Pin feed</Trans>
363 </ButtonText>
364 )}
365 </>
366 )}
367 </Button>
368
369 <Prompt.Basic
370 control={removePromptControl}
371 title={l`Remove from your feeds?`}
372 description={l`Are you sure you want to remove this from your feeds?`}
373 onConfirm={(e: GestureResponderEvent) => void toggleSave(e)}
374 confirmButtonCta={l`Remove`}
375 confirmButtonColor="negative"
376 />
377 </>
378 )
379}
380
381export function createProfileFeedHref({
382 feed,
383}: {
384 feed: AppBskyFeedDefs.GeneratorView
385}) {
386 const urip = new AtUri(feed.uri)
387 const handleOrDid = feed.creator.handle || feed.creator.did
388 return `/profile/${handleOrDid}/feed/${urip.rkey}`
389}