Compare commits

...

13 Commits

Author SHA1 Message Date
Eric Bailey 746abfbde6 :think: 2024-12-11 15:57:36 -06:00
Eric Bailey 3a5cb7e370 Remove hack 2024-12-11 14:31:48 -06:00
Eric Bailey 05ef3b9354 Fix layout, overflowing FAB buttons 2024-12-11 14:23:38 -06:00
Eric Bailey be9032ebe9 Rename header 2024-12-11 14:04:55 -06:00
Eric Bailey 953229d767 Remove transform on native 2024-12-11 13:49:24 -06:00
Eric Bailey f33f1749cc Fix report dialog nesting 2024-12-11 13:45:55 -06:00
Eric Bailey 8a4aa869b6 Cleanup 2024-12-11 13:37:43 -06:00
Eric Bailey 2db6e5543c Cleanups 2024-12-11 13:30:06 -06:00
Eric Bailey e753cc64e3 Mostly there 2024-12-11 12:23:50 -06:00
Eric Bailey 6db9d09b73 Revert "Playing around"
This reverts commit f58a7fafa1.
2024-12-10 21:09:59 -06:00
Eric Bailey f58a7fafa1 Playing around 2024-12-10 21:09:50 -06:00
Eric Bailey 4d380a0da1 Lil baby button checkpoint 2024-12-10 20:23:56 -06:00
Eric Bailey d4efa913f4 Init hacking 2024-12-10 19:07:47 -06:00
9 changed files with 767 additions and 625 deletions
@@ -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>

After

Width:  |  Height:  |  Size: 323 B

+1 -1
View File
@@ -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 {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
@@ -77,6 +76,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 {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
+4
View File
@@ -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]}%)`,
+1
View File
@@ -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
+3 -1
View File
@@ -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}
/>
</>
+4
View File
@@ -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',
})
+227
View File
@@ -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,
},
})
@@ -0,0 +1,526 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyActorDefs,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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
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 * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
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 [likeUri, setLikeUri] = React.useState(info.likeUri || '')
const isLiked = !!likeUri
const likeCount =
isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0
const {data: preferences} = usePreferencesQuery()
const savedFeedConfig = preferences?.savedFeeds?.find(
f => f.value === info.uri,
)
const isPinned = Boolean(savedFeedConfig?.pinned)
const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
useAddSavedFeedsMutation()
const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} =
useUpdateSavedFeedsMutation()
const isOuterPinPending = isAddSavedFeedPending || isUpdateFeedPending
const onTogglePinned = async () => {
if (isOuterPinPending) return
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`Un-pinned ${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})
}
}
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>
<Button
label={
isPinned
? _(`Un-pin ${info.displayName} from Home`)
: _(msg`Pin ${info.displayName} to Home`)
}
size="small"
variant="ghost"
shape="square"
color="secondary"
onPress={onTogglePinned}>
{isPinned ? (
<PinFilled size="lg" fill={t.palette.primary_500} />
) : (
<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}
isOuterPinPending={isOuterPinPending}
savedFeedConfig={savedFeedConfig}
/>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
function DialogInner({
info,
likeUri,
setLikeUri,
likeCount,
isPinned,
onTogglePinned,
isOuterPinPending,
savedFeedConfig,
}: {
info: FeedSourceFeedInfo
likeUri: string
setLikeUri: (uri: string) => void
likeCount: number
isPinned: boolean
onTogglePinned: () => void
isOuterPinPending: boolean
savedFeedConfig: AppBskyActorDefs.SavedFeed | undefined
}) {
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 isSaved = Boolean(savedFeedConfig)
const {mutateAsync: removeFeed, isPending: isRemovePending} =
useRemoveFeedMutation()
const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
useAddSavedFeedsMutation()
const isFeedChangePending =
isOuterPinPending || isRemovePending || isAddSavedFeedPending
const onToggleSaved = async () => {
if (isFeedChangePending) return
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})
}
}
const onTogglePinnedInner = () => {
if (isFeedChangePending) return
onTogglePinned()
}
const onPressShare = () => {
const url = toShareUrl(info.route.href)
shareUrl(url)
}
const onPressReport = () => {
reportDialogControl.open()
}
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</Trans>{' '}
<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>
</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, {marginTop: -3}]}>
<Button
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={t.palette.like} />
) : (
<Heart size="md" fill={t.atoms.text_contrast_medium.color} />
)}
</Button>
{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()}>
<Plural
value={likeCount}
one="Liked by # user"
other="Liked by # users"
/>
</InlineLinkText>
)}
</View>
{hasSession && (
<>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Button
label={
isPinned
? _(`Remove ${info.displayName} from my saved feeds`)
: _(msg`Add ${info.displayName} to my saved feeds`)
}
size="small"
variant="solid"
color={'secondary'}
onPress={onToggleSaved}
style={[a.flex_1]}>
<ButtonText>
{isSaved ? <Trans>Saved</Trans> : <Trans>Save feed</Trans>}
</ButtonText>
<ButtonIcon icon={isSaved ? Check : Plus} position="right" />
</Button>
<Button
label={
isPinned
? _(`Un-pin ${info.displayName} from Home`)
: _(msg`Pin ${info.displayName} to Home`)
}
size="small"
variant="solid"
color={isPinned ? 'secondary_inverted' : 'primary'}
onPress={onTogglePinnedInner}
style={[a.flex_1]}>
<ButtonText>
{isPinned ? (
<Trans>Pinned</Trans>
) : isSaved ? (
<Trans>Pin</Trans>
) : (
<Trans>Pin and save</Trans>
)}
</ButtonText>
<ButtonIcon icon={isPinned ? Check : 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>
)
}
-623
View File
@@ -1,623 +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]}>
<Plural
value={likeCount}
one="Liked by # user"
other="Liked by # users"
/>
</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,
},
})