diff options
author | dan <dan.abramov@gmail.com> | 2023-11-13 18:35:15 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-13 10:35:15 -0800 |
commit | e1938931e028f4486cce8cd9da39ffd940c10ec2 (patch) | |
tree | d7d22d93a95503350ab4d2d0ace409cd873815cb | |
parent | c3edde8ac6f9c65eac1004cd8e2fc14b0493cba8 (diff) | |
download | voidsky-e1938931e028f4486cce8cd9da39ffd940c10ec2.tar.zst |
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 <pfrazee@gmail.com>
-rw-r--r-- | src/state/cache/profile-shadow.ts | 88 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 166 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 15 | ||||
-rw-r--r-- | src/view/com/posts/FeedErrorMessage.tsx | 8 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 327 | ||||
-rw-r--r-- | src/view/screens/PostThread.tsx | 6 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 546 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 2 | ||||
-rw-r--r-- | src/view/shell/desktop/LeftNav.tsx | 4 |
9 files changed, 718 insertions, 444 deletions
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts new file mode 100644 index 000000000..a1cf59954 --- /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<T extends ProfileView>( + profile: T, + ifAfterTS: number, +): T { + const [state, setState] = useState<CacheEntry>({ + ts: Date.now(), + value: fromProfile(profile), + }) + const firstRun = useRef(true) + + const onUpdate = useCallback( + (value: Partial<ProfileShadow>) => { + 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<ProfileShadow>, +) { + emitter.emit(uri, value) +} + +function fromProfile(profile: ProfileView): ProfileShadow { + return { + followingUri: profile.viewer?.following, + muted: profile.viewer?.muted, + blockingUri: profile.viewer?.blocking, + } +} + +function mergeShadow<T extends ProfileView>( + 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 c2cd19482..1bd28d5b1 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<void, Error, {did: string; followUri: string}>({ + 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<void, Error, {did: string}>({ + 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<void, Error, {did: string}>({ + 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<void, Error, {did: string; blockUri: string}>({ + 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 26e0a475b..83bccdce7 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<string | undefined, Error>({ - 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 feb4b1c99..e29b35f8a 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 <ErrorMessage message={error} onPressTryAgain={onPressTryAgain} /> + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={onPressTryAgain} + /> + ) } function FeedgenErrorMessage({ diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index d7b7b8ed7..ea3b86301 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 ( <View style={pal.view}> <LoadingPlaceholder width="100%" height={153} /> @@ -75,9 +86,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ </View> <View> <Text type="title-2xl" style={[pal.text, styles.title]}> - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - )} + <Trans>Loading...</Trans> </Text> </View> </View> @@ -85,44 +94,48 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ ) } - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } - // loaded // = return ( <ProfileHeaderLoaded - view={view} - onRefreshAll={onRefreshAll} + profile={profile} + moderation={moderation} hideBackButton={hideBackButton} isProfilePreview={isProfilePreview} /> ) -}) +} -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<NavigationProp>() 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]) - - 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]) + }, [store, profile, moderation]) + + 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 ( <View style={pal.view}> - <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> + <UserBanner banner={profile.banner} moderation={moderation.avatar} /> <View style={styles.content}> <View style={[styles.buttonsLine]}> {isMe ? ( @@ -378,8 +408,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Trans>Edit Profile</Trans> </Text> </TouchableOpacity> - ) : view.viewer.blocking ? ( - view.viewer.blockingByList ? null : ( + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( <TouchableOpacity testID="unblockBtn" onPress={onPressUnblockAccount} @@ -392,7 +422,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </Text> </TouchableOpacity> ) - ) : !view.viewer.blockedBy ? ( + ) : !profile.viewer?.blockedBy ? ( <> {!isProfilePreview && ( <TouchableOpacity @@ -410,7 +440,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, ]} accessibilityRole="button" - accessibilityLabel={`Show follows similar to ${view.handle}`} + accessibilityLabel={`Show follows similar to ${profile.handle}`} accessibilityHint={`Shows a list of users similar to this user.`}> <FontAwesomeIcon icon="user-plus" @@ -427,15 +457,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </TouchableOpacity> )} - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( + {profile.viewer?.following ? ( <TouchableOpacity testID="unfollowBtn" - onPress={onPressToggleFollow} + onPress={onPressUnfollow} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel={`Unfollow ${view.handle}`} - accessibilityHint={`Hides posts from ${view.handle} in your feed`}> + accessibilityLabel={`Unfollow ${profile.handle}`} + accessibilityHint={`Hides posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="check" style={[pal.text, s.mr5]} @@ -448,11 +477,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ ) : ( <TouchableOpacity testID="followBtn" - onPress={onPressToggleFollow} + onPress={onPressFollow} style={[styles.btn, styles.mainBtn, palInverted.view]} accessibilityRole="button" - accessibilityLabel={`Follow ${view.handle}`} - accessibilityHint={`Shows posts from ${view.handle} in your feed`}> + accessibilityLabel={`Follow ${profile.handle}`} + accessibilityHint={`Shows posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="plus" style={[palInverted.text, s.mr5]} @@ -482,13 +511,13 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ type="title-2xl" style={[pal.text, styles.title]}> {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - view.moderation.profile, + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} </Text> </View> <View style={styles.handleLine}> - {view.viewer.followedBy && !blockHide ? ( + {profile.viewer?.followedBy && !blockHide ? ( <View style={[styles.pill, pal.btn, s.mr5]}> <Text type="xs" style={[pal.text]}> <Trans>Follows you</Trans> @@ -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}`} </ThemedText> </View> {!blockHide && ( @@ -512,8 +541,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Link testID="profileHeaderFollowersButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'followers')} - onPressOut={() => 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({ <Link testID="profileHeaderFollowsButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'follows')} - onPressOut={() => 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({ </Text> </Link> <Text type="md" style={[s.bold, pal.text]}> - {formatCount(view.postsCount)}{' '} + {formatCount(profile.postsCount || 0)}{' '} <Text type="md" style={[pal.textLight]}> - {pluralize(view.postsCount, 'post')} + {pluralize(profile.postsCount || 0, 'post')} </Text> </Text> </View> - {view.description && - view.descriptionRichText && - !view.moderation.profile.blur ? ( + {descriptionRT && !moderation.profile.blur ? ( <RichText testID="profileHeaderDescription" style={[styles.description, pal.text]} numberOfLines={15} - richText={view.descriptionRichText} + richText={descriptionRT} /> ) : undefined} </> )} - <ProfileHeaderAlerts moderation={view.moderation} /> + <ProfileHeaderAlerts moderation={moderation} /> </View> {!isProfilePreview && ( <ProfileHeaderSuggestedFollows - actorDid={view.did} + actorDid={profile.did} active={showSuggestedFollows} requestDismiss={() => 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=""> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> <UserAvatar size={80} - avatar={view.avatar} - moderation={view.moderation.avatar} + avatar={profile.avatar} + moderation={moderation.avatar} /> </View> </TouchableWithoutFeedback> </View> ) -}) +} const styles = StyleSheet.create({ banner: { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index b254c1eca..9536e86e7 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<ThreadNode>( - 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( </CenteredView> ) : ( <PostThreadComponent - uri={resolvedUri} + uri={resolvedUri?.uri} onPressReply={onPressReply} treeView={!!store.preferences.thread.lab_treeViewEnabled} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 945a8cc20..dab8988ad 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<CommonNavigatorParams, 'Profile'> -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<ViewSelectorHandle>(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 onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) - const [hasSetup, setHasSetup] = useState<boolean>(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], + if (isFetchingDid || isFetchingProfile) { + return ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> ) - useSetTitle(combinedDisplayName(uiState.profile)) + } + if (resolveError || profileError) { + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message={cleanError(resolveError || profileError)} + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) + } + if (profile && moderationOpts) { + return ( + <ProfileScreenLoaded + profile={profile} + dataUpdatedAt={dataUpdatedAt} + moderationOpts={moderationOpts} + hideBackButton={!!route.params.hideBackButton} + /> + ) + } + // should never happen + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) +}) - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) +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<ViewSelectorHandle>(null) + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - useEffect(() => { - setHasSetup(false) - }, [name]) + useSetTitle(combinedDisplayName(profile)) - // 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 - }, []) + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) - 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]), - ) + /* + - todo + - feeds + - lists + */ - // events - // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + const softResetSub = store.onScreenSoftReset(() => { + viewSelectorRef.current?.scrollToTop() + }) + return () => softResetSub.remove() + }, [store, viewSelectorRef, setMinimalShellMode, screen]), + ) - 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]) + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) + } + }, [setDrawerSwipeDisabled, currentPage]), + ) - // rendering - // = + // events + // = - const renderHeader = React.useCallback(() => { - if (!uiState) { - return <View /> - } - return ( - <ProfileHeader - view={uiState.profile} - onRefreshAll={onRefresh} - hideBackButton={route.params.hideBackButton} - /> - ) - }, [uiState, onRefresh, route.params.hideBackButton]) + 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 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 <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="listsEmpty" - icon="list-ul" - message="No lists yet!" - style={styles.emptyState} - /> - ) - } else { - return <ListCard testID={`list-${item.name}`} list={item} /> - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="customAlgorithmsEmpty" - icon="list-ul" - message="No custom algorithms yet!" - style={styles.emptyState} - /> - ) - } else if (item instanceof FeedSourceModel) { - return ( - <FeedSourceCard - item={item} - showSaveBtn - showLikes - showDescription - /> - ) - } - // if section is posts or posts & replies - } else { - if (item === ProfileUiModel.END_ITEM) { - return ( - <Text style={styles.endItem}> - <Trans>- end of feed -</Trans> - </Text> - ) - } else if (item === ProfileUiModel.LOADING_ITEM) { - return <PostFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - if (uiState.feed.isBlockedBy) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} - /> - ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> - ) - } - } - return <View /> - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) + const onPageSelected = React.useCallback( + i => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + // rendering + // = + const renderHeader = React.useCallback(() => { return ( - <ScreenHider - testID="profileView" - style={styles.container} - screenDescription="profile" - moderation={uiState.profile.moderation.account}> - {uiState.profile.hasError ? ( - <ErrorScreen - testID="profileErrorScreen" - title="Failed to load profile" - message={uiState.profile.error} - onPressTryAgain={onPressTryAgain} + <ProfileHeader + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + /> + ) + }, [profile, moderation, hideBackButton]) + + return ( + <ScreenHider + testID="profileView" + style={styles.container} + screenDescription="profile" + moderation={moderation.account}> + <PagerWithHeader + isHeaderReady={true} + items={SECTION_TITLES_PROFILE} + onPageSelected={onPageSelected} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={null} + feed={`author|${profile.did}|posts_no_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + scrollElRef={scrollElRef} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={null} + feed={`author|${profile.did}|posts_with_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + scrollElRef={scrollElRef} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={null} + feed={`author|${profile.did}|posts_with_media`} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + scrollElRef={scrollElRef} /> - ) : uiState.profile.hasLoaded ? ( - <ViewSelector - ref={viewSelectorRef} - swipeEnabled={false} - sections={uiState.selectorItems} - items={uiState.uiItems} - renderHeader={renderHeader} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshing={uiState.isRefreshing || false} - onSelectView={onSelectView} - onRefresh={onRefresh} - onEndReached={onEndReached} + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={null} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + scrollElRef={scrollElRef} /> - ) : ( - <CenteredView>{renderHeader()}</CenteredView> )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </ScreenHider> + ) +} + +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isScrolledDown: boolean + scrollElRef: any /* TODO */ +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + 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 <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + testID="postsFeed" + feed={feed} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} /> - </ScreenHider> + </View> ) - }), + }, ) -function LoadingMoreFooter() { - return ( - <View style={styles.loadingMoreFooter}> - <ActivityIndicator /> - </View> - ) -} - const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 18665f519..42c3741db 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 c9a03ce62..d7814cb5d 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" /> <NavItem - href={makeProfileLink(store.me)} + href={makeProfileLink(currentAccount)} icon={ <UserIcon strokeWidth={1.75} |