diff options
-rw-r--r-- | src/lib/hooks/useFollowDid.ts | 46 | ||||
-rw-r--r-- | src/view/com/modals/ProfilePreview.tsx | 7 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 47 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 52 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeaderSuggestedFollows.tsx | 288 |
5 files changed, 406 insertions, 34 deletions
diff --git a/src/lib/hooks/useFollowDid.ts b/src/lib/hooks/useFollowDid.ts new file mode 100644 index 000000000..223adb047 --- /dev/null +++ b/src/lib/hooks/useFollowDid.ts @@ -0,0 +1,46 @@ +import React from 'react' + +import {useStores} from 'state/index' +import {FollowState} from 'state/models/cache/my-follows' + +export function useFollowDid({did}: {did: string}) { + const store = useStores() + const state = store.me.follows.getFollowState(did) + + return { + state, + following: state === FollowState.Following, + toggle: React.useCallback(async () => { + if (state === FollowState.Following) { + try { + await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + return { + state: FollowState.NotFollowing, + following: false, + } + } catch (e: any) { + store.log.error('Failed to delete follow', e) + throw e + } + } else if (state === FollowState.NotFollowing) { + try { + const res = await store.agent.follow(did) + store.me.follows.addFollow(did, res.uri) + return { + state: FollowState.Following, + following: true, + } + } catch (e: any) { + store.log.error('Failed to create follow', e) + throw e + } + } + + return { + state: FollowState.Unknown, + following: false, + } + }, [store, did, state]), + } +} diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index 6f189cf1a..e0b3ec072 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -41,7 +41,12 @@ export const Component = observer(function ProfilePreviewImpl({ styles.headerWrapper, isLoading && isIOS && styles.headerPositionAdjust, ]}> - <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> + <ProfileHeader + view={model} + hideBackButton + onRefreshAll={() => {}} + isProfilePreview + /> </View> <View style={[styles.hintWrapper, pal.view]}> <View style={styles.hint}> diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 4b2b944f7..217d326e8 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -2,9 +2,9 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' -import {useStores} from 'state/index' import * as Toast from '../util/Toast' import {FollowState} from 'state/models/cache/my-follows' +import {useFollowDid} from 'lib/hooks/useFollowDid' export const FollowButton = observer(function FollowButtonImpl({ unfollowedType = 'inverted', @@ -19,44 +19,27 @@ export const FollowButton = observer(function FollowButtonImpl({ onToggleFollow?: (v: boolean) => void labelStyle?: StyleProp<TextStyle> }) { - const store = useStores() - const followState = store.me.follows.getFollowState(did) + const {state, following, toggle} = useFollowDid({did}) - if (followState === FollowState.Unknown) { - return <View /> - } - - const onToggleFollowInner = async () => { - const updatedFollowState = await store.me.follows.fetchFollowState(did) - if (updatedFollowState === FollowState.Following) { - try { - onToggleFollow?.(false) - await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) - store.me.follows.removeFollow(did) - } catch (e: any) { - store.log.error('Failed to delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } else if (updatedFollowState === FollowState.NotFollowing) { - try { - onToggleFollow?.(true) - const res = await store.agent.follow(did) - store.me.follows.addFollow(did, res.uri) - } catch (e: any) { - store.log.error('Failed to create follow', e) - Toast.show('An issue occurred, please try again.') - } + const onPress = React.useCallback(async () => { + try { + const {following} = await toggle() + onToggleFollow?.(following) + } catch (e: any) { + Toast.show('An issue occurred, please try again.') } + }, [toggle, onToggleFollow]) + + if (state === FollowState.Unknown) { + return <View /> } return ( <Button - type={ - followState === FollowState.Following ? followedType : unfollowedType - } + type={following ? followedType : unfollowedType} labelStyle={labelStyle} - onPress={onToggleFollowInner} - label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} + onPress={onPress} + label={following ? 'Unfollow' : 'Follow'} withLoading={true} /> ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index cafb37743..7f3e52d96 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -38,17 +38,20 @@ import {BACK_HITSLOP} from 'lib/constants' import {isInvalidHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {Link} from '../util/Link' +import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' interface Props { view: ProfileModel onRefreshAll: () => void hideBackButton?: boolean + isProfilePreview?: boolean } export const ProfileHeader = observer(function ProfileHeaderImpl({ view, onRefreshAll, hideBackButton = false, + isProfilePreview, }: Props) { const pal = usePalette('default') @@ -95,6 +98,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ view={view} onRefreshAll={onRefreshAll} hideBackButton={hideBackButton} + isProfilePreview={isProfilePreview} /> ) }) @@ -103,6 +107,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ view, onRefreshAll, hideBackButton = false, + isProfilePreview, }: Props) { const pal = usePalette('default') const palInverted = usePalette('inverted') @@ -111,6 +116,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ const {track} = useAnalytics() const invalidHandle = isInvalidHandle(view.handle) const {isDesktop} = useWebMediaQueries() + const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const onPressBack = React.useCallback(() => { navigation.goBack() @@ -133,6 +139,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ ) view?.toggleFollowing().then( () => { + setShowSuggestedFollows(Boolean(view.viewer.following)) + Toast.show( `${ view.viewer.following ? 'Following' : 'No longer following' @@ -141,7 +149,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, err => store.log.error('Failed to toggle follow', err), ) - }, [track, view, store.log]) + }, [track, view, store.log, setShowSuggestedFollows]) const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') @@ -373,6 +381,39 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </TouchableOpacity> ) : !view.viewer.blockedBy ? ( <> + {!isProfilePreview && ( + <TouchableOpacity + testID="suggestedFollowsBtn" + onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} + style={[ + styles.btn, + styles.mainBtn, + pal.btn, + { + paddingHorizontal: 10, + backgroundColor: showSuggestedFollows + ? colors.blue3 + : pal.viewLight.backgroundColor, + }, + ]} + accessibilityRole="button" + accessibilityLabel={`Show follows similar to ${view.handle}`} + accessibilityHint={`Shows a list of users similar to this user.`}> + <FontAwesomeIcon + icon="user-plus" + style={[ + pal.text, + { + color: showSuggestedFollows + ? colors.white + : pal.text.color, + }, + ]} + size={14} + /> + </TouchableOpacity> + )} + {store.me.follows.getFollowState(view.did) === FollowState.Following ? ( <TouchableOpacity @@ -504,6 +545,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ )} <ProfileHeaderAlerts moderation={view.moderation} /> </View> + + {!isProfilePreview && ( + <ProfileHeaderSuggestedFollows + actorDid={view.did} + active={showSuggestedFollows} + requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)} + /> + )} + {!isDesktop && !hideBackButton && ( <TouchableWithoutFeedback onPress={onPressBack} diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx new file mode 100644 index 000000000..0199c9b39 --- /dev/null +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -0,0 +1,288 @@ +import React from 'react' +import {View, StyleSheet, ScrollView, Pressable} from 'react-native' +import Animated, { + useSharedValue, + withTiming, + useAnimatedStyle, + Easing, +} from 'react-native-reanimated' +import {useQuery} from '@tanstack/react-query' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' + +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {useFollowDid} from 'lib/hooks/useFollowDid' +import {Button} from 'view/com/util/forms/Button' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {Link} from 'view/com/util/Link' + +const OUTER_PADDING = 10 +const INNER_PADDING = 14 +const TOTAL_HEIGHT = 250 + +export function ProfileHeaderSuggestedFollows({ + actorDid, + active, + requestDismiss, +}: { + actorDid: string + active: boolean + requestDismiss: () => void +}) { + const pal = usePalette('default') + const store = useStores() + const animatedHeight = useSharedValue(0) + const animatedStyles = useAnimatedStyle(() => ({ + opacity: animatedHeight.value / TOTAL_HEIGHT, + height: animatedHeight.value, + })) + + React.useEffect(() => { + if (active) { + animatedHeight.value = withTiming(TOTAL_HEIGHT, { + duration: 500, + easing: Easing.inOut(Easing.exp), + }) + } else { + animatedHeight.value = withTiming(0, { + duration: 500, + easing: Easing.inOut(Easing.exp), + }) + } + }, [active, animatedHeight]) + + const {isLoading, data: suggestedFollows} = useQuery({ + enabled: active, + cacheTime: 0, + staleTime: 0, + queryKey: ['suggested_follows_by_actor', actorDid], + async queryFn() { + try { + const { + data: {suggestions}, + success, + } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({ + actor: actorDid, + }) + + if (!success) { + return [] + } + + store.me.follows.hydrateProfiles(suggestions) + + return suggestions + } catch (e) { + return [] + } + }, + }) + + return ( + <Animated.View style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}> + <View style={{paddingVertical: OUTER_PADDING}}> + <View + style={{ + backgroundColor: pal.viewLight.backgroundColor, + height: '100%', + paddingTop: INNER_PADDING / 2, + paddingBottom: INNER_PADDING, + }}> + <View + style={{ + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 4, + paddingBottom: INNER_PADDING / 2, + paddingLeft: INNER_PADDING, + paddingRight: INNER_PADDING / 2, + }}> + <Text type="sm-bold" style={[pal.textLight]}> + Suggested for you + </Text> + + <Pressable + accessibilityRole="button" + onPress={requestDismiss} + hitSlop={10} + style={{padding: INNER_PADDING / 2}}> + <FontAwesomeIcon + icon="x" + size={12} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + </View> + + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + alignItems: 'flex-start', + paddingLeft: INNER_PADDING / 2, + }}> + {isLoading ? ( + <> + <SuggestedFollowSkeleton /> + <SuggestedFollowSkeleton /> + <SuggestedFollowSkeleton /> + <SuggestedFollowSkeleton /> + <SuggestedFollowSkeleton /> + <SuggestedFollowSkeleton /> + </> + ) : suggestedFollows ? ( + suggestedFollows.map(profile => ( + <SuggestedFollow key={profile.did} profile={profile} /> + )) + ) : ( + <View /> + )} + </ScrollView> + </View> + </View> + </Animated.View> + ) +} + +function SuggestedFollowSkeleton() { + const pal = usePalette('default') + return ( + <View + style={[ + styles.suggestedFollowCardOuter, + { + backgroundColor: pal.view.backgroundColor, + }, + ]}> + <View + style={{ + height: 60, + width: 60, + borderRadius: 60, + backgroundColor: pal.viewLight.backgroundColor, + opacity: 0.6, + }} + /> + <View + style={{ + height: 17, + width: 70, + borderRadius: 4, + backgroundColor: pal.viewLight.backgroundColor, + marginTop: 12, + marginBottom: 4, + }} + /> + <View + style={{ + height: 12, + width: 70, + borderRadius: 4, + backgroundColor: pal.viewLight.backgroundColor, + marginBottom: 12, + opacity: 0.6, + }} + /> + <View + style={{ + height: 32, + borderRadius: 32, + width: '100%', + backgroundColor: pal.viewLight.backgroundColor, + }} + /> + </View> + ) +} + +const SuggestedFollow = observer(function SuggestedFollowImpl({ + profile, +}: { + profile: AppBskyActorDefs.ProfileView +}) { + const pal = usePalette('default') + const store = useStores() + const {following, toggle} = useFollowDid({did: profile.did}) + const moderation = moderateProfile(profile, store.preferences.moderationOpts) + + const onPress = React.useCallback(async () => { + try { + await toggle() + } catch (e: any) { + Toast.show('An issue occurred, please try again.') + } + }, [toggle]) + + return ( + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline> + <View + style={[ + styles.suggestedFollowCardOuter, + { + backgroundColor: pal.view.backgroundColor, + }, + ]}> + <UserAvatar + size={60} + avatar={profile.avatar} + moderation={moderation.avatar} + /> + + <View style={{width: '100%', paddingVertical: 12}}> + <Text + type="xs-medium" + style={[pal.text, {textAlign: 'center'}]} + numberOfLines={1}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + </Text> + <Text + type="xs-medium" + style={[pal.textLight, {textAlign: 'center'}]} + numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + + <Button + label={following ? 'Unfollow' : 'Follow'} + type="inverted" + labelStyle={{textAlign: 'center'}} + onPress={onPress} + withLoading + /> + </View> + </Link> + ) +}) + +const styles = StyleSheet.create({ + suggestedFollowCardOuter: { + marginHorizontal: INNER_PADDING / 2, + paddingTop: 10, + paddingBottom: 12, + paddingHorizontal: 10, + borderRadius: 8, + width: 130, + alignItems: 'center', + overflow: 'hidden', + flexShrink: 1, + }, +}) |