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>
This commit is contained in:
dan 2023-11-13 18:35:15 +00:00 committed by GitHub
parent c3edde8ac6
commit e1938931e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 730 additions and 456 deletions

88
src/state/cache/profile-shadow.ts vendored Normal file
View file

@ -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,
},
}
}

View file

@ -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({ return useQuery({
queryKey: ['getProfile', did], queryKey: RQKEY(did),
queryFn: async () => { queryFn: async () => {
const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did}) const res = await agent.getProfile({actor: did || ''})
return res.data 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,
})
},
}) })
} }

View file

@ -4,17 +4,22 @@ import {useSession} from '../session'
export const RQKEY = (uri: string) => ['resolved-uri', uri] export const RQKEY = (uri: string) => ['resolved-uri', uri]
export function useResolveUriQuery(uri: string) { export function useResolveUriQuery(uri: string | undefined) {
const {agent} = useSession() const {agent} = useSession()
return useQuery<string | undefined, Error>({ return useQuery<{uri: string; did: string}, Error>({
queryKey: RQKEY(uri), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
const urip = new AtUri(uri) const urip = new AtUri(uri || '')
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({handle: urip.host}) const res = await agent.resolveHandle({handle: urip.host})
urip.host = res.data.did 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)
}

View file

@ -13,6 +13,7 @@ import {logger} from '#/logger'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors'
enum KnownError { enum KnownError {
Block, Block,
@ -69,7 +70,12 @@ export function FeedErrorMessage({
) )
} }
return <ErrorMessage message={error} onPressTryAgain={onPressTryAgain} /> return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={onPressTryAgain}
/>
)
} }
function FeedgenErrorMessage({ function FeedgenErrorMessage({

View file

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite'
import { import {
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
@ -8,15 +7,17 @@ import {
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' 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 {BlurView} from '../util/BlurView'
import {ProfileModel} from 'state/models/content/profile'
import {useStores} from 'state/index'
import {ProfileImageLightbox} from 'state/models/ui/shell' 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 * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -25,35 +26,45 @@ import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' 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 {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 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 {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles' import {isInvalidHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {Link} from '../util/Link' import {pluralize} from 'lib/strings/helpers'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' 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 {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {useSession} from '#/state/session'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
interface Props { interface Props {
view: ProfileModel profile: AppBskyActorDefs.ProfileViewDetailed
onRefreshAll: () => void moderation: ProfileModeration
hideBackButton?: boolean hideBackButton?: boolean
isProfilePreview?: boolean isProfilePreview?: boolean
} }
export const ProfileHeader = observer(function ProfileHeaderImpl({ export function ProfileHeader({
view, profile,
onRefreshAll, moderation,
hideBackButton = false, hideBackButton = false,
isProfilePreview, isProfilePreview,
}: Props) { }: Props) {
@ -61,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
// loading // loading
// = // =
if (!view || !view.hasLoaded) { if (!profile) {
return ( return (
<View style={pal.view}> <View style={pal.view}>
<LoadingPlaceholder width="100%" height={153} /> <LoadingPlaceholder width="100%" height={153} />
@ -75,9 +86,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
</View> </View>
<View> <View>
<Text type="title-2xl" style={[pal.text, styles.title]}> <Text type="title-2xl" style={[pal.text, styles.title]}>
{sanitizeDisplayName( <Trans>Loading...</Trans>
view.displayName || sanitizeHandle(view.handle),
)}
</Text> </Text>
</View> </View>
</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 // loaded
// = // =
return ( return (
<ProfileHeaderLoaded <ProfileHeaderLoaded
view={view} profile={profile}
onRefreshAll={onRefreshAll} moderation={moderation}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isProfilePreview={isProfilePreview} isProfilePreview={isProfilePreview}
/> />
) )
}) }
const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ function ProfileHeaderLoaded({
view, profile,
onRefreshAll, moderation,
hideBackButton = false, hideBackButton = false,
isProfilePreview, isProfilePreview,
}: Props) { }: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const palInverted = usePalette('inverted') const palInverted = usePalette('inverted')
const store = useStores() const store = useStores()
const {currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics() const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(view.handle) const invalidHandle = isInvalidHandle(profile.handle)
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) 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(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@ -134,86 +147,95 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
const onPressAvi = React.useCallback(() => { const onPressAvi = React.useCallback(() => {
if ( if (
view.avatar && profile.avatar &&
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride) !(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(() => { const onPressFollow = React.useCallback(async () => {
view?.toggleFollowing().then( if (profile.viewer?.following) {
() => { return
setShowSuggestedFollows(Boolean(view.viewer.following)) }
try {
track('ProfileHeader:FollowButtonClicked')
await followMutation.mutateAsync({did: profile.did})
Toast.show( Toast.show(
`${ `Following ${sanitizeDisplayName(
view.viewer.following ? 'Following' : 'No longer following' profile.displayName || profile.handle,
} ${sanitizeDisplayName(view.displayName || view.handle)}`, )}`,
) )
track( } catch (e: any) {
view.viewer.following logger.error('Failed to follow', {error: String(e)})
? 'ProfileHeader:FollowButtonClicked' Toast.show(`There was an issue! ${e.toString()}`)
: 'ProfileHeader:UnfollowButtonClicked', }
}, [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) {
err => logger.error('Failed to toggle follow', {error: err}), logger.error('Failed to unfollow', {error: String(e)})
) Toast.show(`There was an issue! ${e.toString()}`)
}, [track, view, setShowSuggestedFollows]) }
}, [unfollowMutation, profile, track])
const onPressEditProfile = React.useCallback(() => { const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked') track('ProfileHeader:EditProfileButtonClicked')
openModal({ openModal({
name: 'edit-profile', name: 'edit-profile',
profileView: view, profileView: profile,
onUpdate: onRefreshAll,
}) })
}, [track, openModal, view, onRefreshAll]) }, [track, openModal, profile])
const trackPress = React.useCallback(
(f: 'Followers' | 'Follows') => {
track(`ProfileHeader:${f}ButtonClicked`, {
handle: view.handle,
})
},
[track, view],
)
const onPressShare = React.useCallback(() => { const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked') track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(makeProfileLink(view)) shareUrl(toShareUrl(makeProfileLink(profile)))
shareUrl(url) }, [track, profile])
}, [track, view])
const onPressAddRemoveLists = React.useCallback(() => { const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked') track('ProfileHeader:AddToListsButtonClicked')
openModal({ openModal({
name: 'user-add-remove-lists', name: 'user-add-remove-lists',
subject: view.did, subject: profile.did,
displayName: view.displayName || view.handle, displayName: profile.displayName || profile.handle,
}) })
}, [track, view, openModal]) }, [track, profile, openModal])
const onPressMuteAccount = React.useCallback(async () => { const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked') track('ProfileHeader:MuteAccountButtonClicked')
try { try {
await view.muteAccount() await muteMutation.mutateAsync({did: profile.did})
Toast.show('Account muted') Toast.show('Account muted')
} catch (e: any) { } catch (e: any) {
logger.error('Failed to mute account', {error: e}) logger.error('Failed to mute account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`) Toast.show(`There was an issue! ${e.toString()}`)
} }
}, [track, view]) }, [track, muteMutation, profile])
const onPressUnmuteAccount = React.useCallback(async () => { const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked') track('ProfileHeader:UnmuteAccountButtonClicked')
try { try {
await view.unmuteAccount() await unmuteMutation.mutateAsync({did: profile.did})
Toast.show('Account unmuted') Toast.show('Account unmuted')
} catch (e: any) { } catch (e: any) {
logger.error('Failed to unmute account', {error: e}) logger.error('Failed to unmute account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`) Toast.show(`There was an issue! ${e.toString()}`)
} }
}, [track, view]) }, [track, unmuteMutation, profile])
const onPressBlockAccount = React.useCallback(async () => { const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked') track('ProfileHeader:BlockAccountButtonClicked')
@ -223,9 +245,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
message: message:
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
onPressConfirm: async () => { onPressConfirm: async () => {
if (profile.viewer?.blocking) {
return
}
try { try {
await view.blockAccount() await blockMutation.mutateAsync({did: profile.did})
onRefreshAll()
Toast.show('Account blocked') Toast.show('Account blocked')
} catch (e: any) { } catch (e: any) {
logger.error('Failed to block account', {error: e}) 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 () => { const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked') track('ProfileHeader:UnblockAccountButtonClicked')
@ -243,9 +267,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
message: message:
'The account will be able to interact with you after unblocking.', 'The account will be able to interact with you after unblocking.',
onPressConfirm: async () => { onPressConfirm: async () => {
if (!profile.viewer?.blocking) {
return
}
try { try {
await view.unblockAccount() await unblockMutation.mutateAsync({
onRefreshAll() did: profile.did,
blockUri: profile.viewer.blocking,
})
Toast.show('Account unblocked') Toast.show('Account unblocked')
} catch (e: any) { } catch (e: any) {
logger.error('Failed to unblock account', {error: e}) 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(() => { const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked') track('ProfileHeader:ReportAccountButtonClicked')
openModal({ openModal({
name: 'report', name: 'report',
did: view.did, did: profile.did,
}) })
}, [track, openModal, view]) }, [track, openModal, profile])
const isMe = React.useMemo( const isMe = React.useMemo(
() => store.me.did === view.did, () => currentAccount?.did === profile.did,
[store.me.did, view.did], [currentAccount, profile],
) )
const dropdownItems: DropdownItem[] = React.useMemo(() => { const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [ let items: DropdownItem[] = [
@ -296,11 +325,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
}, },
}) })
if (!isMe) { if (!isMe) {
if (!view.viewer.blocking) { if (!profile.viewer?.blocking) {
items.push({ items.push({
testID: 'profileHeaderDropdownMuteBtn', testID: 'profileHeaderDropdownMuteBtn',
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', label: profile.viewer?.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.viewer.muted onPress: profile.viewer?.muted
? onPressUnmuteAccount ? onPressUnmuteAccount
: onPressMuteAccount, : onPressMuteAccount,
icon: { icon: {
@ -312,11 +341,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
}, },
}) })
} }
if (!view.viewer.blockingByList) { if (!profile.viewer?.blockingByList) {
items.push({ items.push({
testID: 'profileHeaderDropdownBlockBtn', testID: 'profileHeaderDropdownBlockBtn',
label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', label: profile.viewer?.blocking ? 'Unblock Account' : 'Block Account',
onPress: view.viewer.blocking onPress: profile.viewer?.blocking
? onPressUnblockAccount ? onPressUnblockAccount
: onPressBlockAccount, : onPressBlockAccount,
icon: { icon: {
@ -344,9 +373,9 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
return items return items
}, [ }, [
isMe, isMe,
view.viewer.muted, profile.viewer?.muted,
view.viewer.blocking, profile.viewer?.blocking,
view.viewer.blockingByList, profile.viewer?.blockingByList,
onPressShare, onPressShare,
onPressUnmuteAccount, onPressUnmuteAccount,
onPressMuteAccount, onPressMuteAccount,
@ -356,14 +385,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
onPressAddRemoveLists, onPressAddRemoveLists,
]) ])
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) const blockHide =
const following = formatCount(view.followsCount) !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
const followers = formatCount(view.followersCount) const following = formatCount(profile.followsCount || 0)
const pluralizedFollowers = pluralize(view.followersCount, 'follower') const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
return ( return (
<View style={pal.view}> <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.content}>
<View style={[styles.buttonsLine]}> <View style={[styles.buttonsLine]}>
{isMe ? ( {isMe ? (
@ -378,8 +408,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
<Trans>Edit Profile</Trans> <Trans>Edit Profile</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : view.viewer.blocking ? ( ) : profile.viewer?.blocking ? (
view.viewer.blockingByList ? null : ( profile.viewer?.blockingByList ? null : (
<TouchableOpacity <TouchableOpacity
testID="unblockBtn" testID="unblockBtn"
onPress={onPressUnblockAccount} onPress={onPressUnblockAccount}
@ -392,7 +422,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )
) : !view.viewer.blockedBy ? ( ) : !profile.viewer?.blockedBy ? (
<> <>
{!isProfilePreview && ( {!isProfilePreview && (
<TouchableOpacity <TouchableOpacity
@ -410,7 +440,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
}, },
]} ]}
accessibilityRole="button" 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.`}> accessibilityHint={`Shows a list of users similar to this user.`}>
<FontAwesomeIcon <FontAwesomeIcon
icon="user-plus" icon="user-plus"
@ -427,15 +457,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</TouchableOpacity> </TouchableOpacity>
)} )}
{store.me.follows.getFollowState(view.did) === {profile.viewer?.following ? (
FollowState.Following ? (
<TouchableOpacity <TouchableOpacity
testID="unfollowBtn" testID="unfollowBtn"
onPress={onPressToggleFollow} onPress={onPressUnfollow}
style={[styles.btn, styles.mainBtn, pal.btn]} style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Unfollow ${view.handle}`} accessibilityLabel={`Unfollow ${profile.handle}`}
accessibilityHint={`Hides posts from ${view.handle} in your feed`}> accessibilityHint={`Hides posts from ${profile.handle} in your feed`}>
<FontAwesomeIcon <FontAwesomeIcon
icon="check" icon="check"
style={[pal.text, s.mr5]} style={[pal.text, s.mr5]}
@ -448,11 +477,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
) : ( ) : (
<TouchableOpacity <TouchableOpacity
testID="followBtn" testID="followBtn"
onPress={onPressToggleFollow} onPress={onPressFollow}
style={[styles.btn, styles.mainBtn, palInverted.view]} style={[styles.btn, styles.mainBtn, palInverted.view]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Follow ${view.handle}`} accessibilityLabel={`Follow ${profile.handle}`}
accessibilityHint={`Shows posts from ${view.handle} in your feed`}> accessibilityHint={`Shows posts from ${profile.handle} in your feed`}>
<FontAwesomeIcon <FontAwesomeIcon
icon="plus" icon="plus"
style={[palInverted.text, s.mr5]} style={[palInverted.text, s.mr5]}
@ -482,13 +511,13 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
type="title-2xl" type="title-2xl"
style={[pal.text, styles.title]}> style={[pal.text, styles.title]}>
{sanitizeDisplayName( {sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle), profile.displayName || sanitizeHandle(profile.handle),
view.moderation.profile, moderation.profile,
)} )}
</Text> </Text>
</View> </View>
<View style={styles.handleLine}> <View style={styles.handleLine}>
{view.viewer.followedBy && !blockHide ? ( {profile.viewer?.followedBy && !blockHide ? (
<View style={[styles.pill, pal.btn, s.mr5]}> <View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}> <Text type="xs" style={[pal.text]}>
<Trans>Follows you</Trans> <Trans>Follows you</Trans>
@ -503,7 +532,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
invalidHandle ? styles.invalidHandle : undefined, invalidHandle ? styles.invalidHandle : undefined,
styles.handle, styles.handle,
]}> ]}>
{invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`} {invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`}
</ThemedText> </ThemedText>
</View> </View>
{!blockHide && ( {!blockHide && (
@ -512,8 +541,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
<Link <Link
testID="profileHeaderFollowersButton" testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]} style={[s.flexRow, s.mr10]}
href={makeProfileLink(view, 'followers')} href={makeProfileLink(profile, 'followers')}
onPressOut={() => trackPress('Followers')} onPressOut={() =>
track(`ProfileHeader:FollowersButtonClicked`, {
handle: profile.handle,
})
}
asAnchor asAnchor
accessibilityLabel={`${followers} ${pluralizedFollowers}`} accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={'Opens followers list'}> accessibilityHint={'Opens followers list'}>
@ -527,8 +560,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
<Link <Link
testID="profileHeaderFollowsButton" testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]} style={[s.flexRow, s.mr10]}
href={makeProfileLink(view, 'follows')} href={makeProfileLink(profile, 'follows')}
onPressOut={() => trackPress('Follows')} onPressOut={() =>
track(`ProfileHeader:FollowsButtonClicked`, {
handle: profile.handle,
})
}
asAnchor asAnchor
accessibilityLabel={`${following} following`} accessibilityLabel={`${following} following`}
accessibilityHint={'Opens following list'}> accessibilityHint={'Opens following list'}>
@ -540,30 +577,28 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
</Text> </Text>
</Link> </Link>
<Text type="md" style={[s.bold, pal.text]}> <Text type="md" style={[s.bold, pal.text]}>
{formatCount(view.postsCount)}{' '} {formatCount(profile.postsCount || 0)}{' '}
<Text type="md" style={[pal.textLight]}> <Text type="md" style={[pal.textLight]}>
{pluralize(view.postsCount, 'post')} {pluralize(profile.postsCount || 0, 'post')}
</Text> </Text>
</Text> </Text>
</View> </View>
{view.description && {descriptionRT && !moderation.profile.blur ? (
view.descriptionRichText &&
!view.moderation.profile.blur ? (
<RichText <RichText
testID="profileHeaderDescription" testID="profileHeaderDescription"
style={[styles.description, pal.text]} style={[styles.description, pal.text]}
numberOfLines={15} numberOfLines={15}
richText={view.descriptionRichText} richText={descriptionRT}
/> />
) : undefined} ) : undefined}
</> </>
)} )}
<ProfileHeaderAlerts moderation={view.moderation} /> <ProfileHeaderAlerts moderation={moderation} />
</View> </View>
{!isProfilePreview && ( {!isProfilePreview && (
<ProfileHeaderSuggestedFollows <ProfileHeaderSuggestedFollows
actorDid={view.did} actorDid={profile.did}
active={showSuggestedFollows} active={showSuggestedFollows}
requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)} requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)}
/> />
@ -588,20 +623,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
testID="profileHeaderAviButton" testID="profileHeaderAviButton"
onPress={onPressAvi} onPress={onPressAvi}
accessibilityRole="image" accessibilityRole="image"
accessibilityLabel={`View ${view.handle}'s avatar`} accessibilityLabel={`View ${profile.handle}'s avatar`}
accessibilityHint=""> accessibilityHint="">
<View <View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<UserAvatar <UserAvatar
size={80} size={80}
avatar={view.avatar} avatar={profile.avatar}
moderation={view.moderation.avatar} moderation={moderation.avatar}
/> />
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
banner: { banner: {

View file

@ -49,7 +49,7 @@ export const PostThreadScreen = withAuthRequired(
return return
} }
const thread = queryClient.getQueryData<ThreadNode>( const thread = queryClient.getQueryData<ThreadNode>(
POST_THREAD_RQKEY(resolvedUri), POST_THREAD_RQKEY(resolvedUri.uri),
) )
if (thread?.type !== 'post') { if (thread?.type !== 'post') {
return return
@ -67,7 +67,7 @@ export const PostThreadScreen = withAuthRequired(
}, },
onPost: () => onPost: () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: POST_THREAD_RQKEY(resolvedUri || ''), queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''),
}), }),
}) })
}, [store, queryClient, resolvedUri]) }, [store, queryClient, resolvedUri])
@ -82,7 +82,7 @@ export const PostThreadScreen = withAuthRequired(
</CenteredView> </CenteredView>
) : ( ) : (
<PostThreadComponent <PostThreadComponent
uri={resolvedUri} uri={resolvedUri?.uri}
onPressReply={onPressReply} onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled} treeView={!!store.preferences.thread.lab_treeViewEnabled}
/> />

View file

@ -1,94 +1,166 @@
import React, {useEffect, useState} from 'react' import React, {useMemo} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useFocusEffect} from '@react-navigation/native' 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 {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' 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 {CenteredView} from '../com/util/Views'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider' 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 {useStores} from 'state/index'
import {ProfileHeader} from '../com/profile/ProfileHeader' import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedSlice} from '../com/posts/FeedSlice' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ListCard} from 'view/com/lists/ListCard'
import {
PostFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from '../com/util/LoadingPlaceholder'
import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ErrorMessage} from '../com/util/error/ErrorMessage'
import {EmptyState} from '../com/util/EmptyState' import {EmptyState} from '../com/util/EmptyState'
import {Text} from '../com/util/text/Text'
import {FAB} from '../com/util/fab/FAB' import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons' 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 {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names' import {combinedDisplayName} from 'lib/strings/display-names'
import {logger} from '#/logger' import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {Trans, msg} from '@lingui/macro' import {FeedDescriptor} from '#/state/queries/post-feed'
import {useLingui} from '@lingui/react' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useSetMinimalShellMode} from '#/state/shell' 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'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export const ProfileScreen = withAuthRequired( export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({
observer(function ProfileScreenImpl({route}: Props) { 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,
})
const onPressTryAgain = React.useCallback(() => {
if (resolveError) {
refetchDid()
} else {
refetchProfile()
}
}, [resolveError, refetchDid, refetchProfile])
if (isFetchingDid || isFetchingProfile) {
return (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}
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>
)
})
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 store = useStores()
const {currentAccount} = useSession()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui() const {_} = useLingui()
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
const name = route.params.name === 'me' ? store.me.did : route.params.name const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useEffect(() => { useSetTitle(combinedDisplayName(profile))
screen('Profile')
}, [screen])
const [hasSetup, setHasSetup] = useState<boolean>(false) const moderation = useMemo(
const uiState = React.useMemo( () => moderateProfile(profile, moderationOpts),
() => new ProfileUiModel(store, {user: name}), [profile, moderationOpts],
[name, store],
) )
useSetTitle(combinedDisplayName(uiState.profile))
const onSoftReset = React.useCallback(() => { /*
viewSelectorRef.current?.scrollToTop() - todo
}, []) - feeds
- lists
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( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
let aborted = false
setMinimalShellMode(false) setMinimalShellMode(false)
const feedCleanup = uiState.feed.registerListeners() screen('Profile')
if (!hasSetup) { const softResetSub = store.onScreenSoftReset(() => {
uiState.setup().then(() => { viewSelectorRef.current?.scrollToTop()
if (aborted) {
return
}
setHasSetup(true)
}) })
} return () => softResetSub.remove()
}, [store, viewSelectorRef, setMinimalShellMode, screen]),
)
useFocusEffect(
React.useCallback(() => {
setDrawerSwipeDisabled(currentPage > 0)
return () => { return () => {
aborted = true setDrawerSwipeDisabled(false)
feedCleanup()
softResetSub.remove()
} }
}, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), }, [setDrawerSwipeDisabled, currentPage]),
) )
// events // events
@ -97,206 +169,85 @@ export const ProfileScreen = withAuthRequired(
const onPressCompose = React.useCallback(() => { const onPressCompose = React.useCallback(() => {
track('ProfileScreen:PressCompose') track('ProfileScreen:PressCompose')
const mention = const mention =
uiState.profile.handle === store.me.handle || profile.handle === currentAccount?.handle ||
uiState.profile.handle === 'handle.invalid' profile.handle === 'handle.invalid'
? undefined ? undefined
: uiState.profile.handle : profile.handle
store.shell.openComposer({mention}) store.shell.openComposer({mention})
}, [store, track, uiState]) }, [store, currentAccount, track, profile])
const onSelectView = React.useCallback(
(index: number) => { const onPageSelected = React.useCallback(
uiState.setSelectedViewIndex(index) i => {
setCurrentPage(i)
}, },
[uiState], [setCurrentPage],
) )
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 // rendering
// = // =
const renderHeader = React.useCallback(() => { const renderHeader = React.useCallback(() => {
if (!uiState) {
return <View />
}
return ( return (
<ProfileHeader <ProfileHeader
view={uiState.profile} profile={profile}
onRefreshAll={onRefresh} moderation={moderation}
hideBackButton={route.params.hideBackButton} hideBackButton={hideBackButton}
/> />
) )
}, [uiState, onRefresh, route.params.hideBackButton]) }, [profile, moderation, 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 <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,
],
)
return ( return (
<ScreenHider <ScreenHider
testID="profileView" testID="profileView"
style={styles.container} style={styles.container}
screenDescription="profile" screenDescription="profile"
moderation={uiState.profile.moderation.account}> moderation={moderation.account}>
{uiState.profile.hasError ? ( <PagerWithHeader
<ErrorScreen isHeaderReady={true}
testID="profileErrorScreen" items={SECTION_TITLES_PROFILE}
title="Failed to load profile" onPageSelected={onPageSelected}
message={uiState.profile.error} renderHeader={renderHeader}>
onPressTryAgain={onPressTryAgain} {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={null}
feed={`author|${profile.did}|posts_no_replies`}
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}
/>
) : (
<CenteredView>{renderHeader()}</CenteredView>
)} )}
{({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}
/>
)}
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={null}
feed={`likes|${profile.did}`}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
scrollElRef={scrollElRef}
/>
)}
</PagerWithHeader>
<FAB <FAB
testID="composeFAB" testID="composeFAB"
onPress={onPressCompose} onPress={onPressCompose}
@ -307,16 +258,49 @@ export const ProfileScreen = withAuthRequired(
/> />
</ScreenHider> </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!" />
}, [])
function LoadingMoreFooter() {
return ( return (
<View style={styles.loadingMoreFooter}> <View>
<ActivityIndicator /> <Feed
testID="postsFeed"
feed={feed}
scrollElRef={scrollElRef}
onScroll={onScroll}
scrollEventThrottle={1}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
</View> </View>
) )
} },
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View file

@ -70,7 +70,7 @@ export const ProfileListScreen = withAuthRequired(
const {data: resolvedUri, error: resolveError} = useResolveUriQuery( const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), 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) { if (resolveError) {
return ( return (

View file

@ -251,7 +251,7 @@ function ComposeBtn() {
} }
export const DesktopLeftNav = observer(function DesktopLeftNav() { export const DesktopLeftNav = observer(function DesktopLeftNav() {
const store = useStores() const {currentAccount} = useSession()
const pal = usePalette('default') const pal = usePalette('default')
const {isDesktop, isTablet} = useWebMediaQueries() const {isDesktop, isTablet} = useWebMediaQueries()
const numUnread = useUnreadNotifications() const numUnread = useUnreadNotifications()
@ -370,7 +370,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
label="Moderation" label="Moderation"
/> />
<NavItem <NavItem
href={makeProfileLink(store.me)} href={makeProfileLink(currentAccount)}
icon={ icon={
<UserIcon <UserIcon
strokeWidth={1.75} strokeWidth={1.75}