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:
parent
c3edde8ac6
commit
e1938931e0
9 changed files with 730 additions and 456 deletions
88
src/state/cache/profile-shadow.ts
vendored
Normal file
88
src/state/cache/profile-shadow.ts
vendored
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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))
|
}
|
||||||
Toast.show(
|
try {
|
||||||
`${
|
track('ProfileHeader:FollowButtonClicked')
|
||||||
view.viewer.following ? 'Following' : 'No longer following'
|
await followMutation.mutateAsync({did: profile.did})
|
||||||
} ${sanitizeDisplayName(view.displayName || view.handle)}`,
|
Toast.show(
|
||||||
)
|
`Following ${sanitizeDisplayName(
|
||||||
track(
|
profile.displayName || profile.handle,
|
||||||
view.viewer.following
|
)}`,
|
||||||
? 'ProfileHeader:FollowButtonClicked'
|
)
|
||||||
: 'ProfileHeader:UnfollowButtonClicked',
|
} catch (e: any) {
|
||||||
)
|
logger.error('Failed to follow', {error: String(e)})
|
||||||
},
|
Toast.show(`There was an issue! ${e.toString()}`)
|
||||||
err => logger.error('Failed to toggle follow', {error: err}),
|
}
|
||||||
)
|
}, [followMutation, profile, track])
|
||||||
}, [track, view, setShowSuggestedFollows])
|
|
||||||
|
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(() => {
|
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: {
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,323 +1,307 @@
|
||||||
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,
|
||||||
const store = useStores()
|
}: Props) {
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const {currentAccount} = useSession()
|
||||||
const {screen, track} = useAnalytics()
|
const name =
|
||||||
const {_} = useLingui()
|
route.params.name === 'me' ? currentAccount?.did : route.params.name
|
||||||
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
|
const moderationOpts = useModerationOpts()
|
||||||
const name = route.params.name === 'me' ? store.me.did : route.params.name
|
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(() => {
|
const onPressTryAgain = React.useCallback(() => {
|
||||||
screen('Profile')
|
if (resolveError) {
|
||||||
}, [screen])
|
refetchDid()
|
||||||
|
} else {
|
||||||
const [hasSetup, setHasSetup] = useState<boolean>(false)
|
refetchProfile()
|
||||||
const uiState = React.useMemo(
|
}
|
||||||
() => new ProfileUiModel(store, {user: name}),
|
}, [resolveError, refetchDid, refetchProfile])
|
||||||
[name, store],
|
|
||||||
)
|
|
||||||
useSetTitle(combinedDisplayName(uiState.profile))
|
|
||||||
|
|
||||||
const onSoftReset = React.useCallback(() => {
|
|
||||||
viewSelectorRef.current?.scrollToTop()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasSetup(false)
|
|
||||||
}, [name])
|
|
||||||
|
|
||||||
// We don't need this to be reactive, so we can just register the listeners once
|
|
||||||
useEffect(() => {
|
|
||||||
const listCleanup = uiState.lists.registerListeners()
|
|
||||||
return () => listCleanup()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
|
||||||
let aborted = false
|
|
||||||
setMinimalShellMode(false)
|
|
||||||
const feedCleanup = uiState.feed.registerListeners()
|
|
||||||
if (!hasSetup) {
|
|
||||||
uiState.setup().then(() => {
|
|
||||||
if (aborted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setHasSetup(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
aborted = true
|
|
||||||
feedCleanup()
|
|
||||||
softResetSub.remove()
|
|
||||||
}
|
|
||||||
}, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// events
|
|
||||||
// =
|
|
||||||
|
|
||||||
const onPressCompose = React.useCallback(() => {
|
|
||||||
track('ProfileScreen:PressCompose')
|
|
||||||
const mention =
|
|
||||||
uiState.profile.handle === store.me.handle ||
|
|
||||||
uiState.profile.handle === 'handle.invalid'
|
|
||||||
? undefined
|
|
||||||
: uiState.profile.handle
|
|
||||||
store.shell.openComposer({mention})
|
|
||||||
}, [store, track, uiState])
|
|
||||||
const onSelectView = React.useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
uiState.setSelectedViewIndex(index)
|
|
||||||
},
|
|
||||||
[uiState],
|
|
||||||
)
|
|
||||||
const onRefresh = React.useCallback(() => {
|
|
||||||
uiState
|
|
||||||
.refresh()
|
|
||||||
.catch((err: any) =>
|
|
||||||
logger.error('Failed to refresh user profile', {error: err}),
|
|
||||||
)
|
|
||||||
}, [uiState])
|
|
||||||
const onEndReached = React.useCallback(() => {
|
|
||||||
uiState.loadMore().catch((err: any) =>
|
|
||||||
logger.error('Failed to load more entries in user profile', {
|
|
||||||
error: err,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, [uiState])
|
|
||||||
const onPressTryAgain = React.useCallback(() => {
|
|
||||||
uiState.setup()
|
|
||||||
}, [uiState])
|
|
||||||
|
|
||||||
// rendering
|
|
||||||
// =
|
|
||||||
|
|
||||||
const renderHeader = React.useCallback(() => {
|
|
||||||
if (!uiState) {
|
|
||||||
return <View />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ProfileHeader
|
|
||||||
view={uiState.profile}
|
|
||||||
onRefreshAll={onRefresh}
|
|
||||||
hideBackButton={route.params.hideBackButton}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}, [uiState, onRefresh, route.params.hideBackButton])
|
|
||||||
|
|
||||||
const Footer = React.useMemo(() => {
|
|
||||||
return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined
|
|
||||||
}, [uiState.showLoadingMoreFooter])
|
|
||||||
const renderItem = React.useCallback(
|
|
||||||
(item: any) => {
|
|
||||||
// if section is lists
|
|
||||||
if (uiState.selectedView === Sections.Lists) {
|
|
||||||
if (item === ProfileUiModel.LOADING_ITEM) {
|
|
||||||
return <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,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if (isFetchingDid || isFetchingProfile) {
|
||||||
return (
|
return (
|
||||||
<ScreenHider
|
<CenteredView>
|
||||||
testID="profileView"
|
<View style={s.p20}>
|
||||||
style={styles.container}
|
<ActivityIndicator size="large" />
|
||||||
screenDescription="profile"
|
</View>
|
||||||
moderation={uiState.profile.moderation.account}>
|
</CenteredView>
|
||||||
{uiState.profile.hasError ? (
|
|
||||||
<ErrorScreen
|
|
||||||
testID="profileErrorScreen"
|
|
||||||
title="Failed to load profile"
|
|
||||||
message={uiState.profile.error}
|
|
||||||
onPressTryAgain={onPressTryAgain}
|
|
||||||
/>
|
|
||||||
) : 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>
|
|
||||||
)}
|
|
||||||
<FAB
|
|
||||||
testID="composeFAB"
|
|
||||||
onPress={onPressCompose}
|
|
||||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`New post`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
</ScreenHider>
|
|
||||||
)
|
)
|
||||||
}),
|
}
|
||||||
)
|
if (resolveError || profileError) {
|
||||||
|
return (
|
||||||
function LoadingMoreFooter() {
|
<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 (
|
return (
|
||||||
<View style={styles.loadingMoreFooter}>
|
<CenteredView>
|
||||||
<ActivityIndicator />
|
<ErrorScreen
|
||||||
</View>
|
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 {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()
|
||||||
|
|
||||||
|
useSetTitle(combinedDisplayName(profile))
|
||||||
|
|
||||||
|
const moderation = useMemo(
|
||||||
|
() => moderateProfile(profile, moderationOpts),
|
||||||
|
[profile, moderationOpts],
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
- todo
|
||||||
|
- feeds
|
||||||
|
- lists
|
||||||
|
*/
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
screen('Profile')
|
||||||
|
const softResetSub = store.onScreenSoftReset(() => {
|
||||||
|
viewSelectorRef.current?.scrollToTop()
|
||||||
|
})
|
||||||
|
return () => softResetSub.remove()
|
||||||
|
}, [store, viewSelectorRef, setMinimalShellMode, screen]),
|
||||||
|
)
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
setDrawerSwipeDisabled(currentPage > 0)
|
||||||
|
return () => {
|
||||||
|
setDrawerSwipeDisabled(false)
|
||||||
|
}
|
||||||
|
}, [setDrawerSwipeDisabled, currentPage]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// events
|
||||||
|
// =
|
||||||
|
|
||||||
|
const onPressCompose = React.useCallback(() => {
|
||||||
|
track('ProfileScreen:PressCompose')
|
||||||
|
const mention =
|
||||||
|
profile.handle === currentAccount?.handle ||
|
||||||
|
profile.handle === 'handle.invalid'
|
||||||
|
? undefined
|
||||||
|
: profile.handle
|
||||||
|
store.shell.openComposer({mention})
|
||||||
|
}, [store, currentAccount, track, profile])
|
||||||
|
|
||||||
|
const onPageSelected = React.useCallback(
|
||||||
|
i => {
|
||||||
|
setCurrentPage(i)
|
||||||
|
},
|
||||||
|
[setCurrentPage],
|
||||||
|
)
|
||||||
|
|
||||||
|
// rendering
|
||||||
|
// =
|
||||||
|
|
||||||
|
const renderHeader = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
|
||||||
|
<FeedSection
|
||||||
|
ref={null}
|
||||||
|
feed={`likes|${profile.did}`}
|
||||||
|
onScroll={onScroll}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
isScrolledDown={isScrolledDown}
|
||||||
|
scrollElRef={scrollElRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue