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