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