diff options
author | Eric Bailey <git@esb.lol> | 2024-12-12 11:48:26 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-12 11:48:26 -0600 |
commit | 2808f8b73da4791e0f3cb85b03403738e5bf6b63 (patch) | |
tree | a254debcb1b11c51489aebe3fcd0f91d72049cf9 | |
parent | ffc63dc85fc191a51c3dc12c1afcd250f95036d5 (diff) | |
download | voidsky-2808f8b73da4791e0f3cb85b03403738e5bf6b63.tar.zst |
New profile feed header (#7056)
* Init hacking * Lil baby button checkpoint * Playing around * Revert "Playing around" This reverts commit f58a7fafa12269035d440cfa2d8cb1dbd562305f. * Mostly there * Cleanups * Cleanup * Fix report dialog nesting * Remove transform on native * Rename header * Fix layout, overflowing FAB buttons * Remove hack * Couple of fixes * Keep Pin primary CTA (#7061) * Update src/screens/Profile/components/ProfileFeedHeader.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Simplify, use old string * Wrap Trans better --------- Co-authored-by: dan <dan.abramov@gmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
-rw-r--r-- | assets/icons/pin_filled_stroke2_corner0_rounded.svg | 1 | ||||
-rw-r--r-- | src/Navigation.tsx | 2 | ||||
-rw-r--r-- | src/alf/themes.ts | 4 | ||||
-rw-r--r-- | src/alf/types.ts | 1 | ||||
-rw-r--r-- | src/components/Layout/index.tsx | 4 | ||||
-rw-r--r-- | src/components/icons/Pin.tsx | 4 | ||||
-rw-r--r-- | src/screens/Profile/ProfileFeed/index.tsx | 227 | ||||
-rw-r--r-- | src/screens/Profile/components/ProfileFeedHeader.tsx | 534 | ||||
-rw-r--r-- | src/view/screens/ProfileFeed.tsx | 621 |
9 files changed, 775 insertions, 623 deletions
diff --git a/assets/icons/pin_filled_stroke2_corner0_rounded.svg b/assets/icons/pin_filled_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..a2e71b967 --- /dev/null +++ b/assets/icons/pin_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7.5 2a1 1 0 0 0-1 1v3.997a6.25 6.25 0 0 1-1.83 4.42l-.377.376A1 1 0 0 0 4 12.5V15a1 1 0 0 0 1 1h6v5a1 1 0 1 0 2 0v-5h6a1 1 0 0 0 1-1v-2.5a1 1 0 0 0-.293-.707l-.376-.377a6.25 6.25 0 0 1-1.831-4.42V3.001a1 1 0 0 0-1-1h-9Z"/></svg> diff --git a/src/Navigation.tsx b/src/Navigation.tsx index cf0021526..7443128d2 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -55,7 +55,6 @@ import {NotificationsScreen} from '#/view/screens/Notifications' import {PostThreadScreen} from '#/view/screens/PostThread' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' -import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' @@ -75,6 +74,7 @@ import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostQuotesScreen} from '#/screens/Post/PostQuotes' import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' +import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 0cfe09aad..cb97a7065 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -60,6 +60,7 @@ export function createThemes({ dim: Theme } { const color = { + like: '#ec4899', trueBlack: '#000000', gray_0: `hsl(${hues.primary}, 20%, ${defaultScale[14]}%)`, @@ -124,6 +125,7 @@ export function createThemes({ const lightPalette = { white: color.gray_0, black: color.gray_1000, + like: color.like, contrast_25: color.gray_25, contrast_50: color.gray_50, @@ -185,6 +187,7 @@ export function createThemes({ const darkPalette: Palette = { white: color.gray_25, black: color.trueBlack, + like: color.like, contrast_25: color.gray_975, contrast_50: color.gray_950, @@ -246,6 +249,7 @@ export function createThemes({ const dimPalette: Palette = { ...darkPalette, black: `hsl(${hues.primary}, 28%, ${dimScale[0]}%)`, + like: color.like, contrast_25: `hsl(${hues.primary}, 28%, ${dimScale[1]}%)`, contrast_50: `hsl(${hues.primary}, 28%, ${dimScale[2]}%)`, diff --git a/src/alf/types.ts b/src/alf/types.ts index 08ec59392..5bac690e2 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -12,6 +12,7 @@ export type ThemeName = 'light' | 'dim' | 'dark' export type Palette = { white: string black: string + like: string contrast_25: string contrast_50: string diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index d08505fbf..8532cbbb4 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -21,6 +21,7 @@ export * as Header from '#/components/Layout/Header' export type ScreenProps = React.ComponentProps<typeof View> & { style?: StyleProp<ViewStyle> + noInsetTop?: boolean } /** @@ -28,6 +29,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & { */ export const Screen = React.memo(function Screen({ style, + noInsetTop, ...props }: ScreenProps) { const {top} = useSafeAreaInsets() @@ -35,7 +37,7 @@ export const Screen = React.memo(function Screen({ <> {isWeb && <WebCenterBorders />} <View - style={[a.util_screen_outer, {paddingTop: top}, style]} + style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]} {...props} /> </> diff --git a/src/components/icons/Pin.tsx b/src/components/icons/Pin.tsx index 03dbbac90..d1c37f39a 100644 --- a/src/components/icons/Pin.tsx +++ b/src/components/icons/Pin.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Pin_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z', }) + +export const Pin_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M7.5 2a1 1 0 0 0-1 1v3.997a6.25 6.25 0 0 1-1.83 4.42l-.377.376A1 1 0 0 0 4 12.5V15a1 1 0 0 0 1 1h6v5a1 1 0 1 0 2 0v-5h6a1 1 0 0 0 1-1v-2.5a1 1 0 0 0-.293-.707l-.376-.377a6.25 6.25 0 0 1-1.831-4.42V3.001a1 1 0 0 0-1-1h-9Z', +}) diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx new file mode 100644 index 000000000..7d48b5ac1 --- /dev/null +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -0,0 +1,227 @@ +import React, {useCallback, useMemo} from 'react' +import {StyleSheet, View} from 'react-native' +import {useAnimatedRef} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useIsFocused, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useQueryClient} from '@tanstack/react-query' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useSetTitle} from '#/lib/hooks/useSetTitle' +import {ComposeIcon2} from '#/lib/icons' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {NavigationProp} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {s} from '#/lib/styles' +import {isNative} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import { + usePreferencesQuery, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {truncateAndInvalidate} from '#/state/queries/util' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {PostFeed} from '#/view/com/posts/PostFeed' +import {EmptyState} from '#/view/com/util/EmptyState' +import {FAB} from '#/view/com/util/fab/FAB' +import {Button} from '#/view/com/util/forms/Button' +import {ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' +import {LoadingScreen} from '#/view/com/util/LoadingScreen' +import {Text} from '#/view/com/util/text/Text' +import {ProfileFeedHeader} from '#/screens/Profile/components/ProfileFeedHeader' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> +export function ProfileFeedScreen(props: Props) { + const {rkey, name: handleOrDid} = props.route.params + + const pal = usePalette('default') + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + + const uri = useMemo( + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), + [rkey, handleOrDid], + ) + const {error, data: resolvedUri} = useResolveUriQuery(uri) + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (error) { + return ( + <Layout.Screen testID="profileFeedScreenError"> + <Layout.Content> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + <Trans>Could not load feed</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error.toString()} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel={_(msg`Go back`)} + accessibilityHint={_(msg`Returns to previous page`)} + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + <Trans>Go Back</Trans> + </Text> + </Button> + </View> + </View> + </Layout.Content> + </Layout.Screen> + ) + } + + return resolvedUri ? ( + <Layout.Screen noInsetTop> + <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> + </Layout.Screen> + ) : ( + <Layout.Screen> + <LoadingScreen /> + </Layout.Screen> + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!preferences || !info) { + return <LoadingScreen /> + } + + return ( + <ProfileFeedScreenInner + preferences={preferences} + feedInfo={info as FeedSourceFeedInfo} + /> + ) +} + +export function ProfileFeedScreenInner({ + feedInfo, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo +}) { + const {_} = useLingui() + const {hasSession} = useSession() + const {openComposer} = useComposerControls() + const isScreenFocused = useIsFocused() + + useSetTitle(feedInfo?.displayName) + + const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor + + const [hasNew, setHasNew] = React.useState(false) + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const queryClient = useQueryClient() + const feedFeedback = useFeedFeedback(feed, hasSession) + const scrollElRef = useAnimatedRef() as ListRef + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: 0, // -headerHeight, + }) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, queryClient, feed, setHasNew]) + + React.useEffect(() => { + if (!isScreenFocused) { + return + } + return listenSoftReset(onScrollToTop) + }, [onScrollToTop, isScreenFocused]) + + const renderPostsEmpty = useCallback(() => { + return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> + }, [_]) + + return ( + <> + <ProfileFeedHeader info={feedInfo} /> + + <FeedFeedbackProvider value={feedFeedback}> + <PostFeed + feed={feed} + pollInterval={60e3} + disablePoll={hasNew} + onHasNew={setHasNew} + scrollElRef={scrollElRef} + onScrolledDownChange={setIsScrolledDown} + renderEmptyState={renderPostsEmpty} + /> + </FeedFeedbackProvider> + + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label={_(msg`Load new posts`)} + showIndicator={hasNew} + /> + )} + + {hasSession && ( + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} + </> + ) +} + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, +}) diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx new file mode 100644 index 000000000..0154d535c --- /dev/null +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -0,0 +1,534 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {AtUri} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {makeProfileLink} from '#/lib/routes/links' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {shareUrl} from '#/lib/sharing' +import {sanitizeHandle} from '#/lib/strings/handles' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {FeedSourceFeedInfo} from '#/state/queries/feed' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import { + useAddSavedFeedsMutation, + usePreferencesQuery, + useRemoveFeedMutation, + useUpdateSavedFeedsMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {useRichText} from '#/components/hooks/useRichText' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import { + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, + Heart2_Stroke2_Corner0_Rounded as Heart, +} from '#/components/icons/Heart2' +import { + Pin_Filled_Corner0_Rounded as PinFilled, + Pin_Stroke2_Corner0_Rounded as Pin, +} from '#/components/icons/Pin' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { + const t = useTheme() + const {_, i18n} = useLingui() + const {hasSession} = useSession() + const {gtPhone, gtMobile} = useBreakpoints() + const {top} = useSafeAreaInsets() + const infoControl = Dialog.useDialogControl() + const playHaptic = useHaptics() + + const {data: preferences} = usePreferencesQuery() + + const [likeUri, setLikeUri] = React.useState(info.likeUri || '') + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0 + + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: removeFeed, isPending: isRemovePending} = + useRemoveFeedMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = + useUpdateSavedFeedsMutation() + + const isFeedStateChangePending = + isAddSavedFeedPending || isRemovePending || isUpdateFeedPending + const savedFeedConfig = preferences?.savedFeeds?.find( + f => f.value === info.uri, + ) + const isSaved = Boolean(savedFeedConfig) + const isPinned = Boolean(savedFeedConfig?.pinned) + + const onToggleSaved = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + Toast.show(_(msg`Removed from your feeds`)) + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: false, + }, + ]) + Toast.show(_(msg`Saved to your feeds`)) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue updating your feeds, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to update feeds', {message: err}) + } + }, [_, playHaptic, info, removeFeed, addSavedFeeds, savedFeedConfig]) + + const onTogglePinned = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + const pinned = !savedFeedConfig.pinned + await updateSavedFeeds([ + { + ...savedFeedConfig, + pinned, + }, + ]) + + if (pinned) { + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } else { + Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) + } + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') + logger.error('Failed to toggle pinned feed', {message: e}) + } + }, [playHaptic, info, _, savedFeedConfig, updateSavedFeeds, addSavedFeeds]) + + return ( + <> + <Layout.Center + style={[ + t.atoms.bg, + a.z_10, + {paddingTop: top}, + web([a.sticky, a.z_10, {top: 0}]), + ]}> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content align="left"> + <Button + label={_(msg`Open feed info screen`)} + style={[ + a.justify_start, + { + paddingVertical: 6, + paddingHorizontal: 8, + paddingRight: 12, + }, + ]} + onPress={() => { + playHaptic() + infoControl.open() + }}> + {({hovered, pressed}) => ( + <> + <View + style={[ + a.absolute, + a.inset_0, + a.rounded_sm, + a.transition_transform, + t.atoms.bg_contrast_25, + pressed && t.atoms.bg_contrast_50, + hovered && { + transform: [{scaleX: 1.01}, {scaleY: 1.1}], + }, + ]} + /> + + <View + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> + {info.avatar && ( + <UserAvatar size={32} type="algo" avatar={info.avatar} /> + )} + + <View style={[a.flex_1]}> + <Text + style={[ + a.text_md, + a.font_heavy, + a.leading_tight, + gtMobile && a.text_xl, + ]} + numberOfLines={2}> + {info.displayName} + </Text> + <View style={[a.flex_row, {gap: 6}]}> + <Text + style={[ + a.flex_shrink, + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + gtPhone && a.text_sm, + ]} + numberOfLines={1}> + {sanitizeHandle(info.creatorHandle, '@')} + </Text> + <View style={[a.flex_row, a.align_center, {gap: 2}]}> + <HeartFilled + size="xs" + fill={ + likeUri + ? t.palette.like + : t.atoms.text_contrast_low.color + } + /> + <Text + style={[ + a.text_xs, + a.leading_snug, + t.atoms.text_contrast_medium, + gtPhone && a.text_sm, + ]} + numberOfLines={1}> + {formatCount(i18n, likeCount)} + </Text> + </View> + </View> + </View> + + <ChevronDown + size="md" + fill={t.atoms.text_contrast_low.color} + /> + </View> + </> + )} + </Button> + </Layout.Header.Content> + + {hasSession && ( + <Layout.Header.Slot> + {isPinned ? ( + <Menu.Root> + <Menu.Trigger label={_(msg`Open feed options menu`)}> + {({props}) => { + return ( + <Button + {...props} + label={_(msg`Open feed options menu`)} + size="small" + variant="ghost" + shape="square" + color="secondary"> + <PinFilled size="lg" fill={t.palette.primary_500} /> + </Button> + ) + }} + </Menu.Trigger> + + <Menu.Outer> + <Menu.Item + disabled={isFeedStateChangePending} + label={_(msg`Unpin from home`)} + onPress={onTogglePinned}> + <Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText> + <Menu.ItemIcon icon={X} position="right" /> + </Menu.Item> + <Menu.Item + disabled={isFeedStateChangePending} + label={ + isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`) + } + onPress={onToggleSaved}> + <Menu.ItemText> + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isSaved ? Trash : Plus} + position="right" + /> + </Menu.Item> + </Menu.Outer> + </Menu.Root> + ) : ( + <Button + label={_(msg`Pin to Home`)} + size="small" + variant="ghost" + shape="square" + color="secondary" + onPress={onTogglePinned}> + <ButtonIcon icon={Pin} size="lg" /> + </Button> + )} + </Layout.Header.Slot> + )} + </Layout.Header.Outer> + </Layout.Center> + + <Dialog.Outer control={infoControl}> + <Dialog.Handle /> + <Dialog.ScrollableInner + label={_(msg`Feed menu`)} + style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}> + <DialogInner + info={info} + likeUri={likeUri} + setLikeUri={setLikeUri} + likeCount={likeCount} + isPinned={isPinned} + onTogglePinned={onTogglePinned} + isFeedStateChangePending={isFeedStateChangePending} + /> + </Dialog.ScrollableInner> + </Dialog.Outer> + </> + ) +} + +function DialogInner({ + info, + likeUri, + setLikeUri, + likeCount, + isPinned, + onTogglePinned, + isFeedStateChangePending, +}: { + info: FeedSourceFeedInfo + likeUri: string + setLikeUri: (uri: string) => void + likeCount: number + isPinned: boolean + onTogglePinned: () => void + isFeedStateChangePending: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + const reportDialogControl = useReportDialogControl() + const [rt, loading] = useRichText(info.description.text) + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri]) + + const onToggleLiked = React.useCallback(async () => { + try { + playHaptic() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: info.uri, cid: info.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue contacting the server, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to toggle like', {message: err}) + } + }, [playHaptic, isLiked, likeUri, unlikeFeed, setLikeUri, likeFeed, info, _]) + + const onPressShare = React.useCallback(() => { + playHaptic() + const url = toShareUrl(info.route.href) + shareUrl(url) + }, [info, playHaptic]) + + const onPressReport = React.useCallback(() => { + reportDialogControl.open() + }, [reportDialogControl]) + + return loading ? ( + <Loader size="xl" /> + ) : ( + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.align_center, a.gap_md]}> + <UserAvatar type="algo" size={48} avatar={info.avatar} /> + + <View style={[a.flex_1, a.gap_2xs]}> + <Text + style={[a.text_2xl, a.font_heavy, a.leading_tight]} + numberOfLines={2}> + {info.displayName} + </Text> + <Text + style={[a.text_sm, a.leading_tight, t.atoms.text_contrast_medium]} + numberOfLines={1}> + <Trans> + By{' '} + <InlineLinkText + label={_(msg`View ${info.creatorHandle}'s profile`)} + to={makeProfileLink({ + did: info.creatorDid, + handle: info.creatorHandle, + })} + style={[ + a.text_sm, + a.leading_tight, + a.underline, + t.atoms.text_contrast_medium, + ]} + numberOfLines={1} + onPress={() => control.close()}> + {sanitizeHandle(info.creatorHandle, '@')} + </InlineLinkText> + </Trans> + </Text> + </View> + + <Button + label={_(msg`Share this feed`)} + size="small" + variant="ghost" + color="secondary" + shape="round" + onPress={onPressShare}> + <ButtonIcon icon={Share} size="lg" /> + </Button> + </View> + + <RichText value={rt} style={[a.text_md, a.leading_snug]} /> + + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + {typeof likeCount === 'number' && ( + <InlineLinkText + label={_(msg`View users who like this feed`)} + to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')} + style={[a.underline, t.atoms.text_contrast_medium]} + onPress={() => control.close()}> + <Trans> + Liked by <Plural value={likeCount} one="# user" other="# users" /> + </Trans> + </InlineLinkText> + )} + </View> + + {hasSession && ( + <> + <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}> + <Button + disabled={isLikePending || isUnlikePending} + label={_(msg`Like feed`)} + size="small" + variant="solid" + color="secondary" + onPress={onToggleLiked} + style={[a.flex_1]}> + {isLiked ? ( + <HeartFilled size="sm" fill={t.palette.like} /> + ) : ( + <ButtonIcon icon={Heart} position="left" /> + )} + + <ButtonText> + {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>} + </ButtonText> + </Button> + <Button + disabled={isFeedStateChangePending} + label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)} + size="small" + variant="solid" + color={isPinned ? 'secondary' : 'primary'} + onPress={onTogglePinned} + style={[a.flex_1]}> + <ButtonText> + {isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>} + </ButtonText> + <ButtonIcon icon={Pin} position="right" /> + </Button> + </View> + + <View style={[a.pt_xs, a.gap_lg]}> + <Divider /> + + <View + style={[a.flex_row, a.align_center, a.gap_sm, a.justify_between]}> + <Text style={[a.italic, t.atoms.text_contrast_medium]}> + Something wrong? Let us know. + </Text> + + <Button + label={_(msg`Report feed`)} + size="small" + variant="solid" + color="secondary" + onPress={onPressReport}> + <ButtonText> + <Trans>Report feed</Trans> + </ButtonText> + <ButtonIcon icon={CircleInfo} position="right" /> + </Button> + </View> + + <ReportDialog + control={reportDialogControl} + params={{ + type: 'feedgen', + uri: info.uri, + cid: info.cid, + }} + /> + </View> + </> + )} + </View> + ) +} diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx deleted file mode 100644 index c3f98c067..000000000 --- a/src/view/screens/ProfileFeed.tsx +++ /dev/null @@ -1,621 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useIsFocused, useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useQueryClient} from '@tanstack/react-query' - -import {HITSLOP_20} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import {usePalette} from '#/lib/hooks/usePalette' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {ComposeIcon2} from '#/lib/icons' -import {makeCustomFeedLink} from '#/lib/routes/links' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {makeRecordUri} from '#/lib/strings/url-helpers' -import {toShareUrl} from '#/lib/strings/url-helpers' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' -import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' -import {FeedDescriptor} from '#/state/queries/post-feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import { - useAddSavedFeedsMutation, - usePreferencesQuery, - UsePreferencesQueryResponse, - useRemoveFeedMutation, - useUpdateSavedFeedsMutation, -} from '#/state/queries/preferences' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {truncateAndInvalidate} from '#/state/queries/util' -import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' -import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' -import {PostFeed} from '#/view/com/posts/PostFeed' -import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' -import {EmptyState} from '#/view/com/util/EmptyState' -import {FAB} from '#/view/com/util/fab/FAB' -import {Button} from '#/view/com/util/forms/Button' -import {ListRef} from '#/view/com/util/List' -import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' -import {LoadingScreen} from '#/view/com/util/LoadingScreen' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' -import {Button as NewButton, ButtonText} from '#/components/Button' -import {useRichText} from '#/components/hooks/useRichText' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import { - Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, - Heart2_Stroke2_Corner0_Rounded as HeartOutline, -} from '#/components/icons/Heart2' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import * as Layout from '#/components/Layout' -import {InlineLinkText} from '#/components/Link' -import * as Menu from '#/components/Menu' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' -import {RichText} from '#/components/RichText' - -const SECTION_TITLES = ['Posts'] - -interface SectionRef { - scrollToTop: () => void -} - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> -export function ProfileFeedScreen(props: Props) { - const {rkey, name: handleOrDid} = props.route.params - - const pal = usePalette('default') - const {_} = useLingui() - const navigation = useNavigation<NavigationProp>() - - const uri = useMemo( - () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), - [rkey, handleOrDid], - ) - const {error, data: resolvedUri} = useResolveUriQuery(uri) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (error) { - return ( - <Layout.Screen testID="profileFeedScreenError"> - <Layout.Content> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - <Trans>Could not load feed</Trans> - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error.toString()} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel={_(msg`Go back`)} - accessibilityHint={_(msg`Returns to previous page`)} - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - <Trans>Go Back</Trans> - </Text> - </Button> - </View> - </View> - </Layout.Content> - </Layout.Screen> - ) - } - - return resolvedUri ? ( - <Layout.Screen> - <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> - </Layout.Screen> - ) : ( - <Layout.Screen> - <LoadingScreen /> - </Layout.Screen> - ) -} - -function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { - const {data: preferences} = usePreferencesQuery() - const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) - - if (!preferences || !info) { - return <LoadingScreen /> - } - - return ( - <ProfileFeedScreenInner - preferences={preferences} - feedInfo={info as FeedSourceFeedInfo} - /> - ) -} - -export function ProfileFeedScreenInner({ - preferences, - feedInfo, -}: { - preferences: UsePreferencesQueryResponse - feedInfo: FeedSourceFeedInfo -}) { - const {_} = useLingui() - const t = useTheme() - const {hasSession, currentAccount} = useSession() - const reportDialogControl = useReportDialogControl() - const {openComposer} = useComposerControls() - const playHaptic = useHaptics() - const feedSectionRef = React.useRef<SectionRef>(null) - const isScreenFocused = useIsFocused() - - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = - useAddSavedFeedsMutation() - const {mutateAsync: removeFeed, isPending: isRemovePending} = - useRemoveFeedMutation() - const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = - useUpdateSavedFeedsMutation() - - const isPending = - isAddSavedFeedPending || isRemovePending || isUpdateFeedPending - const savedFeedConfig = preferences.savedFeeds.find( - f => f.value === feedInfo.uri, - ) - const isSaved = Boolean(savedFeedConfig) - const isPinned = Boolean(savedFeedConfig?.pinned) - - useSetTitle(feedInfo?.displayName) - - // event handlers - // - - const onToggleSaved = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await removeFeed(savedFeedConfig) - Toast.show(_(msg`Removed from your feeds`)) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: false, - }, - ]) - Toast.show(_(msg`Saved to your feeds`)) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue updating your feeds, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to update feeds', {message: err}) - } - }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig]) - - const onTogglePinned = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await updateSavedFeeds([ - { - ...savedFeedConfig, - pinned: !savedFeedConfig.pinned, - }, - ]) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: true, - }, - ]) - } - } catch (e) { - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') - logger.error('Failed to toggle pinned feed', {message: e}) - } - }, [ - playHaptic, - feedInfo, - _, - savedFeedConfig, - updateSavedFeeds, - addSavedFeeds, - ]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(feedInfo.route.href) - shareUrl(url) - }, [feedInfo]) - - const onPressReport = React.useCallback(() => { - reportDialogControl.open() - }, [reportDialogControl]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - - const renderHeader = useCallback(() => { - return ( - <> - <ProfileSubpageHeader - isLoading={false} - href={feedInfo.route.href} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo.creatorDid === currentAccount?.did} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - {feedInfo && hasSession && ( - <NewButton - testID={isPinned ? 'unpinBtn' : 'pinBtn'} - disabled={isPending} - size="small" - variant="solid" - color={isPinned ? 'secondary' : 'primary'} - label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)} - onPress={onTogglePinned}> - <ButtonText> - {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} - </ButtonText> - </NewButton> - )} - <Menu.Root> - <Menu.Trigger label={_(msg`Open feed options menu`)}> - {({props, state}) => { - return ( - <Pressable - {...props} - hitSlop={HITSLOP_20} - style={[ - a.justify_center, - a.align_center, - a.rounded_full, - {height: 36, width: 36}, - t.atoms.bg_contrast_25, - (state.hovered || state.pressed) && [ - t.atoms.bg_contrast_50, - ], - ]} - testID="headerDropdownBtn"> - <FontAwesomeIcon - icon="ellipsis" - size={20} - style={t.atoms.text} - /> - </Pressable> - ) - }} - </Menu.Trigger> - - <Menu.Outer> - <Menu.Group> - {hasSession && ( - <> - <Menu.Item - disabled={isPending} - testID="feedHeaderDropdownToggleSavedBtn" - label={ - isSaved - ? _(msg`Remove from my feeds`) - : _(msg`Save to my feeds`) - } - onPress={onToggleSaved}> - <Menu.ItemText> - {isSaved - ? _(msg`Remove from my feeds`) - : _(msg`Save to my feeds`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isSaved ? Trash : Plus} - position="right" - /> - </Menu.Item> - - <Menu.Item - testID="feedHeaderDropdownReportBtn" - label={_(msg`Report feed`)} - onPress={onPressReport}> - <Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText> - <Menu.ItemIcon icon={CircleInfo} position="right" /> - </Menu.Item> - </> - )} - - <Menu.Item - testID="feedHeaderDropdownShareBtn" - label={_(msg`Share feed`)} - onPress={onPressShare}> - <Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - </Menu.Group> - </Menu.Outer> - </Menu.Root> - </View> - </ProfileSubpageHeader> - <AboutSection - feedOwnerDid={feedInfo.creatorDid} - feedRkey={feedInfo.route.params.rkey} - feedInfo={feedInfo} - /> - </> - ) - }, [ - _, - hasSession, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - currentAccount?.did, - isSaved, - onPressReport, - onPressShare, - t, - isPending, - ]) - - return ( - <> - <ReportDialog - control={reportDialogControl} - params={{ - type: 'feedgen', - uri: feedInfo.uri, - cid: feedInfo.cid, - }} - /> - <PagerWithHeader - items={SECTION_TITLES} - isHeaderReady={true} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({headerHeight, scrollElRef, isFocused}) => ( - <FeedSection - ref={feedSectionRef} - feed={`feedgen|${feedInfo.uri}`} - headerHeight={headerHeight} - scrollElRef={scrollElRef as ListRef} - isFocused={isScreenFocused && isFocused} - /> - )} - </PagerWithHeader> - {hasSession && ( - <FAB - testID="composeFAB" - onPress={() => openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - )} - </> - ) -} - -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - scrollElRef: ListRef - isFocused: boolean -} -const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( - function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) { - const {_} = useLingui() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - const queryClient = useQueryClient() - const isScreenFocused = useIsFocused() - const {hasSession} = useSession() - const feedFeedback = useFeedFeedback(feed, hasSession) - - const onScrollToTop = useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - React.useEffect(() => { - if (!isScreenFocused) { - return - } - return listenSoftReset(onScrollToTop) - }, [onScrollToTop, isScreenFocused]) - - const renderPostsEmpty = useCallback(() => { - return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> - }, [_]) - - return ( - <View> - <FeedFeedbackProvider value={feedFeedback}> - <PostFeed - enabled={isFocused} - feed={feed} - pollInterval={60e3} - disablePoll={hasNew} - scrollElRef={scrollElRef} - onHasNew={setHasNew} - onScrolledDownChange={setIsScrolledDown} - renderEmptyState={renderPostsEmpty} - headerOffset={headerHeight} - /> - </FeedFeedbackProvider> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onScrollToTop} - label={_(msg`Load new posts`)} - showIndicator={hasNew} - /> - )} - </View> - ) - }, -) - -function AboutSection({ - feedOwnerDid, - feedRkey, - feedInfo, -}: { - feedOwnerDid: string - feedRkey: string - feedInfo: FeedSourceFeedInfo -}) { - const t = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) - const {hasSession} = useSession() - const playHaptic = useHaptics() - const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() - const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = - useUnlikeMutation() - const [resolvedRT] = useRichText(feedInfo.description.text || '') - - const isLiked = !!likeUri - const likeCount = - isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount - - const onToggleLiked = React.useCallback(async () => { - try { - playHaptic() - - if (isLiked && likeUri) { - await unlikeFeed({uri: likeUri}) - setLikeUri('') - } else { - const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) - setLikeUri(res.uri) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue contacting the server, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to toggle like', {message: err}) - } - }, [playHaptic, isLiked, likeUri, unlikeFeed, likeFeed, feedInfo, _]) - - return ( - <View style={[styles.aboutSectionContainer]}> - <View style={[a.pt_sm]}> - {feedInfo.description ? ( - <RichText - testID="listDescription" - style={[a.text_md]} - value={resolvedRT ?? feedInfo.description} - /> - ) : ( - <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - <Trans>No description</Trans> - </Text> - )} - </View> - - <View style={[a.flex_row, a.gap_sm, a.align_center, a.pb_sm]}> - <NewButton - size="small" - variant="solid" - color="secondary" - shape="round" - label={isLiked ? _(msg`Unlike this feed`) : _(msg`Like this feed`)} - testID="toggleLikeBtn" - disabled={!hasSession || isLikePending || isUnlikePending} - onPress={onToggleLiked}> - {isLiked ? ( - <HeartFilled size="md" fill={s.likeColor.color} /> - ) : ( - <HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} /> - )} - </NewButton> - {typeof likeCount === 'number' && ( - <InlineLinkText - label={_(msg`View users who like this feed`)} - to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - style={[t.atoms.text_contrast_medium, a.font_bold]}> - <Trans> - Liked by <Plural value={likeCount} one="# user" other="# users" /> - </Trans> - </InlineLinkText> - )} - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 7, - paddingHorizontal: 14, - borderRadius: 50, - marginLeft: 6, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, - aboutSectionContainer: { - paddingVertical: 4, - paddingHorizontal: 16, - gap: 12, - }, -}) |