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, Plural, Trans} from '@lingui/macro'
10import {useLingui} from '@lingui/react'
11import {useQueryClient} from '@tanstack/react-query'
12
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 * as Toast from '#/view/com/util/Toast'
23import {UserAvatar} from '#/view/com/util/UserAvatar'
24import {atoms as a, select, useTheme} from '#/alf'
25import {
26 Button,
27 ButtonIcon,
28 type ButtonProps,
29 ButtonText,
30} from '#/components/Button'
31import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
32import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
33import {Link as InternalLink, type LinkProps} from '#/components/Link'
34import {Loader} from '#/components/Loader'
35import * as Prompt from '#/components/Prompt'
36import {RichText, type RichTextProps} from '#/components/RichText'
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 = React.useMemo(() => {
76 return createProfileFeedHref({feed: view})
77 }, [view])
78
79 React.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 = React.useMemo(() => {
215 if (!description) return
216 const rt = new RichTextApi({text: description || ''})
217 rt.detectFacetsWithoutResolution()
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 {_} = 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 = React.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 = React.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(_(msg({message: 'Feeds updated!', context: 'toast'})))
315 } catch (err: any) {
316 logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
317 Toast.show(_(msg`Failed to update feeds`), 'xmark')
318 }
319 },
320 [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
321 )
322
323 const onPrompRemoveFeed = React.useCallback(
324 async (e: GestureResponderEvent) => {
325 e.preventDefault()
326 e.stopPropagation()
327
328 removePromptControl.open()
329 },
330 [removePromptControl],
331 )
332
333 return (
334 <>
335 <Button
336 disabled={isPending}
337 label={_(msg`Add this feed to your feeds`)}
338 size="small"
339 variant="solid"
340 color={savedFeedConfig ? 'secondary' : 'primary'}
341 onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
342 {...buttonProps}>
343 {savedFeedConfig ? (
344 <>
345 {isPending ? (
346 <ButtonIcon size="md" icon={Loader} />
347 ) : (
348 !text && <ButtonIcon size="md" icon={TrashIcon} />
349 )}
350 {text && (
351 <ButtonText>
352 <Trans>Unpin Feed</Trans>
353 </ButtonText>
354 )}
355 </>
356 ) : (
357 <>
358 <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
359 {text && (
360 <ButtonText>
361 <Trans>Pin Feed</Trans>
362 </ButtonText>
363 )}
364 </>
365 )}
366 </Button>
367
368 <Prompt.Basic
369 control={removePromptControl}
370 title={_(msg`Remove from your feeds?`)}
371 description={_(
372 msg`Are you sure you want to remove this from your feeds?`,
373 )}
374 onConfirm={toggleSave}
375 confirmButtonCta={_(msg`Remove`)}
376 confirmButtonColor="negative"
377 />
378 </>
379 )
380}
381
382export function createProfileFeedHref({
383 feed,
384}: {
385 feed: AppBskyFeedDefs.GeneratorView
386}) {
387 const urip = new AtUri(feed.uri)
388 const handleOrDid = feed.creator.handle || feed.creator.did
389 return `/profile/${handleOrDid}/feed/${urip.rkey}`
390}