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