From e1938931e028f4486cce8cd9da39ffd940c10ec2 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 13 Nov 2023 18:35:15 +0000 Subject: [PATCH] Refactor profile screen to use new pager and react-query (#1870) * Profile tabs WIP * Refactor the profile screen to use react-query (WIP) * Add the profile shadow and get follow, mute, and block working * Cleanup --------- Co-authored-by: Paul Frazee --- src/state/cache/profile-shadow.ts | 88 ++++ src/state/queries/profile.ts | 166 ++++++- src/state/queries/resolve-uri.ts | 15 +- src/view/com/posts/FeedErrorMessage.tsx | 8 +- src/view/com/profile/ProfileHeader.tsx | 325 ++++++++------ src/view/screens/PostThread.tsx | 6 +- src/view/screens/Profile.tsx | 572 ++++++++++++------------ src/view/screens/ProfileList.tsx | 2 +- src/view/shell/desktop/LeftNav.tsx | 4 +- 9 files changed, 730 insertions(+), 456 deletions(-) create mode 100644 src/state/cache/profile-shadow.ts diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts new file mode 100644 index 00000000..a1cf5995 --- /dev/null +++ b/src/state/cache/profile-shadow.ts @@ -0,0 +1,88 @@ +import {useEffect, useState, useCallback, useRef} from 'react' +import EventEmitter from 'eventemitter3' +import {AppBskyActorDefs} from '@atproto/api' + +const emitter = new EventEmitter() + +export interface ProfileShadow { + followingUri: string | undefined + muted: boolean | undefined + blockingUri: string | undefined +} + +interface CacheEntry { + ts: number + value: ProfileShadow +} + +type ProfileView = + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed + +export function useProfileShadow( + profile: T, + ifAfterTS: number, +): T { + const [state, setState] = useState({ + ts: Date.now(), + value: fromProfile(profile), + }) + const firstRun = useRef(true) + + const onUpdate = useCallback( + (value: Partial) => { + setState(s => ({ts: Date.now(), value: {...s.value, ...value}})) + }, + [setState], + ) + + // react to shadow updates + useEffect(() => { + emitter.addListener(profile.did, onUpdate) + return () => { + emitter.removeListener(profile.did, onUpdate) + } + }, [profile.did, onUpdate]) + + // react to profile updates + useEffect(() => { + // dont fire on first run to avoid needless re-renders + if (!firstRun.current) { + setState({ts: Date.now(), value: fromProfile(profile)}) + } + firstRun.current = false + }, [profile]) + + return state.ts > ifAfterTS ? mergeShadow(profile, state.value) : profile +} + +export function updateProfileShadow( + uri: string, + value: Partial, +) { + emitter.emit(uri, value) +} + +function fromProfile(profile: ProfileView): ProfileShadow { + return { + followingUri: profile.viewer?.following, + muted: profile.viewer?.muted, + blockingUri: profile.viewer?.blocking, + } +} + +function mergeShadow( + profile: T, + shadow: ProfileShadow, +): T { + return { + ...profile, + viewer: { + ...(profile.viewer || {}), + following: shadow.followingUri, + muted: shadow.muted, + blocking: shadow.blockingUri, + }, + } +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index c2cd1948..1bd28d5b 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,13 +1,169 @@ -import {useQuery} from '@tanstack/react-query' +import {AtUri} from '@atproto/api' +import {useQuery, useMutation} from '@tanstack/react-query' +import {useSession} from '../session' +import {updateProfileShadow} from '../cache/profile-shadow' -import {PUBLIC_BSKY_AGENT} from '#/state/queries' +export const RQKEY = (did: string) => ['profile', did] -export function useProfileQuery({did}: {did: string}) { +export function useProfileQuery({did}: {did: string | undefined}) { + const {agent} = useSession() return useQuery({ - queryKey: ['getProfile', did], + queryKey: RQKEY(did), queryFn: async () => { - const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did}) + const res = await agent.getProfile({actor: did || ''}) return res.data }, + enabled: !!did, + }) +} + +export function useProfileFollowMutation() { + const {agent} = useSession() + return useMutation<{uri: string; cid: string}, Error, {did: string}>({ + mutationFn: async ({did}) => { + return await agent.follow(did) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + followingUri: 'pending', + }) + }, + onSuccess(data, variables) { + // finalize + updateProfileShadow(variables.did, { + followingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + }, + }) +} + +export function useProfileUnfollowMutation() { + const {agent} = useSession() + return useMutation({ + mutationFn: async ({followUri}) => { + return await agent.deleteFollow(followUri) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: variables.followUri, + }) + }, + }) +} + +export function useProfileMuteMutation() { + const {agent} = useSession() + return useMutation({ + mutationFn: async ({did}) => { + await agent.mute(did) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: true, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: false, + }) + }, + }) +} + +export function useProfileUnmuteMutation() { + const {agent} = useSession() + return useMutation({ + mutationFn: async ({did}) => { + await agent.unmute(did) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: false, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: true, + }) + }, + }) +} + +export function useProfileBlockMutation() { + const {agent, currentAccount} = useSession() + return useMutation<{uri: string; cid: string}, Error, {did: string}>({ + mutationFn: async ({did}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + return await agent.app.bsky.graph.block.create( + {repo: currentAccount.did}, + {subject: did, createdAt: new Date().toISOString()}, + ) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + blockingUri: 'pending', + }) + }, + onSuccess(data, variables) { + // finalize + updateProfileShadow(variables.did, { + blockingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + }, + }) +} + +export function useProfileUnblockMutation() { + const {agent, currentAccount} = useSession() + return useMutation({ + mutationFn: async ({blockUri}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + const {rkey} = new AtUri(blockUri) + await agent.app.bsky.graph.block.delete({ + repo: currentAccount.did, + rkey, + }) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: variables.blockUri, + }) + }, }) } diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 26e0a475..83bccdce 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -4,17 +4,22 @@ import {useSession} from '../session' export const RQKEY = (uri: string) => ['resolved-uri', uri] -export function useResolveUriQuery(uri: string) { +export function useResolveUriQuery(uri: string | undefined) { const {agent} = useSession() - return useQuery({ - queryKey: RQKEY(uri), + return useQuery<{uri: string; did: string}, Error>({ + queryKey: RQKEY(uri || ''), async queryFn() { - const urip = new AtUri(uri) + const urip = new AtUri(uri || '') if (!urip.host.startsWith('did:')) { const res = await agent.resolveHandle({handle: urip.host}) urip.host = res.data.did } - return urip.toString() + return {did: urip.host, uri: urip.toString()} }, + enabled: !!uri, }) } + +export function useResolveDidQuery(didOrHandle: string | undefined) { + return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined) +} diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index feb4b1c9..e29b35f8 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -13,6 +13,7 @@ import {logger} from '#/logger' import {useModalControls} from '#/state/modals' import {FeedDescriptor} from '#/state/queries/post-feed' import {EmptyState} from '../util/EmptyState' +import {cleanError} from '#/lib/strings/errors' enum KnownError { Block, @@ -69,7 +70,12 @@ export function FeedErrorMessage({ ) } - return + return ( + + ) } function FeedgenErrorMessage({ diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index d7b7b8ed..ea3b8630 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import { StyleSheet, TouchableOpacity, @@ -8,15 +7,17 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import { + AppBskyActorDefs, + ProfileModeration, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NavigationProp} from 'lib/routes/types' +import {isNative} from 'platform/detection' import {BlurView} from '../util/BlurView' -import {ProfileModel} from 'state/models/content/profile' -import {useStores} from 'state/index' import {ProfileImageLightbox} from 'state/models/ui/shell' -import {pluralize} from 'lib/strings/helpers' -import {toShareUrl} from 'lib/strings/url-helpers' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s, colors} from 'lib/styles' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' @@ -25,35 +26,45 @@ import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' +import {formatCount} from '../util/numeric/format' +import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' +import {Link} from '../util/Link' +import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' +import {useStores} from 'state/index' +import {useModalControls} from '#/state/modals' +import { + useProfileFollowMutation, + useProfileUnfollowMutation, + useProfileMuteMutation, + useProfileUnmuteMutation, + useProfileBlockMutation, + useProfileUnblockMutation, +} from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isNative} from 'platform/detection' -import {FollowState} from 'state/models/cache/my-follows' -import {shareUrl} from 'lib/sharing' -import {formatCount} from '../util/numeric/format' -import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' 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' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {shareUrl} from 'lib/sharing' +import {s, colors} from 'lib/styles' import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' +import {useSession} from '#/state/session' interface Props { - view: ProfileModel - onRefreshAll: () => void + profile: AppBskyActorDefs.ProfileViewDetailed + moderation: ProfileModeration hideBackButton?: boolean isProfilePreview?: boolean } -export const ProfileHeader = observer(function ProfileHeaderImpl({ - view, - onRefreshAll, +export function ProfileHeader({ + profile, + moderation, hideBackButton = false, isProfilePreview, }: Props) { @@ -61,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ // loading // = - if (!view || !view.hasLoaded) { + if (!profile) { return ( @@ -75,9 +86,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - )} + Loading... @@ -85,44 +94,48 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ ) } - // error - // = - if (view.hasError) { - return ( - - {view.error} - - ) - } - // loaded // = return ( ) -}) +} -const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ - view, - onRefreshAll, +function ProfileHeaderLoaded({ + profile, + moderation, hideBackButton = false, isProfilePreview, }: Props) { const pal = usePalette('default') const palInverted = usePalette('inverted') const store = useStores() + const {currentAccount} = useSession() const {_} = useLingui() const {openModal} = useModalControls() const navigation = useNavigation() const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(view.handle) + const invalidHandle = isInvalidHandle(profile.handle) const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) + const descriptionRT = React.useMemo( + () => + profile.description + ? new RichTextAPI({text: profile.description}) + : undefined, + [profile], + ) + const followMutation = useProfileFollowMutation() + const unfollowMutation = useProfileUnfollowMutation() + const muteMutation = useProfileMuteMutation() + const unmuteMutation = useProfileUnmuteMutation() + const blockMutation = useProfileBlockMutation() + const unblockMutation = useProfileUnblockMutation() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -134,86 +147,95 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ const onPressAvi = React.useCallback(() => { if ( - view.avatar && - !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + profile.avatar && + !(moderation.avatar.blur && moderation.avatar.noOverride) ) { - store.shell.openLightbox(new ProfileImageLightbox(view)) + store.shell.openLightbox(new ProfileImageLightbox(profile)) } - }, [store, view]) + }, [store, profile, moderation]) - const onPressToggleFollow = React.useCallback(() => { - view?.toggleFollowing().then( - () => { - setShowSuggestedFollows(Boolean(view.viewer.following)) - Toast.show( - `${ - view.viewer.following ? 'Following' : 'No longer following' - } ${sanitizeDisplayName(view.displayName || view.handle)}`, - ) - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', - ) - }, - err => logger.error('Failed to toggle follow', {error: err}), - ) - }, [track, view, setShowSuggestedFollows]) + const onPressFollow = React.useCallback(async () => { + if (profile.viewer?.following) { + return + } + try { + track('ProfileHeader:FollowButtonClicked') + await followMutation.mutateAsync({did: profile.did}) + Toast.show( + `Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ) + } catch (e: any) { + logger.error('Failed to follow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, [followMutation, profile, track]) + + const onPressUnfollow = React.useCallback(async () => { + if (!profile.viewer?.following) { + return + } + try { + track('ProfileHeader:UnfollowButtonClicked') + await unfollowMutation.mutateAsync({ + did: profile.did, + followUri: profile.viewer?.following, + }) + Toast.show( + `No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ) + } catch (e: any) { + logger.error('Failed to unfollow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, [unfollowMutation, profile, track]) const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') openModal({ name: 'edit-profile', - profileView: view, - onUpdate: onRefreshAll, + profileView: profile, }) - }, [track, openModal, view, onRefreshAll]) - - const trackPress = React.useCallback( - (f: 'Followers' | 'Follows') => { - track(`ProfileHeader:${f}ButtonClicked`, { - handle: view.handle, - }) - }, - [track, view], - ) + }, [track, openModal, profile]) const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') - const url = toShareUrl(makeProfileLink(view)) - shareUrl(url) - }, [track, view]) + shareUrl(toShareUrl(makeProfileLink(profile))) + }, [track, profile]) const onPressAddRemoveLists = React.useCallback(() => { track('ProfileHeader:AddToListsButtonClicked') openModal({ name: 'user-add-remove-lists', - subject: view.did, - displayName: view.displayName || view.handle, + subject: profile.did, + displayName: profile.displayName || profile.handle, }) - }, [track, view, openModal]) + }, [track, profile, openModal]) const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { - await view.muteAccount() + await muteMutation.mutateAsync({did: profile.did}) Toast.show('Account muted') } catch (e: any) { logger.error('Failed to mute account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } - }, [track, view]) + }, [track, muteMutation, profile]) const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { - await view.unmuteAccount() + await unmuteMutation.mutateAsync({did: profile.did}) Toast.show('Account unmuted') } catch (e: any) { logger.error('Failed to unmute account', {error: e}) Toast.show(`There was an issue! ${e.toString()}`) } - }, [track, view]) + }, [track, unmuteMutation, profile]) const onPressBlockAccount = React.useCallback(async () => { track('ProfileHeader:BlockAccountButtonClicked') @@ -223,9 +245,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ message: 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', onPressConfirm: async () => { + if (profile.viewer?.blocking) { + return + } try { - await view.blockAccount() - onRefreshAll() + await blockMutation.mutateAsync({did: profile.did}) Toast.show('Account blocked') } catch (e: any) { logger.error('Failed to block account', {error: e}) @@ -233,7 +257,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ } }, }) - }, [track, view, openModal, onRefreshAll]) + }, [track, blockMutation, profile, openModal]) const onPressUnblockAccount = React.useCallback(async () => { track('ProfileHeader:UnblockAccountButtonClicked') @@ -243,9 +267,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ message: 'The account will be able to interact with you after unblocking.', onPressConfirm: async () => { + if (!profile.viewer?.blocking) { + return + } try { - await view.unblockAccount() - onRefreshAll() + await unblockMutation.mutateAsync({ + did: profile.did, + blockUri: profile.viewer.blocking, + }) Toast.show('Account unblocked') } catch (e: any) { logger.error('Failed to unblock account', {error: e}) @@ -253,19 +282,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ } }, }) - }, [track, view, openModal, onRefreshAll]) + }, [track, unblockMutation, profile, openModal]) const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') openModal({ name: 'report', - did: view.did, + did: profile.did, }) - }, [track, openModal, view]) + }, [track, openModal, profile]) const isMe = React.useMemo( - () => store.me.did === view.did, - [store.me.did, view.did], + () => currentAccount?.did === profile.did, + [currentAccount, profile], ) const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ @@ -296,11 +325,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }) if (!isMe) { - if (!view.viewer.blocking) { + if (!profile.viewer?.blocking) { items.push({ testID: 'profileHeaderDropdownMuteBtn', - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted + label: profile.viewer?.muted ? 'Unmute Account' : 'Mute Account', + onPress: profile.viewer?.muted ? onPressUnmuteAccount : onPressMuteAccount, icon: { @@ -312,11 +341,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }) } - if (!view.viewer.blockingByList) { + if (!profile.viewer?.blockingByList) { items.push({ testID: 'profileHeaderDropdownBlockBtn', - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', - onPress: view.viewer.blocking + label: profile.viewer?.blocking ? 'Unblock Account' : 'Block Account', + onPress: profile.viewer?.blocking ? onPressUnblockAccount : onPressBlockAccount, icon: { @@ -344,9 +373,9 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ return items }, [ isMe, - view.viewer.muted, - view.viewer.blocking, - view.viewer.blockingByList, + profile.viewer?.muted, + profile.viewer?.blocking, + profile.viewer?.blockingByList, onPressShare, onPressUnmuteAccount, onPressMuteAccount, @@ -356,14 +385,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPressAddRemoveLists, ]) - const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) - const following = formatCount(view.followsCount) - const followers = formatCount(view.followersCount) - const pluralizedFollowers = pluralize(view.followersCount, 'follower') + const blockHide = + !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) + const following = formatCount(profile.followsCount || 0) + const followers = formatCount(profile.followersCount || 0) + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( - + {isMe ? ( @@ -378,8 +408,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ Edit Profile - ) : view.viewer.blocking ? ( - view.viewer.blockingByList ? null : ( + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( ) - ) : !view.viewer.blockedBy ? ( + ) : !profile.viewer?.blockedBy ? ( <> {!isProfilePreview && ( )} - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( + {profile.viewer?.following ? ( + accessibilityLabel={`Unfollow ${profile.handle}`} + accessibilityHint={`Hides posts from ${profile.handle} in your feed`}> + accessibilityLabel={`Follow ${profile.handle}`} + accessibilityHint={`Shows posts from ${profile.handle} in your feed`}> {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - view.moderation.profile, + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} - {view.viewer.followedBy && !blockHide ? ( + {profile.viewer?.followedBy && !blockHide ? ( Follows you @@ -503,7 +532,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ invalidHandle ? styles.invalidHandle : undefined, styles.handle, ]}> - {invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`} + {invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`} {!blockHide && ( @@ -512,8 +541,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ trackPress('Followers')} + href={makeProfileLink(profile, 'followers')} + onPressOut={() => + track(`ProfileHeader:FollowersButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${followers} ${pluralizedFollowers}`} accessibilityHint={'Opens followers list'}> @@ -527,8 +560,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ trackPress('Follows')} + href={makeProfileLink(profile, 'follows')} + onPressOut={() => + track(`ProfileHeader:FollowsButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${following} following`} accessibilityHint={'Opens following list'}> @@ -540,30 +577,28 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ - {formatCount(view.postsCount)}{' '} + {formatCount(profile.postsCount || 0)}{' '} - {pluralize(view.postsCount, 'post')} + {pluralize(profile.postsCount || 0, 'post')} - {view.description && - view.descriptionRichText && - !view.moderation.profile.blur ? ( + {descriptionRT && !moderation.profile.blur ? ( ) : undefined} )} - + {!isProfilePreview && ( setShowSuggestedFollows(!showSuggestedFollows)} /> @@ -588,20 +623,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ testID="profileHeaderAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityLabel={`View ${profile.handle}'s avatar`} accessibilityHint=""> ) -}) +} const styles = StyleSheet.create({ banner: { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index b254c1ec..9536e86e 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -49,7 +49,7 @@ export const PostThreadScreen = withAuthRequired( return } const thread = queryClient.getQueryData( - POST_THREAD_RQKEY(resolvedUri), + POST_THREAD_RQKEY(resolvedUri.uri), ) if (thread?.type !== 'post') { return @@ -67,7 +67,7 @@ export const PostThreadScreen = withAuthRequired( }, onPost: () => queryClient.invalidateQueries({ - queryKey: POST_THREAD_RQKEY(resolvedUri || ''), + queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), }), }) }, [store, queryClient, resolvedUri]) @@ -82,7 +82,7 @@ export const PostThreadScreen = withAuthRequired( ) : ( diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 945a8cc2..dab8988a 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,323 +1,307 @@ -import React, {useEffect, useState} from 'react' +import React, {useMemo} from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {useFocusEffect} from '@react-navigation/native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector' +import {ViewSelectorHandle} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel, Sections} from 'state/models/ui/profile' +import {Feed} from 'view/com/posts/Feed' import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {FeedSlice} from '../com/posts/FeedSlice' -import {ListCard} from 'view/com/lists/ListCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '../com/util/LoadingPlaceholder' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' -import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useSetMinimalShellMode} from '#/state/shell' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useProfileQuery} from '#/state/queries/profile' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {cleanError} from '#/lib/strings/errors' + +const SECTION_TITLES_PROFILE = ['Posts', 'Posts & Replies', 'Media', 'Likes'] type Props = NativeStackScreenProps -export const ProfileScreen = withAuthRequired( - observer(function ProfileScreenImpl({route}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {_} = useLingui() - const viewSelectorRef = React.useRef(null) - const name = route.params.name === 'me' ? store.me.did : route.params.name +export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ + route, +}: Props) { + const {currentAccount} = useSession() + const name = + route.params.name === 'me' ? currentAccount?.did : route.params.name + const moderationOpts = useModerationOpts() + const { + data: resolvedDid, + error: resolveError, + refetch: refetchDid, + isFetching: isFetchingDid, + } = useResolveDidQuery(name) + const { + data: profile, + dataUpdatedAt, + error: profileError, + refetch: refetchProfile, + isFetching: isFetchingProfile, + } = useProfileQuery({ + did: resolvedDid?.did, + }) - useEffect(() => { - screen('Profile') - }, [screen]) - - const [hasSetup, setHasSetup] = useState(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], - ) - useSetTitle(combinedDisplayName(uiState.profile)) - - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) - - useEffect(() => { - setHasSetup(false) - }, [name]) - - // We don't need this to be reactive, so we can just register the listeners once - useEffect(() => { - const listCleanup = uiState.lists.registerListeners() - return () => listCleanup() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - let aborted = false - setMinimalShellMode(false) - const feedCleanup = uiState.feed.registerListeners() - if (!hasSetup) { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - softResetSub.remove() - } - }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), - ) - - // events - // = - - const onPressCompose = React.useCallback(() => { - track('ProfileScreen:PressCompose') - const mention = - uiState.profile.handle === store.me.handle || - uiState.profile.handle === 'handle.invalid' - ? undefined - : uiState.profile.handle - store.shell.openComposer({mention}) - }, [store, track, uiState]) - const onSelectView = React.useCallback( - (index: number) => { - uiState.setSelectedViewIndex(index) - }, - [uiState], - ) - const onRefresh = React.useCallback(() => { - uiState - .refresh() - .catch((err: any) => - logger.error('Failed to refresh user profile', {error: err}), - ) - }, [uiState]) - const onEndReached = React.useCallback(() => { - uiState.loadMore().catch((err: any) => - logger.error('Failed to load more entries in user profile', { - error: err, - }), - ) - }, [uiState]) - const onPressTryAgain = React.useCallback(() => { - uiState.setup() - }, [uiState]) - - // rendering - // = - - const renderHeader = React.useCallback(() => { - if (!uiState) { - return - } - return ( - - ) - }, [uiState, onRefresh, route.params.hideBackButton]) - - const Footer = React.useMemo(() => { - return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined - }, [uiState.showLoadingMoreFooter]) - const renderItem = React.useCallback( - (item: any) => { - // if section is lists - if (uiState.selectedView === Sections.Lists) { - if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else { - return - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else if (item instanceof FeedSourceModel) { - return ( - - ) - } - // if section is posts or posts & replies - } else { - if (item === ProfileUiModel.END_ITEM) { - return ( - - - end of feed - - - ) - } else if (item === ProfileUiModel.LOADING_ITEM) { - return - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { - return ( - - ) - } - if (uiState.feed.isBlockedBy) { - return ( - - ) - } - return ( - - - - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - - ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - - ) - } - } - return - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) + const onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) + if (isFetchingDid || isFetchingProfile) { return ( - - {uiState.profile.hasError ? ( - - ) : uiState.profile.hasLoaded ? ( - - ) : ( - {renderHeader()} - )} - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - + + + + + ) - }), -) - -function LoadingMoreFooter() { + } + if (resolveError || profileError) { + return ( + + + + ) + } + if (profile && moderationOpts) { + return ( + + ) + } + // should never happen return ( - - - + + + + ) +}) + +function ProfileScreenLoaded({ + profile: profileUnshadowed, + dataUpdatedAt, + moderationOpts, + hideBackButton, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + dataUpdatedAt: number + moderationOpts: ModerationOpts + hideBackButton: boolean +}) { + const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt) + const store = useStores() + const {currentAccount} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen, track} = useAnalytics() + const [currentPage, setCurrentPage] = React.useState(0) + const {_} = useLingui() + const viewSelectorRef = React.useRef(null) + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + + useSetTitle(combinedDisplayName(profile)) + + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) + + /* + - todo + - feeds + - lists + */ + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + const softResetSub = store.onScreenSoftReset(() => { + viewSelectorRef.current?.scrollToTop() + }) + return () => softResetSub.remove() + }, [store, viewSelectorRef, setMinimalShellMode, screen]), + ) + + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) + } + }, [setDrawerSwipeDisabled, currentPage]), + ) + + // events + // = + + const onPressCompose = React.useCallback(() => { + track('ProfileScreen:PressCompose') + const mention = + profile.handle === currentAccount?.handle || + profile.handle === 'handle.invalid' + ? undefined + : profile.handle + store.shell.openComposer({mention}) + }, [store, currentAccount, track, profile]) + + const onPageSelected = React.useCallback( + i => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + // rendering + // = + + const renderHeader = React.useCallback(() => { + return ( + + ) + }, [profile, moderation, hideBackButton]) + + return ( + + + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + + )} + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + ) } +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isScrolledDown: boolean + scrollElRef: any /* TODO */ +} +const FeedSection = React.forwardRef( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isScrolledDown, scrollElRef}, + ref, + ) { + const hasNew = false //TODO feed.hasNewLatest && !feed.isRefreshing + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + // feed.refresh() TODO + }, [feed, scrollElRef, headerHeight]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return + }, []) + + return ( + + + + ) + }, +) + const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 18665f51..42c3741d 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -70,7 +70,7 @@ export const ProfileListScreen = withAuthRequired( const {data: resolvedUri, error: resolveError} = useResolveUriQuery( AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), ) - const {data: list, error: listError} = useListQuery(resolvedUri) + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) if (resolveError) { return ( diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index c9a03ce6..d7814cb5 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -251,7 +251,7 @@ function ComposeBtn() { } export const DesktopLeftNav = observer(function DesktopLeftNav() { - const store = useStores() + const {currentAccount} = useSession() const pal = usePalette('default') const {isDesktop, isTablet} = useWebMediaQueries() const numUnread = useUnreadNotifications() @@ -370,7 +370,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { label="Moderation" />