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 0000000000..a2e71b967c --- /dev/null +++ b/assets/icons/pin_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 0cfe09aadc..cb97a7065b 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 08ec593927..5bac690e2b 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/icons/Pin.tsx b/src/components/icons/Pin.tsx index 03dbbac90e..d1c37f39a3 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 index 84e31ed2fb..4ce5af132a 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -1,46 +1,34 @@ import React, {useCallback, useMemo} from 'react' -import {Pressable, StyleSheet, View, ScrollView} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' +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 {useAnimatedRef} from 'react-native-reanimated' -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 {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' @@ -48,29 +36,8 @@ 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' - import {ProfileSubpageHeader as NewHeader} from '#/screens/Profile/components/ProfileSubpageHeader' - -interface SectionRef { - scrollToTop: () => void -} +import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function ProfileFeedScreen(props: Props) { @@ -154,282 +121,49 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { } export function ProfileFeedScreenInner({ - preferences, feedInfo, }: { preferences: UsePreferencesQueryResponse feedInfo: FeedSourceFeedInfo }) { const {_} = useLingui() - const t = useTheme() - const {hasSession, currentAccount} = useSession() - const reportDialogControl = useReportDialogControl() + const {hasSession} = useSession() const {openComposer} = useComposerControls() - const playHaptic = useHaptics() 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 renderHeader = useCallback(() => { - return ( - - - - {feedInfo && hasSession && ( - - - {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} - - - )} - - - {({props, state}) => { - return ( - - - - ) - }} - - - - - {hasSession && ( - <> - - - {isSaved - ? _(msg`Remove from my feeds`) - : _(msg`Save to my feeds`)} - - - - - - {_(msg`Report feed`)} - - - - )} - - - {_(msg`Share feed`)} - - - - - - - - - - - ) - }, [ - _, - hasSession, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - currentAccount?.did, - isSaved, - onPressReport, - onPressShare, - t, - isPending, - ]) - 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 [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]) + 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]) + React.useEffect(() => { + if (!isScreenFocused) { + return + } + return listenSoftReset(onScrollToTop) + }, [onScrollToTop, isScreenFocused]) - const renderPostsEmpty = useCallback(() => { - return - }, [_]) + const renderPostsEmpty = useCallback(() => { + return + }, [_]) return ( <> - - - + @@ -472,101 +205,6 @@ export function ProfileFeedScreenInner({ ) } -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 ( - - - {feedInfo.description ? ( - - ) : ( - - No description - - )} - - - - - {isLiked ? ( - - ) : ( - - )} - - {typeof likeCount === 'number' && ( - - - - )} - - - ) -} - const styles = StyleSheet.create({ btn: { flexDirection: 'row', diff --git a/src/screens/Profile/components/ProfileSubpageHeader.tsx b/src/screens/Profile/components/ProfileSubpageHeader.tsx index 7b56405227..14b86cfbe9 100644 --- a/src/screens/Profile/components/ProfileSubpageHeader.tsx +++ b/src/screens/Profile/components/ProfileSubpageHeader.tsx @@ -1,125 +1,478 @@ -import {View, Text as RNText} from 'react-native' -import {useLingui} from '@lingui/react' -import {msg, plural, Trans} from '@lingui/macro' +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 * as Layout from '#/components/Layout' -import {atoms as a, useTheme, useBreakpoints, web} from '#/alf' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {Text} from '#/components/Typography' -import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' -import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' -import {Button, ButtonIcon} from '#/components/Button' +import {useHaptics} from '#/lib/haptics' +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 ProfileSubpageHeader({ - title, - avatar, - creator, - likeCount, -}: { - title: string - avatar?: string - creator: {did: string; handle: string} - likeCount: number -}) { +export function ProfileSubpageHeader({info}: {info: FeedSourceFeedInfo}) { const t = useTheme() + const {_, i18n} = useLingui() + const {hasSession} = useSession() const {gtPhone, gtMobile} = useBreakpoints() - const {_} = useLingui() const {top} = useSafeAreaInsets() + const infoControl = Dialog.useDialogControl() + const playHaptic = useHaptics() + const reportDialogControl = useReportDialogControl() + + 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 isPending = + 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 to your home screen`)) + } else { + Toast.show(_(msg`Un-pinned from your home screen`)) + } + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Pinned to your home screen`)) + } + } 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 ( - - - - - - - - - - - + + {info.avatar && ( + + )} + + + {/* Should roughly match Layout.Header.TitleText */} + + {info.displayName} + + {/* Should roughly match Layout.Header.SubtitleText */} + + + {sanitizeHandle(info.creatorHandle, '@')} + + + + + {formatCount(i18n, likeCount)} + + + + + + + + + )} + + + + {hasSession && ( + + + + {({props}) => { + return ( + + ) + }} + + + + + + {isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)} + + + + + + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + + + + + + + )} + + + + + + + + + + + + + ) +} + +function DialogInner({ + info, + likeUri, + setLikeUri, + likeCount, + reportDialogControl, +}: { + info: FeedSourceFeedInfo + likeUri: string + setLikeUri: (uri: string) => void + likeCount: number + reportDialogControl: Dialog.DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const playHaptic = useHaptics() + 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(() => { + const url = toShareUrl(info.route.href) + shareUrl(url) + }, [info]) + + const onPressReport = React.useCallback(() => { + reportDialogControl.open() + }, [reportDialogControl]) + + return loading ? ( + + ) : ( + + + + + + + {info.displayName} + + + By {sanitizeHandle(info.creatorHandle, '@')} + + + + + + + + + + + {typeof likeCount === 'number' && ( + + + + )} + + + + + + + + Something wrong? Let us know. + + + + + + ) }