Bluesky app fork with some witchin' additions 💫

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>

authored by

Eric Bailey
surfdude29
dan
and committed by
GitHub
2808f8b7 ffc63dc8

+775 -623
+1
assets/icons/pin_filled_stroke2_corner0_rounded.svg
··· 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>
+1 -1
src/Navigation.tsx
··· 55 55 import {PostThreadScreen} from '#/view/screens/PostThread' 56 56 import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' 57 57 import {ProfileScreen} from '#/view/screens/Profile' 58 - import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' 59 58 import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 60 59 import {ProfileListScreen} from '#/view/screens/ProfileList' 61 60 import {SavedFeeds} from '#/view/screens/SavedFeeds' ··· 75 74 import {PostQuotesScreen} from '#/screens/Post/PostQuotes' 76 75 import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' 77 76 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 77 + import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' 78 78 import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' 79 79 import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' 80 80 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
+4
src/alf/themes.ts
··· 60 60 dim: Theme 61 61 } { 62 62 const color = { 63 + like: '#ec4899', 63 64 trueBlack: '#000000', 64 65 65 66 gray_0: `hsl(${hues.primary}, 20%, ${defaultScale[14]}%)`, ··· 124 125 const lightPalette = { 125 126 white: color.gray_0, 126 127 black: color.gray_1000, 128 + like: color.like, 127 129 128 130 contrast_25: color.gray_25, 129 131 contrast_50: color.gray_50, ··· 185 187 const darkPalette: Palette = { 186 188 white: color.gray_25, 187 189 black: color.trueBlack, 190 + like: color.like, 188 191 189 192 contrast_25: color.gray_975, 190 193 contrast_50: color.gray_950, ··· 246 249 const dimPalette: Palette = { 247 250 ...darkPalette, 248 251 black: `hsl(${hues.primary}, 28%, ${dimScale[0]}%)`, 252 + like: color.like, 249 253 250 254 contrast_25: `hsl(${hues.primary}, 28%, ${dimScale[1]}%)`, 251 255 contrast_50: `hsl(${hues.primary}, 28%, ${dimScale[2]}%)`,
+1
src/alf/types.ts
··· 12 12 export type Palette = { 13 13 white: string 14 14 black: string 15 + like: string 15 16 16 17 contrast_25: string 17 18 contrast_50: string
+3 -1
src/components/Layout/index.tsx
··· 21 21 22 22 export type ScreenProps = React.ComponentProps<typeof View> & { 23 23 style?: StyleProp<ViewStyle> 24 + noInsetTop?: boolean 24 25 } 25 26 26 27 /** ··· 28 29 */ 29 30 export const Screen = React.memo(function Screen({ 30 31 style, 32 + noInsetTop, 31 33 ...props 32 34 }: ScreenProps) { 33 35 const {top} = useSafeAreaInsets() ··· 35 37 <> 36 38 {isWeb && <WebCenterBorders />} 37 39 <View 38 - style={[a.util_screen_outer, {paddingTop: top}, style]} 40 + style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]} 39 41 {...props} 40 42 /> 41 43 </>
+4
src/components/icons/Pin.tsx
··· 3 3 export const Pin_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 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', 5 5 }) 6 + 7 + export const Pin_Filled_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+227
src/screens/Profile/ProfileFeed/index.tsx
··· 1 + import React, {useCallback, useMemo} from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {useAnimatedRef} from 'react-native-reanimated' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useIsFocused, useNavigation} from '@react-navigation/native' 7 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 + import {useQueryClient} from '@tanstack/react-query' 9 + 10 + import {usePalette} from '#/lib/hooks/usePalette' 11 + import {useSetTitle} from '#/lib/hooks/useSetTitle' 12 + import {ComposeIcon2} from '#/lib/icons' 13 + import {CommonNavigatorParams} from '#/lib/routes/types' 14 + import {NavigationProp} from '#/lib/routes/types' 15 + import {makeRecordUri} from '#/lib/strings/url-helpers' 16 + import {s} from '#/lib/styles' 17 + import {isNative} from '#/platform/detection' 18 + import {listenSoftReset} from '#/state/events' 19 + import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 20 + import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 21 + import {FeedDescriptor} from '#/state/queries/post-feed' 22 + import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 23 + import { 24 + usePreferencesQuery, 25 + UsePreferencesQueryResponse, 26 + } from '#/state/queries/preferences' 27 + import {useResolveUriQuery} from '#/state/queries/resolve-uri' 28 + import {truncateAndInvalidate} from '#/state/queries/util' 29 + import {useSession} from '#/state/session' 30 + import {useComposerControls} from '#/state/shell/composer' 31 + import {PostFeed} from '#/view/com/posts/PostFeed' 32 + import {EmptyState} from '#/view/com/util/EmptyState' 33 + import {FAB} from '#/view/com/util/fab/FAB' 34 + import {Button} from '#/view/com/util/forms/Button' 35 + import {ListRef} from '#/view/com/util/List' 36 + import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 37 + import {LoadingScreen} from '#/view/com/util/LoadingScreen' 38 + import {Text} from '#/view/com/util/text/Text' 39 + import {ProfileFeedHeader} from '#/screens/Profile/components/ProfileFeedHeader' 40 + import * as Layout from '#/components/Layout' 41 + 42 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> 43 + export function ProfileFeedScreen(props: Props) { 44 + const {rkey, name: handleOrDid} = props.route.params 45 + 46 + const pal = usePalette('default') 47 + const {_} = useLingui() 48 + const navigation = useNavigation<NavigationProp>() 49 + 50 + const uri = useMemo( 51 + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), 52 + [rkey, handleOrDid], 53 + ) 54 + const {error, data: resolvedUri} = useResolveUriQuery(uri) 55 + 56 + const onPressBack = React.useCallback(() => { 57 + if (navigation.canGoBack()) { 58 + navigation.goBack() 59 + } else { 60 + navigation.navigate('Home') 61 + } 62 + }, [navigation]) 63 + 64 + if (error) { 65 + return ( 66 + <Layout.Screen testID="profileFeedScreenError"> 67 + <Layout.Content> 68 + <View style={[pal.view, pal.border, styles.notFoundContainer]}> 69 + <Text type="title-lg" style={[pal.text, s.mb10]}> 70 + <Trans>Could not load feed</Trans> 71 + </Text> 72 + <Text type="md" style={[pal.text, s.mb20]}> 73 + {error.toString()} 74 + </Text> 75 + 76 + <View style={{flexDirection: 'row'}}> 77 + <Button 78 + type="default" 79 + accessibilityLabel={_(msg`Go back`)} 80 + accessibilityHint={_(msg`Returns to previous page`)} 81 + onPress={onPressBack} 82 + style={{flexShrink: 1}}> 83 + <Text type="button" style={pal.text}> 84 + <Trans>Go Back</Trans> 85 + </Text> 86 + </Button> 87 + </View> 88 + </View> 89 + </Layout.Content> 90 + </Layout.Screen> 91 + ) 92 + } 93 + 94 + return resolvedUri ? ( 95 + <Layout.Screen noInsetTop> 96 + <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> 97 + </Layout.Screen> 98 + ) : ( 99 + <Layout.Screen> 100 + <LoadingScreen /> 101 + </Layout.Screen> 102 + ) 103 + } 104 + 105 + function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { 106 + const {data: preferences} = usePreferencesQuery() 107 + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) 108 + 109 + if (!preferences || !info) { 110 + return <LoadingScreen /> 111 + } 112 + 113 + return ( 114 + <ProfileFeedScreenInner 115 + preferences={preferences} 116 + feedInfo={info as FeedSourceFeedInfo} 117 + /> 118 + ) 119 + } 120 + 121 + export function ProfileFeedScreenInner({ 122 + feedInfo, 123 + }: { 124 + preferences: UsePreferencesQueryResponse 125 + feedInfo: FeedSourceFeedInfo 126 + }) { 127 + const {_} = useLingui() 128 + const {hasSession} = useSession() 129 + const {openComposer} = useComposerControls() 130 + const isScreenFocused = useIsFocused() 131 + 132 + useSetTitle(feedInfo?.displayName) 133 + 134 + const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor 135 + 136 + const [hasNew, setHasNew] = React.useState(false) 137 + const [isScrolledDown, setIsScrolledDown] = React.useState(false) 138 + const queryClient = useQueryClient() 139 + const feedFeedback = useFeedFeedback(feed, hasSession) 140 + const scrollElRef = useAnimatedRef() as ListRef 141 + 142 + const onScrollToTop = useCallback(() => { 143 + scrollElRef.current?.scrollToOffset({ 144 + animated: isNative, 145 + offset: 0, // -headerHeight, 146 + }) 147 + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 148 + setHasNew(false) 149 + }, [scrollElRef, queryClient, feed, setHasNew]) 150 + 151 + React.useEffect(() => { 152 + if (!isScreenFocused) { 153 + return 154 + } 155 + return listenSoftReset(onScrollToTop) 156 + }, [onScrollToTop, isScreenFocused]) 157 + 158 + const renderPostsEmpty = useCallback(() => { 159 + return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> 160 + }, [_]) 161 + 162 + return ( 163 + <> 164 + <ProfileFeedHeader info={feedInfo} /> 165 + 166 + <FeedFeedbackProvider value={feedFeedback}> 167 + <PostFeed 168 + feed={feed} 169 + pollInterval={60e3} 170 + disablePoll={hasNew} 171 + onHasNew={setHasNew} 172 + scrollElRef={scrollElRef} 173 + onScrolledDownChange={setIsScrolledDown} 174 + renderEmptyState={renderPostsEmpty} 175 + /> 176 + </FeedFeedbackProvider> 177 + 178 + {(isScrolledDown || hasNew) && ( 179 + <LoadLatestBtn 180 + onPress={onScrollToTop} 181 + label={_(msg`Load new posts`)} 182 + showIndicator={hasNew} 183 + /> 184 + )} 185 + 186 + {hasSession && ( 187 + <FAB 188 + testID="composeFAB" 189 + onPress={() => openComposer({})} 190 + icon={ 191 + <ComposeIcon2 192 + strokeWidth={1.5} 193 + size={29} 194 + style={{color: 'white'}} 195 + /> 196 + } 197 + accessibilityRole="button" 198 + accessibilityLabel={_(msg`New post`)} 199 + accessibilityHint="" 200 + /> 201 + )} 202 + </> 203 + ) 204 + } 205 + 206 + const styles = StyleSheet.create({ 207 + btn: { 208 + flexDirection: 'row', 209 + alignItems: 'center', 210 + gap: 6, 211 + paddingVertical: 7, 212 + paddingHorizontal: 14, 213 + borderRadius: 50, 214 + marginLeft: 6, 215 + }, 216 + notFoundContainer: { 217 + margin: 10, 218 + paddingHorizontal: 18, 219 + paddingVertical: 14, 220 + borderRadius: 6, 221 + }, 222 + aboutSectionContainer: { 223 + paddingVertical: 4, 224 + paddingHorizontal: 16, 225 + gap: 12, 226 + }, 227 + })
+534
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {AtUri} from '@atproto/api' 5 + import {msg, Plural, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {useHaptics} from '#/lib/haptics' 9 + import {makeProfileLink} from '#/lib/routes/links' 10 + import {makeCustomFeedLink} from '#/lib/routes/links' 11 + import {shareUrl} from '#/lib/sharing' 12 + import {sanitizeHandle} from '#/lib/strings/handles' 13 + import {toShareUrl} from '#/lib/strings/url-helpers' 14 + import {logger} from '#/logger' 15 + import {FeedSourceFeedInfo} from '#/state/queries/feed' 16 + import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 17 + import { 18 + useAddSavedFeedsMutation, 19 + usePreferencesQuery, 20 + useRemoveFeedMutation, 21 + useUpdateSavedFeedsMutation, 22 + } from '#/state/queries/preferences' 23 + import {useSession} from '#/state/session' 24 + import {formatCount} from '#/view/com/util/numeric/format' 25 + import * as Toast from '#/view/com/util/Toast' 26 + import {UserAvatar} from '#/view/com/util/UserAvatar' 27 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import * as Dialog from '#/components/Dialog' 30 + import {Divider} from '#/components/Divider' 31 + import {useRichText} from '#/components/hooks/useRichText' 32 + import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 33 + import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' 34 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 35 + import { 36 + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 37 + Heart2_Stroke2_Corner0_Rounded as Heart, 38 + } from '#/components/icons/Heart2' 39 + import { 40 + Pin_Filled_Corner0_Rounded as PinFilled, 41 + Pin_Stroke2_Corner0_Rounded as Pin, 42 + } from '#/components/icons/Pin' 43 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 44 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 45 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 46 + import * as Layout from '#/components/Layout' 47 + import {InlineLinkText} from '#/components/Link' 48 + import {Loader} from '#/components/Loader' 49 + import * as Menu from '#/components/Menu' 50 + import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 51 + import {RichText} from '#/components/RichText' 52 + import {Text} from '#/components/Typography' 53 + 54 + export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { 55 + const t = useTheme() 56 + const {_, i18n} = useLingui() 57 + const {hasSession} = useSession() 58 + const {gtPhone, gtMobile} = useBreakpoints() 59 + const {top} = useSafeAreaInsets() 60 + const infoControl = Dialog.useDialogControl() 61 + const playHaptic = useHaptics() 62 + 63 + const {data: preferences} = usePreferencesQuery() 64 + 65 + const [likeUri, setLikeUri] = React.useState(info.likeUri || '') 66 + const isLiked = !!likeUri 67 + const likeCount = 68 + isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0 69 + 70 + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 71 + useAddSavedFeedsMutation() 72 + const {mutateAsync: removeFeed, isPending: isRemovePending} = 73 + useRemoveFeedMutation() 74 + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = 75 + useUpdateSavedFeedsMutation() 76 + 77 + const isFeedStateChangePending = 78 + isAddSavedFeedPending || isRemovePending || isUpdateFeedPending 79 + const savedFeedConfig = preferences?.savedFeeds?.find( 80 + f => f.value === info.uri, 81 + ) 82 + const isSaved = Boolean(savedFeedConfig) 83 + const isPinned = Boolean(savedFeedConfig?.pinned) 84 + 85 + const onToggleSaved = React.useCallback(async () => { 86 + try { 87 + playHaptic() 88 + 89 + if (savedFeedConfig) { 90 + await removeFeed(savedFeedConfig) 91 + Toast.show(_(msg`Removed from your feeds`)) 92 + } else { 93 + await addSavedFeeds([ 94 + { 95 + type: 'feed', 96 + value: info.uri, 97 + pinned: false, 98 + }, 99 + ]) 100 + Toast.show(_(msg`Saved to your feeds`)) 101 + } 102 + } catch (err) { 103 + Toast.show( 104 + _( 105 + msg`There was an issue updating your feeds, please check your internet connection and try again.`, 106 + ), 107 + 'xmark', 108 + ) 109 + logger.error('Failed to update feeds', {message: err}) 110 + } 111 + }, [_, playHaptic, info, removeFeed, addSavedFeeds, savedFeedConfig]) 112 + 113 + const onTogglePinned = React.useCallback(async () => { 114 + try { 115 + playHaptic() 116 + 117 + if (savedFeedConfig) { 118 + const pinned = !savedFeedConfig.pinned 119 + await updateSavedFeeds([ 120 + { 121 + ...savedFeedConfig, 122 + pinned, 123 + }, 124 + ]) 125 + 126 + if (pinned) { 127 + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 128 + } else { 129 + Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) 130 + } 131 + } else { 132 + await addSavedFeeds([ 133 + { 134 + type: 'feed', 135 + value: info.uri, 136 + pinned: true, 137 + }, 138 + ]) 139 + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 140 + } 141 + } catch (e) { 142 + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 143 + logger.error('Failed to toggle pinned feed', {message: e}) 144 + } 145 + }, [playHaptic, info, _, savedFeedConfig, updateSavedFeeds, addSavedFeeds]) 146 + 147 + return ( 148 + <> 149 + <Layout.Center 150 + style={[ 151 + t.atoms.bg, 152 + a.z_10, 153 + {paddingTop: top}, 154 + web([a.sticky, a.z_10, {top: 0}]), 155 + ]}> 156 + <Layout.Header.Outer> 157 + <Layout.Header.BackButton /> 158 + <Layout.Header.Content align="left"> 159 + <Button 160 + label={_(msg`Open feed info screen`)} 161 + style={[ 162 + a.justify_start, 163 + { 164 + paddingVertical: 6, 165 + paddingHorizontal: 8, 166 + paddingRight: 12, 167 + }, 168 + ]} 169 + onPress={() => { 170 + playHaptic() 171 + infoControl.open() 172 + }}> 173 + {({hovered, pressed}) => ( 174 + <> 175 + <View 176 + style={[ 177 + a.absolute, 178 + a.inset_0, 179 + a.rounded_sm, 180 + a.transition_transform, 181 + t.atoms.bg_contrast_25, 182 + pressed && t.atoms.bg_contrast_50, 183 + hovered && { 184 + transform: [{scaleX: 1.01}, {scaleY: 1.1}], 185 + }, 186 + ]} 187 + /> 188 + 189 + <View 190 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 191 + {info.avatar && ( 192 + <UserAvatar size={32} type="algo" avatar={info.avatar} /> 193 + )} 194 + 195 + <View style={[a.flex_1]}> 196 + <Text 197 + style={[ 198 + a.text_md, 199 + a.font_heavy, 200 + a.leading_tight, 201 + gtMobile && a.text_xl, 202 + ]} 203 + numberOfLines={2}> 204 + {info.displayName} 205 + </Text> 206 + <View style={[a.flex_row, {gap: 6}]}> 207 + <Text 208 + style={[ 209 + a.flex_shrink, 210 + a.text_xs, 211 + a.leading_snug, 212 + t.atoms.text_contrast_medium, 213 + gtPhone && a.text_sm, 214 + ]} 215 + numberOfLines={1}> 216 + {sanitizeHandle(info.creatorHandle, '@')} 217 + </Text> 218 + <View style={[a.flex_row, a.align_center, {gap: 2}]}> 219 + <HeartFilled 220 + size="xs" 221 + fill={ 222 + likeUri 223 + ? t.palette.like 224 + : t.atoms.text_contrast_low.color 225 + } 226 + /> 227 + <Text 228 + style={[ 229 + a.text_xs, 230 + a.leading_snug, 231 + t.atoms.text_contrast_medium, 232 + gtPhone && a.text_sm, 233 + ]} 234 + numberOfLines={1}> 235 + {formatCount(i18n, likeCount)} 236 + </Text> 237 + </View> 238 + </View> 239 + </View> 240 + 241 + <ChevronDown 242 + size="md" 243 + fill={t.atoms.text_contrast_low.color} 244 + /> 245 + </View> 246 + </> 247 + )} 248 + </Button> 249 + </Layout.Header.Content> 250 + 251 + {hasSession && ( 252 + <Layout.Header.Slot> 253 + {isPinned ? ( 254 + <Menu.Root> 255 + <Menu.Trigger label={_(msg`Open feed options menu`)}> 256 + {({props}) => { 257 + return ( 258 + <Button 259 + {...props} 260 + label={_(msg`Open feed options menu`)} 261 + size="small" 262 + variant="ghost" 263 + shape="square" 264 + color="secondary"> 265 + <PinFilled size="lg" fill={t.palette.primary_500} /> 266 + </Button> 267 + ) 268 + }} 269 + </Menu.Trigger> 270 + 271 + <Menu.Outer> 272 + <Menu.Item 273 + disabled={isFeedStateChangePending} 274 + label={_(msg`Unpin from home`)} 275 + onPress={onTogglePinned}> 276 + <Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText> 277 + <Menu.ItemIcon icon={X} position="right" /> 278 + </Menu.Item> 279 + <Menu.Item 280 + disabled={isFeedStateChangePending} 281 + label={ 282 + isSaved 283 + ? _(msg`Remove from my feeds`) 284 + : _(msg`Save to my feeds`) 285 + } 286 + onPress={onToggleSaved}> 287 + <Menu.ItemText> 288 + {isSaved 289 + ? _(msg`Remove from my feeds`) 290 + : _(msg`Save to my feeds`)} 291 + </Menu.ItemText> 292 + <Menu.ItemIcon 293 + icon={isSaved ? Trash : Plus} 294 + position="right" 295 + /> 296 + </Menu.Item> 297 + </Menu.Outer> 298 + </Menu.Root> 299 + ) : ( 300 + <Button 301 + label={_(msg`Pin to Home`)} 302 + size="small" 303 + variant="ghost" 304 + shape="square" 305 + color="secondary" 306 + onPress={onTogglePinned}> 307 + <ButtonIcon icon={Pin} size="lg" /> 308 + </Button> 309 + )} 310 + </Layout.Header.Slot> 311 + )} 312 + </Layout.Header.Outer> 313 + </Layout.Center> 314 + 315 + <Dialog.Outer control={infoControl}> 316 + <Dialog.Handle /> 317 + <Dialog.ScrollableInner 318 + label={_(msg`Feed menu`)} 319 + style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}> 320 + <DialogInner 321 + info={info} 322 + likeUri={likeUri} 323 + setLikeUri={setLikeUri} 324 + likeCount={likeCount} 325 + isPinned={isPinned} 326 + onTogglePinned={onTogglePinned} 327 + isFeedStateChangePending={isFeedStateChangePending} 328 + /> 329 + </Dialog.ScrollableInner> 330 + </Dialog.Outer> 331 + </> 332 + ) 333 + } 334 + 335 + function DialogInner({ 336 + info, 337 + likeUri, 338 + setLikeUri, 339 + likeCount, 340 + isPinned, 341 + onTogglePinned, 342 + isFeedStateChangePending, 343 + }: { 344 + info: FeedSourceFeedInfo 345 + likeUri: string 346 + setLikeUri: (uri: string) => void 347 + likeCount: number 348 + isPinned: boolean 349 + onTogglePinned: () => void 350 + isFeedStateChangePending: boolean 351 + }) { 352 + const t = useTheme() 353 + const {_} = useLingui() 354 + const {hasSession} = useSession() 355 + const playHaptic = useHaptics() 356 + const control = Dialog.useDialogContext() 357 + const reportDialogControl = useReportDialogControl() 358 + const [rt, loading] = useRichText(info.description.text) 359 + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() 360 + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = 361 + useUnlikeMutation() 362 + 363 + const isLiked = !!likeUri 364 + const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri]) 365 + 366 + const onToggleLiked = React.useCallback(async () => { 367 + try { 368 + playHaptic() 369 + 370 + if (isLiked && likeUri) { 371 + await unlikeFeed({uri: likeUri}) 372 + setLikeUri('') 373 + } else { 374 + const res = await likeFeed({uri: info.uri, cid: info.cid}) 375 + setLikeUri(res.uri) 376 + } 377 + } catch (err) { 378 + Toast.show( 379 + _( 380 + msg`There was an issue contacting the server, please check your internet connection and try again.`, 381 + ), 382 + 'xmark', 383 + ) 384 + logger.error('Failed to toggle like', {message: err}) 385 + } 386 + }, [playHaptic, isLiked, likeUri, unlikeFeed, setLikeUri, likeFeed, info, _]) 387 + 388 + const onPressShare = React.useCallback(() => { 389 + playHaptic() 390 + const url = toShareUrl(info.route.href) 391 + shareUrl(url) 392 + }, [info, playHaptic]) 393 + 394 + const onPressReport = React.useCallback(() => { 395 + reportDialogControl.open() 396 + }, [reportDialogControl]) 397 + 398 + return loading ? ( 399 + <Loader size="xl" /> 400 + ) : ( 401 + <View style={[a.gap_md]}> 402 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 403 + <UserAvatar type="algo" size={48} avatar={info.avatar} /> 404 + 405 + <View style={[a.flex_1, a.gap_2xs]}> 406 + <Text 407 + style={[a.text_2xl, a.font_heavy, a.leading_tight]} 408 + numberOfLines={2}> 409 + {info.displayName} 410 + </Text> 411 + <Text 412 + style={[a.text_sm, a.leading_tight, t.atoms.text_contrast_medium]} 413 + numberOfLines={1}> 414 + <Trans> 415 + By{' '} 416 + <InlineLinkText 417 + label={_(msg`View ${info.creatorHandle}'s profile`)} 418 + to={makeProfileLink({ 419 + did: info.creatorDid, 420 + handle: info.creatorHandle, 421 + })} 422 + style={[ 423 + a.text_sm, 424 + a.leading_tight, 425 + a.underline, 426 + t.atoms.text_contrast_medium, 427 + ]} 428 + numberOfLines={1} 429 + onPress={() => control.close()}> 430 + {sanitizeHandle(info.creatorHandle, '@')} 431 + </InlineLinkText> 432 + </Trans> 433 + </Text> 434 + </View> 435 + 436 + <Button 437 + label={_(msg`Share this feed`)} 438 + size="small" 439 + variant="ghost" 440 + color="secondary" 441 + shape="round" 442 + onPress={onPressShare}> 443 + <ButtonIcon icon={Share} size="lg" /> 444 + </Button> 445 + </View> 446 + 447 + <RichText value={rt} style={[a.text_md, a.leading_snug]} /> 448 + 449 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 450 + {typeof likeCount === 'number' && ( 451 + <InlineLinkText 452 + label={_(msg`View users who like this feed`)} 453 + to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')} 454 + style={[a.underline, t.atoms.text_contrast_medium]} 455 + onPress={() => control.close()}> 456 + <Trans> 457 + Liked by <Plural value={likeCount} one="# user" other="# users" /> 458 + </Trans> 459 + </InlineLinkText> 460 + )} 461 + </View> 462 + 463 + {hasSession && ( 464 + <> 465 + <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}> 466 + <Button 467 + disabled={isLikePending || isUnlikePending} 468 + label={_(msg`Like feed`)} 469 + size="small" 470 + variant="solid" 471 + color="secondary" 472 + onPress={onToggleLiked} 473 + style={[a.flex_1]}> 474 + {isLiked ? ( 475 + <HeartFilled size="sm" fill={t.palette.like} /> 476 + ) : ( 477 + <ButtonIcon icon={Heart} position="left" /> 478 + )} 479 + 480 + <ButtonText> 481 + {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>} 482 + </ButtonText> 483 + </Button> 484 + <Button 485 + disabled={isFeedStateChangePending} 486 + label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)} 487 + size="small" 488 + variant="solid" 489 + color={isPinned ? 'secondary' : 'primary'} 490 + onPress={onTogglePinned} 491 + style={[a.flex_1]}> 492 + <ButtonText> 493 + {isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>} 494 + </ButtonText> 495 + <ButtonIcon icon={Pin} position="right" /> 496 + </Button> 497 + </View> 498 + 499 + <View style={[a.pt_xs, a.gap_lg]}> 500 + <Divider /> 501 + 502 + <View 503 + style={[a.flex_row, a.align_center, a.gap_sm, a.justify_between]}> 504 + <Text style={[a.italic, t.atoms.text_contrast_medium]}> 505 + Something wrong? Let us know. 506 + </Text> 507 + 508 + <Button 509 + label={_(msg`Report feed`)} 510 + size="small" 511 + variant="solid" 512 + color="secondary" 513 + onPress={onPressReport}> 514 + <ButtonText> 515 + <Trans>Report feed</Trans> 516 + </ButtonText> 517 + <ButtonIcon icon={CircleInfo} position="right" /> 518 + </Button> 519 + </View> 520 + 521 + <ReportDialog 522 + control={reportDialogControl} 523 + params={{ 524 + type: 'feedgen', 525 + uri: info.uri, 526 + cid: info.cid, 527 + }} 528 + /> 529 + </View> 530 + </> 531 + )} 532 + </View> 533 + ) 534 + }
-621
src/view/screens/ProfileFeed.tsx
··· 1 - import React, {useCallback, useMemo} from 'react' 2 - import {Pressable, StyleSheet, View} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {msg, Plural, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - import {useIsFocused, useNavigation} from '@react-navigation/native' 7 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 - import {useQueryClient} from '@tanstack/react-query' 9 - 10 - import {HITSLOP_20} from '#/lib/constants' 11 - import {useHaptics} from '#/lib/haptics' 12 - import {usePalette} from '#/lib/hooks/usePalette' 13 - import {useSetTitle} from '#/lib/hooks/useSetTitle' 14 - import {ComposeIcon2} from '#/lib/icons' 15 - import {makeCustomFeedLink} from '#/lib/routes/links' 16 - import {CommonNavigatorParams} from '#/lib/routes/types' 17 - import {NavigationProp} from '#/lib/routes/types' 18 - import {shareUrl} from '#/lib/sharing' 19 - import {makeRecordUri} from '#/lib/strings/url-helpers' 20 - import {toShareUrl} from '#/lib/strings/url-helpers' 21 - import {s} from '#/lib/styles' 22 - import {logger} from '#/logger' 23 - import {isNative} from '#/platform/detection' 24 - import {listenSoftReset} from '#/state/events' 25 - import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 26 - import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 27 - import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 28 - import {FeedDescriptor} from '#/state/queries/post-feed' 29 - import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 30 - import { 31 - useAddSavedFeedsMutation, 32 - usePreferencesQuery, 33 - UsePreferencesQueryResponse, 34 - useRemoveFeedMutation, 35 - useUpdateSavedFeedsMutation, 36 - } from '#/state/queries/preferences' 37 - import {useResolveUriQuery} from '#/state/queries/resolve-uri' 38 - import {truncateAndInvalidate} from '#/state/queries/util' 39 - import {useSession} from '#/state/session' 40 - import {useComposerControls} from '#/state/shell/composer' 41 - import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 42 - import {PostFeed} from '#/view/com/posts/PostFeed' 43 - import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' 44 - import {EmptyState} from '#/view/com/util/EmptyState' 45 - import {FAB} from '#/view/com/util/fab/FAB' 46 - import {Button} from '#/view/com/util/forms/Button' 47 - import {ListRef} from '#/view/com/util/List' 48 - import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 49 - import {LoadingScreen} from '#/view/com/util/LoadingScreen' 50 - import {Text} from '#/view/com/util/text/Text' 51 - import * as Toast from '#/view/com/util/Toast' 52 - import {atoms as a, useTheme} from '#/alf' 53 - import {Button as NewButton, ButtonText} from '#/components/Button' 54 - import {useRichText} from '#/components/hooks/useRichText' 55 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 56 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 57 - import { 58 - Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 59 - Heart2_Stroke2_Corner0_Rounded as HeartOutline, 60 - } from '#/components/icons/Heart2' 61 - import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 62 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 63 - import * as Layout from '#/components/Layout' 64 - import {InlineLinkText} from '#/components/Link' 65 - import * as Menu from '#/components/Menu' 66 - import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' 67 - import {RichText} from '#/components/RichText' 68 - 69 - const SECTION_TITLES = ['Posts'] 70 - 71 - interface SectionRef { 72 - scrollToTop: () => void 73 - } 74 - 75 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> 76 - export function ProfileFeedScreen(props: Props) { 77 - const {rkey, name: handleOrDid} = props.route.params 78 - 79 - const pal = usePalette('default') 80 - const {_} = useLingui() 81 - const navigation = useNavigation<NavigationProp>() 82 - 83 - const uri = useMemo( 84 - () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), 85 - [rkey, handleOrDid], 86 - ) 87 - const {error, data: resolvedUri} = useResolveUriQuery(uri) 88 - 89 - const onPressBack = React.useCallback(() => { 90 - if (navigation.canGoBack()) { 91 - navigation.goBack() 92 - } else { 93 - navigation.navigate('Home') 94 - } 95 - }, [navigation]) 96 - 97 - if (error) { 98 - return ( 99 - <Layout.Screen testID="profileFeedScreenError"> 100 - <Layout.Content> 101 - <View style={[pal.view, pal.border, styles.notFoundContainer]}> 102 - <Text type="title-lg" style={[pal.text, s.mb10]}> 103 - <Trans>Could not load feed</Trans> 104 - </Text> 105 - <Text type="md" style={[pal.text, s.mb20]}> 106 - {error.toString()} 107 - </Text> 108 - 109 - <View style={{flexDirection: 'row'}}> 110 - <Button 111 - type="default" 112 - accessibilityLabel={_(msg`Go back`)} 113 - accessibilityHint={_(msg`Returns to previous page`)} 114 - onPress={onPressBack} 115 - style={{flexShrink: 1}}> 116 - <Text type="button" style={pal.text}> 117 - <Trans>Go Back</Trans> 118 - </Text> 119 - </Button> 120 - </View> 121 - </View> 122 - </Layout.Content> 123 - </Layout.Screen> 124 - ) 125 - } 126 - 127 - return resolvedUri ? ( 128 - <Layout.Screen> 129 - <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> 130 - </Layout.Screen> 131 - ) : ( 132 - <Layout.Screen> 133 - <LoadingScreen /> 134 - </Layout.Screen> 135 - ) 136 - } 137 - 138 - function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { 139 - const {data: preferences} = usePreferencesQuery() 140 - const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) 141 - 142 - if (!preferences || !info) { 143 - return <LoadingScreen /> 144 - } 145 - 146 - return ( 147 - <ProfileFeedScreenInner 148 - preferences={preferences} 149 - feedInfo={info as FeedSourceFeedInfo} 150 - /> 151 - ) 152 - } 153 - 154 - export function ProfileFeedScreenInner({ 155 - preferences, 156 - feedInfo, 157 - }: { 158 - preferences: UsePreferencesQueryResponse 159 - feedInfo: FeedSourceFeedInfo 160 - }) { 161 - const {_} = useLingui() 162 - const t = useTheme() 163 - const {hasSession, currentAccount} = useSession() 164 - const reportDialogControl = useReportDialogControl() 165 - const {openComposer} = useComposerControls() 166 - const playHaptic = useHaptics() 167 - const feedSectionRef = React.useRef<SectionRef>(null) 168 - const isScreenFocused = useIsFocused() 169 - 170 - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 171 - useAddSavedFeedsMutation() 172 - const {mutateAsync: removeFeed, isPending: isRemovePending} = 173 - useRemoveFeedMutation() 174 - const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = 175 - useUpdateSavedFeedsMutation() 176 - 177 - const isPending = 178 - isAddSavedFeedPending || isRemovePending || isUpdateFeedPending 179 - const savedFeedConfig = preferences.savedFeeds.find( 180 - f => f.value === feedInfo.uri, 181 - ) 182 - const isSaved = Boolean(savedFeedConfig) 183 - const isPinned = Boolean(savedFeedConfig?.pinned) 184 - 185 - useSetTitle(feedInfo?.displayName) 186 - 187 - // event handlers 188 - // 189 - 190 - const onToggleSaved = React.useCallback(async () => { 191 - try { 192 - playHaptic() 193 - 194 - if (savedFeedConfig) { 195 - await removeFeed(savedFeedConfig) 196 - Toast.show(_(msg`Removed from your feeds`)) 197 - } else { 198 - await addSavedFeeds([ 199 - { 200 - type: 'feed', 201 - value: feedInfo.uri, 202 - pinned: false, 203 - }, 204 - ]) 205 - Toast.show(_(msg`Saved to your feeds`)) 206 - } 207 - } catch (err) { 208 - Toast.show( 209 - _( 210 - msg`There was an issue updating your feeds, please check your internet connection and try again.`, 211 - ), 212 - 'xmark', 213 - ) 214 - logger.error('Failed to update feeds', {message: err}) 215 - } 216 - }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig]) 217 - 218 - const onTogglePinned = React.useCallback(async () => { 219 - try { 220 - playHaptic() 221 - 222 - if (savedFeedConfig) { 223 - await updateSavedFeeds([ 224 - { 225 - ...savedFeedConfig, 226 - pinned: !savedFeedConfig.pinned, 227 - }, 228 - ]) 229 - } else { 230 - await addSavedFeeds([ 231 - { 232 - type: 'feed', 233 - value: feedInfo.uri, 234 - pinned: true, 235 - }, 236 - ]) 237 - } 238 - } catch (e) { 239 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 240 - logger.error('Failed to toggle pinned feed', {message: e}) 241 - } 242 - }, [ 243 - playHaptic, 244 - feedInfo, 245 - _, 246 - savedFeedConfig, 247 - updateSavedFeeds, 248 - addSavedFeeds, 249 - ]) 250 - 251 - const onPressShare = React.useCallback(() => { 252 - const url = toShareUrl(feedInfo.route.href) 253 - shareUrl(url) 254 - }, [feedInfo]) 255 - 256 - const onPressReport = React.useCallback(() => { 257 - reportDialogControl.open() 258 - }, [reportDialogControl]) 259 - 260 - const onCurrentPageSelected = React.useCallback( 261 - (index: number) => { 262 - if (index === 0) { 263 - feedSectionRef.current?.scrollToTop() 264 - } 265 - }, 266 - [feedSectionRef], 267 - ) 268 - 269 - const renderHeader = useCallback(() => { 270 - return ( 271 - <> 272 - <ProfileSubpageHeader 273 - isLoading={false} 274 - href={feedInfo.route.href} 275 - title={feedInfo?.displayName} 276 - avatar={feedInfo?.avatar} 277 - isOwner={feedInfo.creatorDid === currentAccount?.did} 278 - creator={ 279 - feedInfo 280 - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} 281 - : undefined 282 - } 283 - avatarType="algo"> 284 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 285 - {feedInfo && hasSession && ( 286 - <NewButton 287 - testID={isPinned ? 'unpinBtn' : 'pinBtn'} 288 - disabled={isPending} 289 - size="small" 290 - variant="solid" 291 - color={isPinned ? 'secondary' : 'primary'} 292 - label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)} 293 - onPress={onTogglePinned}> 294 - <ButtonText> 295 - {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} 296 - </ButtonText> 297 - </NewButton> 298 - )} 299 - <Menu.Root> 300 - <Menu.Trigger label={_(msg`Open feed options menu`)}> 301 - {({props, state}) => { 302 - return ( 303 - <Pressable 304 - {...props} 305 - hitSlop={HITSLOP_20} 306 - style={[ 307 - a.justify_center, 308 - a.align_center, 309 - a.rounded_full, 310 - {height: 36, width: 36}, 311 - t.atoms.bg_contrast_25, 312 - (state.hovered || state.pressed) && [ 313 - t.atoms.bg_contrast_50, 314 - ], 315 - ]} 316 - testID="headerDropdownBtn"> 317 - <FontAwesomeIcon 318 - icon="ellipsis" 319 - size={20} 320 - style={t.atoms.text} 321 - /> 322 - </Pressable> 323 - ) 324 - }} 325 - </Menu.Trigger> 326 - 327 - <Menu.Outer> 328 - <Menu.Group> 329 - {hasSession && ( 330 - <> 331 - <Menu.Item 332 - disabled={isPending} 333 - testID="feedHeaderDropdownToggleSavedBtn" 334 - label={ 335 - isSaved 336 - ? _(msg`Remove from my feeds`) 337 - : _(msg`Save to my feeds`) 338 - } 339 - onPress={onToggleSaved}> 340 - <Menu.ItemText> 341 - {isSaved 342 - ? _(msg`Remove from my feeds`) 343 - : _(msg`Save to my feeds`)} 344 - </Menu.ItemText> 345 - <Menu.ItemIcon 346 - icon={isSaved ? Trash : Plus} 347 - position="right" 348 - /> 349 - </Menu.Item> 350 - 351 - <Menu.Item 352 - testID="feedHeaderDropdownReportBtn" 353 - label={_(msg`Report feed`)} 354 - onPress={onPressReport}> 355 - <Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText> 356 - <Menu.ItemIcon icon={CircleInfo} position="right" /> 357 - </Menu.Item> 358 - </> 359 - )} 360 - 361 - <Menu.Item 362 - testID="feedHeaderDropdownShareBtn" 363 - label={_(msg`Share feed`)} 364 - onPress={onPressShare}> 365 - <Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText> 366 - <Menu.ItemIcon icon={Share} position="right" /> 367 - </Menu.Item> 368 - </Menu.Group> 369 - </Menu.Outer> 370 - </Menu.Root> 371 - </View> 372 - </ProfileSubpageHeader> 373 - <AboutSection 374 - feedOwnerDid={feedInfo.creatorDid} 375 - feedRkey={feedInfo.route.params.rkey} 376 - feedInfo={feedInfo} 377 - /> 378 - </> 379 - ) 380 - }, [ 381 - _, 382 - hasSession, 383 - feedInfo, 384 - isPinned, 385 - onTogglePinned, 386 - onToggleSaved, 387 - currentAccount?.did, 388 - isSaved, 389 - onPressReport, 390 - onPressShare, 391 - t, 392 - isPending, 393 - ]) 394 - 395 - return ( 396 - <> 397 - <ReportDialog 398 - control={reportDialogControl} 399 - params={{ 400 - type: 'feedgen', 401 - uri: feedInfo.uri, 402 - cid: feedInfo.cid, 403 - }} 404 - /> 405 - <PagerWithHeader 406 - items={SECTION_TITLES} 407 - isHeaderReady={true} 408 - renderHeader={renderHeader} 409 - onCurrentPageSelected={onCurrentPageSelected}> 410 - {({headerHeight, scrollElRef, isFocused}) => ( 411 - <FeedSection 412 - ref={feedSectionRef} 413 - feed={`feedgen|${feedInfo.uri}`} 414 - headerHeight={headerHeight} 415 - scrollElRef={scrollElRef as ListRef} 416 - isFocused={isScreenFocused && isFocused} 417 - /> 418 - )} 419 - </PagerWithHeader> 420 - {hasSession && ( 421 - <FAB 422 - testID="composeFAB" 423 - onPress={() => openComposer({})} 424 - icon={ 425 - <ComposeIcon2 426 - strokeWidth={1.5} 427 - size={29} 428 - style={{color: 'white'}} 429 - /> 430 - } 431 - accessibilityRole="button" 432 - accessibilityLabel={_(msg`New post`)} 433 - accessibilityHint="" 434 - /> 435 - )} 436 - </> 437 - ) 438 - } 439 - 440 - interface FeedSectionProps { 441 - feed: FeedDescriptor 442 - headerHeight: number 443 - scrollElRef: ListRef 444 - isFocused: boolean 445 - } 446 - const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( 447 - function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) { 448 - const {_} = useLingui() 449 - const [hasNew, setHasNew] = React.useState(false) 450 - const [isScrolledDown, setIsScrolledDown] = React.useState(false) 451 - const queryClient = useQueryClient() 452 - const isScreenFocused = useIsFocused() 453 - const {hasSession} = useSession() 454 - const feedFeedback = useFeedFeedback(feed, hasSession) 455 - 456 - const onScrollToTop = useCallback(() => { 457 - scrollElRef.current?.scrollToOffset({ 458 - animated: isNative, 459 - offset: -headerHeight, 460 - }) 461 - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 462 - setHasNew(false) 463 - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) 464 - 465 - React.useImperativeHandle(ref, () => ({ 466 - scrollToTop: onScrollToTop, 467 - })) 468 - 469 - React.useEffect(() => { 470 - if (!isScreenFocused) { 471 - return 472 - } 473 - return listenSoftReset(onScrollToTop) 474 - }, [onScrollToTop, isScreenFocused]) 475 - 476 - const renderPostsEmpty = useCallback(() => { 477 - return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> 478 - }, [_]) 479 - 480 - return ( 481 - <View> 482 - <FeedFeedbackProvider value={feedFeedback}> 483 - <PostFeed 484 - enabled={isFocused} 485 - feed={feed} 486 - pollInterval={60e3} 487 - disablePoll={hasNew} 488 - scrollElRef={scrollElRef} 489 - onHasNew={setHasNew} 490 - onScrolledDownChange={setIsScrolledDown} 491 - renderEmptyState={renderPostsEmpty} 492 - headerOffset={headerHeight} 493 - /> 494 - </FeedFeedbackProvider> 495 - {(isScrolledDown || hasNew) && ( 496 - <LoadLatestBtn 497 - onPress={onScrollToTop} 498 - label={_(msg`Load new posts`)} 499 - showIndicator={hasNew} 500 - /> 501 - )} 502 - </View> 503 - ) 504 - }, 505 - ) 506 - 507 - function AboutSection({ 508 - feedOwnerDid, 509 - feedRkey, 510 - feedInfo, 511 - }: { 512 - feedOwnerDid: string 513 - feedRkey: string 514 - feedInfo: FeedSourceFeedInfo 515 - }) { 516 - const t = useTheme() 517 - const pal = usePalette('default') 518 - const {_} = useLingui() 519 - const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) 520 - const {hasSession} = useSession() 521 - const playHaptic = useHaptics() 522 - const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() 523 - const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = 524 - useUnlikeMutation() 525 - const [resolvedRT] = useRichText(feedInfo.description.text || '') 526 - 527 - const isLiked = !!likeUri 528 - const likeCount = 529 - isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount 530 - 531 - const onToggleLiked = React.useCallback(async () => { 532 - try { 533 - playHaptic() 534 - 535 - if (isLiked && likeUri) { 536 - await unlikeFeed({uri: likeUri}) 537 - setLikeUri('') 538 - } else { 539 - const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) 540 - setLikeUri(res.uri) 541 - } 542 - } catch (err) { 543 - Toast.show( 544 - _( 545 - msg`There was an issue contacting the server, please check your internet connection and try again.`, 546 - ), 547 - 'xmark', 548 - ) 549 - logger.error('Failed to toggle like', {message: err}) 550 - } 551 - }, [playHaptic, isLiked, likeUri, unlikeFeed, likeFeed, feedInfo, _]) 552 - 553 - return ( 554 - <View style={[styles.aboutSectionContainer]}> 555 - <View style={[a.pt_sm]}> 556 - {feedInfo.description ? ( 557 - <RichText 558 - testID="listDescription" 559 - style={[a.text_md]} 560 - value={resolvedRT ?? feedInfo.description} 561 - /> 562 - ) : ( 563 - <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> 564 - <Trans>No description</Trans> 565 - </Text> 566 - )} 567 - </View> 568 - 569 - <View style={[a.flex_row, a.gap_sm, a.align_center, a.pb_sm]}> 570 - <NewButton 571 - size="small" 572 - variant="solid" 573 - color="secondary" 574 - shape="round" 575 - label={isLiked ? _(msg`Unlike this feed`) : _(msg`Like this feed`)} 576 - testID="toggleLikeBtn" 577 - disabled={!hasSession || isLikePending || isUnlikePending} 578 - onPress={onToggleLiked}> 579 - {isLiked ? ( 580 - <HeartFilled size="md" fill={s.likeColor.color} /> 581 - ) : ( 582 - <HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} /> 583 - )} 584 - </NewButton> 585 - {typeof likeCount === 'number' && ( 586 - <InlineLinkText 587 - label={_(msg`View users who like this feed`)} 588 - to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} 589 - style={[t.atoms.text_contrast_medium, a.font_bold]}> 590 - <Trans> 591 - Liked by <Plural value={likeCount} one="# user" other="# users" /> 592 - </Trans> 593 - </InlineLinkText> 594 - )} 595 - </View> 596 - </View> 597 - ) 598 - } 599 - 600 - const styles = StyleSheet.create({ 601 - btn: { 602 - flexDirection: 'row', 603 - alignItems: 'center', 604 - gap: 6, 605 - paddingVertical: 7, 606 - paddingHorizontal: 14, 607 - borderRadius: 50, 608 - marginLeft: 6, 609 - }, 610 - notFoundContainer: { 611 - margin: 10, 612 - paddingHorizontal: 18, 613 - paddingVertical: 14, 614 - borderRadius: 6, 615 - }, 616 - aboutSectionContainer: { 617 - paddingVertical: 4, 618 - paddingHorizontal: 16, 619 - gap: 12, 620 - }, 621 - })