forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {View} from 'react-native'
3import {AtUri} from '@atproto/api'
4import {msg, Plural, Trans} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6
7import {useHaptics} from '#/lib/haptics'
8import {makeProfileLink} from '#/lib/routes/links'
9import {makeCustomFeedLink} from '#/lib/routes/links'
10import {shareUrl} from '#/lib/sharing'
11import {sanitizeHandle} from '#/lib/strings/handles'
12import {toShareUrl} from '#/lib/strings/url-helpers'
13import {logger} from '#/logger'
14import {isWeb} from '#/platform/detection'
15import {type FeedSourceFeedInfo} from '#/state/queries/feed'
16import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
17import {
18 useAddSavedFeedsMutation,
19 usePreferencesQuery,
20 useRemoveFeedMutation,
21 useUpdateSavedFeedsMutation,
22} from '#/state/queries/preferences'
23import {useSession} from '#/state/session'
24import {formatCount} from '#/view/com/util/numeric/format'
25import * as Toast from '#/view/com/util/Toast'
26import {UserAvatar} from '#/view/com/util/UserAvatar'
27import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
28import {Button, ButtonIcon, ButtonText} from '#/components/Button'
29import * as Dialog from '#/components/Dialog'
30import {Divider} from '#/components/Divider'
31import {useRichText} from '#/components/hooks/useRichText'
32import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
33import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
34import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
35import {
36 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
37 Heart2_Stroke2_Corner0_Rounded as Heart,
38} from '#/components/icons/Heart2'
39import {
40 Pin_Filled_Corner0_Rounded as PinFilled,
41 Pin_Stroke2_Corner0_Rounded as Pin,
42} from '#/components/icons/Pin'
43import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
44import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
45import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
46import * as Layout from '#/components/Layout'
47import {InlineLinkText} from '#/components/Link'
48import * as Menu from '#/components/Menu'
49import {
50 ReportDialog,
51 useReportDialogControl,
52} from '#/components/moderation/ReportDialog'
53import {RichText} from '#/components/RichText'
54import {Text} from '#/components/Typography'
55
56export function ProfileFeedHeaderSkeleton() {
57 const t = useTheme()
58
59 return (
60 <Layout.Header.Outer>
61 <Layout.Header.BackButton />
62 <Layout.Header.Content>
63 <View
64 style={[a.w_full, a.rounded_sm, t.atoms.bg_contrast_25, {height: 40}]}
65 />
66 </Layout.Header.Content>
67 <Layout.Header.Slot>
68 <View
69 style={[
70 a.justify_center,
71 a.align_center,
72 a.rounded_full,
73 t.atoms.bg_contrast_25,
74 {
75 height: 34,
76 width: 34,
77 },
78 ]}>
79 <Pin size="lg" fill={t.atoms.text_contrast_low.color} />
80 </View>
81 </Layout.Header.Slot>
82 </Layout.Header.Outer>
83 )
84}
85
86export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
87 const t = useTheme()
88 const {_, i18n} = useLingui()
89 const {hasSession} = useSession()
90 const {gtMobile} = useBreakpoints()
91 const infoControl = Dialog.useDialogControl()
92 const playHaptic = useHaptics()
93
94 const {data: preferences} = usePreferencesQuery()
95
96 const [likeUri, setLikeUri] = React.useState(info.likeUri || '')
97 const likeCount =
98 (info.likeCount || 0) +
99 (likeUri && !info.likeUri ? 1 : !likeUri && info.likeUri ? -1 : 0)
100
101 const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
102 useAddSavedFeedsMutation()
103 const {mutateAsync: removeFeed, isPending: isRemovePending} =
104 useRemoveFeedMutation()
105 const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} =
106 useUpdateSavedFeedsMutation()
107
108 const isFeedStateChangePending =
109 isAddSavedFeedPending || isRemovePending || isUpdateFeedPending
110 const savedFeedConfig = preferences?.savedFeeds?.find(
111 f => f.value === info.uri,
112 )
113 const isSaved = Boolean(savedFeedConfig)
114 const isPinned = Boolean(savedFeedConfig?.pinned)
115
116 const onToggleSaved = async () => {
117 try {
118 playHaptic()
119
120 if (savedFeedConfig) {
121 await removeFeed(savedFeedConfig)
122 Toast.show(_(msg`Removed from your feeds`))
123 logger.metric('feed:unsave', {feedUrl: info.uri})
124 } else {
125 await addSavedFeeds([
126 {
127 type: 'feed',
128 value: info.uri,
129 pinned: false,
130 },
131 ])
132 Toast.show(_(msg`Saved to your feeds`))
133 logger.metric('feed:save', {feedUrl: info.uri})
134 }
135 } catch (err) {
136 Toast.show(
137 _(
138 msg`There was an issue updating your feeds, please check your internet connection and try again.`,
139 ),
140 'xmark',
141 )
142 logger.error('Failed to update feeds', {message: err})
143 }
144 }
145
146 const onTogglePinned = async () => {
147 try {
148 playHaptic()
149
150 if (savedFeedConfig) {
151 const pinned = !savedFeedConfig.pinned
152 await updateSavedFeeds([
153 {
154 ...savedFeedConfig,
155 pinned,
156 },
157 ])
158
159 if (pinned) {
160 Toast.show(_(msg`Pinned ${info.displayName} to Home`))
161 logger.metric('feed:pin', {feedUrl: info.uri})
162 } else {
163 Toast.show(_(msg`Unpinned ${info.displayName} from Home`))
164 logger.metric('feed:unpin', {feedUrl: info.uri})
165 }
166 } else {
167 await addSavedFeeds([
168 {
169 type: 'feed',
170 value: info.uri,
171 pinned: true,
172 },
173 ])
174 Toast.show(_(msg`Pinned ${info.displayName} to Home`))
175 logger.metric('feed:pin', {feedUrl: info.uri})
176 }
177 } catch (e) {
178 Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
179 logger.error('Failed to toggle pinned feed', {message: e})
180 }
181 }
182
183 return (
184 <>
185 <Layout.Center
186 style={[t.atoms.bg, a.z_10, web([a.sticky, a.z_10, {top: 0}])]}>
187 <Layout.Header.Outer>
188 <Layout.Header.BackButton />
189 <Layout.Header.Content align="left">
190 <Button
191 label={_(msg`Open feed info screen`)}
192 style={[
193 a.justify_start,
194 {
195 paddingVertical: isWeb ? 2 : 4,
196 paddingRight: 8,
197 },
198 ]}
199 onPress={() => {
200 playHaptic()
201 infoControl.open()
202 }}>
203 {({hovered, pressed}) => (
204 <>
205 <View
206 style={[
207 a.absolute,
208 a.inset_0,
209 a.rounded_sm,
210 a.transition_all,
211 t.atoms.bg_contrast_25,
212 {
213 opacity: 0,
214 left: isWeb ? -2 : -4,
215 right: 0,
216 },
217 pressed && {
218 opacity: 1,
219 },
220 hovered && {
221 opacity: 1,
222 transform: [{scaleX: 1.01}, {scaleY: 1.1}],
223 },
224 ]}
225 />
226
227 <View
228 style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
229 {info.avatar && (
230 <UserAvatar size={36} type="algo" avatar={info.avatar} />
231 )}
232
233 <View style={[a.flex_1]}>
234 <Text
235 style={[
236 a.text_md,
237 a.font_bold,
238 a.leading_snug,
239 gtMobile && a.text_lg,
240 ]}
241 numberOfLines={2}
242 emoji>
243 {info.displayName}
244 </Text>
245 <View style={[a.flex_row, {gap: 6}]}>
246 <Text
247 style={[
248 a.flex_shrink,
249 a.text_sm,
250 a.leading_snug,
251 t.atoms.text_contrast_medium,
252 ]}
253 numberOfLines={1}>
254 {sanitizeHandle(info.creatorHandle, '@')}
255 </Text>
256 <View style={[a.flex_row, a.align_center, {gap: 2}]}>
257 <HeartFilled
258 size="xs"
259 fill={
260 likeUri
261 ? t.palette.like
262 : t.atoms.text_contrast_low.color
263 }
264 />
265 <Text
266 style={[
267 a.text_sm,
268 a.leading_snug,
269 t.atoms.text_contrast_medium,
270 ]}
271 numberOfLines={1}>
272 {formatCount(i18n, likeCount)}
273 </Text>
274 </View>
275 </View>
276 </View>
277
278 <Ellipsis
279 size="md"
280 fill={t.atoms.text_contrast_low.color}
281 />
282 </View>
283 </>
284 )}
285 </Button>
286 </Layout.Header.Content>
287
288 {hasSession && (
289 <Layout.Header.Slot>
290 {isPinned ? (
291 <Menu.Root>
292 <Menu.Trigger label={_(msg`Open feed options menu`)}>
293 {({props}) => {
294 return (
295 <Button
296 {...props}
297 label={_(msg`Open feed options menu`)}
298 size="small"
299 variant="ghost"
300 shape="square"
301 color="secondary">
302 <PinFilled size="lg" fill={t.palette.primary_500} />
303 </Button>
304 )
305 }}
306 </Menu.Trigger>
307
308 <Menu.Outer>
309 <Menu.Item
310 disabled={isFeedStateChangePending}
311 label={_(msg`Unpin from home`)}
312 onPress={onTogglePinned}>
313 <Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText>
314 <Menu.ItemIcon icon={X} position="right" />
315 </Menu.Item>
316 <Menu.Item
317 disabled={isFeedStateChangePending}
318 label={
319 isSaved
320 ? _(msg`Remove from my feeds`)
321 : _(msg`Save to my feeds`)
322 }
323 onPress={onToggleSaved}>
324 <Menu.ItemText>
325 {isSaved
326 ? _(msg`Remove from my feeds`)
327 : _(msg`Save to my feeds`)}
328 </Menu.ItemText>
329 <Menu.ItemIcon
330 icon={isSaved ? Trash : Plus}
331 position="right"
332 />
333 </Menu.Item>
334 </Menu.Outer>
335 </Menu.Root>
336 ) : (
337 <Button
338 label={_(msg`Pin to Home`)}
339 size="small"
340 variant="ghost"
341 shape="square"
342 color="secondary"
343 onPress={onTogglePinned}>
344 <ButtonIcon icon={Pin} size="lg" />
345 </Button>
346 )}
347 </Layout.Header.Slot>
348 )}
349 </Layout.Header.Outer>
350 </Layout.Center>
351
352 <Dialog.Outer control={infoControl}>
353 <Dialog.Handle />
354 <Dialog.ScrollableInner
355 label={_(msg`Feed menu`)}
356 style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
357 <DialogInner
358 info={info}
359 likeUri={likeUri}
360 setLikeUri={setLikeUri}
361 likeCount={likeCount}
362 isPinned={isPinned}
363 onTogglePinned={onTogglePinned}
364 isFeedStateChangePending={isFeedStateChangePending}
365 />
366 </Dialog.ScrollableInner>
367 </Dialog.Outer>
368 </>
369 )
370}
371
372function DialogInner({
373 info,
374 likeUri,
375 setLikeUri,
376 likeCount,
377 isPinned,
378 onTogglePinned,
379 isFeedStateChangePending,
380}: {
381 info: FeedSourceFeedInfo
382 likeUri: string
383 setLikeUri: (uri: string) => void
384 likeCount: number
385 isPinned: boolean
386 onTogglePinned: () => void
387 isFeedStateChangePending: boolean
388}) {
389 const t = useTheme()
390 const {_} = useLingui()
391 const {hasSession} = useSession()
392 const playHaptic = useHaptics()
393 const control = Dialog.useDialogContext()
394 const reportDialogControl = useReportDialogControl()
395 const [rt] = useRichText(info.description.text)
396 const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
397 const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
398 useUnlikeMutation()
399
400 const isLiked = !!likeUri
401 const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri])
402
403 const onToggleLiked = async () => {
404 try {
405 playHaptic()
406
407 if (isLiked && likeUri) {
408 await unlikeFeed({uri: likeUri})
409 setLikeUri('')
410 logger.metric('feed:unlike', {feedUrl: info.uri})
411 } else {
412 const res = await likeFeed({uri: info.uri, cid: info.cid})
413 setLikeUri(res.uri)
414 logger.metric('feed:like', {feedUrl: info.uri})
415 }
416 } catch (err) {
417 Toast.show(
418 _(
419 msg`There was an issue contacting the server, please check your internet connection and try again.`,
420 ),
421 'xmark',
422 )
423 logger.error('Failed to toggle like', {message: err})
424 }
425 }
426
427 const onPressShare = React.useCallback(() => {
428 playHaptic()
429 const url = toShareUrl(info.route.href)
430 shareUrl(url)
431 logger.metric('feed:share', {feedUrl: info.uri})
432 }, [info, playHaptic])
433
434 const onPressReport = React.useCallback(() => {
435 reportDialogControl.open()
436 }, [reportDialogControl])
437
438 return (
439 <View style={[a.gap_md]}>
440 <View style={[a.flex_row, a.align_center, a.gap_md]}>
441 <UserAvatar type="algo" size={48} avatar={info.avatar} />
442
443 <View style={[a.flex_1, a.gap_2xs]}>
444 <Text
445 style={[a.text_2xl, a.font_bold, a.leading_tight]}
446 numberOfLines={2}
447 emoji>
448 {info.displayName}
449 </Text>
450 <Text
451 style={[a.text_sm, a.leading_relaxed, t.atoms.text_contrast_medium]}
452 numberOfLines={1}>
453 <Trans>
454 By{' '}
455 <InlineLinkText
456 label={_(msg`View ${info.creatorHandle}'s profile`)}
457 to={makeProfileLink({
458 did: info.creatorDid,
459 handle: info.creatorHandle,
460 })}
461 style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}
462 numberOfLines={1}
463 onPress={() => control.close()}>
464 {sanitizeHandle(info.creatorHandle, '@')}
465 </InlineLinkText>
466 </Trans>
467 </Text>
468 </View>
469
470 <Button
471 label={_(msg`Share this feed`)}
472 size="small"
473 variant="ghost"
474 color="secondary"
475 shape="round"
476 onPress={onPressShare}>
477 <ButtonIcon icon={Share} size="lg" />
478 </Button>
479 </View>
480
481 <RichText value={rt} style={[a.text_md]} />
482
483 <View style={[a.flex_row, a.gap_sm, a.align_center]}>
484 {typeof likeCount === 'number' && (
485 <InlineLinkText
486 label={_(msg`View users who like this feed`)}
487 to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
488 style={[a.underline, t.atoms.text_contrast_medium]}
489 onPress={() => control.close()}>
490 <Trans>
491 Liked by <Plural value={likeCount} one="# user" other="# users" />
492 </Trans>
493 </InlineLinkText>
494 )}
495 </View>
496
497 {hasSession && (
498 <>
499 <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
500 <Button
501 disabled={isLikePending || isUnlikePending}
502 label={_(msg`Like this feed`)}
503 size="small"
504 variant="solid"
505 color="secondary"
506 onPress={onToggleLiked}
507 style={[a.flex_1]}>
508 {isLiked ? (
509 <HeartFilled size="sm" fill={t.palette.like} />
510 ) : (
511 <ButtonIcon icon={Heart} position="left" />
512 )}
513
514 <ButtonText>
515 {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
516 </ButtonText>
517 </Button>
518 <Button
519 disabled={isFeedStateChangePending}
520 label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
521 size="small"
522 variant="solid"
523 color={isPinned ? 'secondary' : 'primary'}
524 onPress={onTogglePinned}
525 style={[a.flex_1]}>
526 <ButtonText>
527 {isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>}
528 </ButtonText>
529 <ButtonIcon icon={Pin} position="right" />
530 </Button>
531 </View>
532
533 <View style={[a.pt_xs, a.gap_lg]}>
534 <Divider />
535
536 <View
537 style={[a.flex_row, a.align_center, a.gap_sm, a.justify_between]}>
538 <Text style={[a.italic, t.atoms.text_contrast_medium]}>
539 <Trans>Something wrong? Let us know.</Trans>
540 </Text>
541
542 <Button
543 label={_(msg`Report feed`)}
544 size="small"
545 variant="solid"
546 color="secondary"
547 onPress={onPressReport}>
548 <ButtonText>
549 <Trans>Report feed</Trans>
550 </ButtonText>
551 <ButtonIcon icon={CircleInfo} position="right" />
552 </Button>
553 </View>
554
555 {info.view && (
556 <ReportDialog
557 control={reportDialogControl}
558 subject={{
559 ...info.view,
560 $type: 'app.bsky.feed.defs#generatorView',
561 }}
562 />
563 )}
564 </View>
565 </>
566 )}
567 </View>
568 )
569}