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