Delete some dead code and do some refactoring

This commit is contained in:
Paul Frazee
2024-03-04 13:24:22 -08:00
parent f30f31f123
commit 473ca0deff
6 changed files with 104 additions and 1273 deletions
+88
View File
@@ -0,0 +1,88 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ListRef} from 'view/com/util/List'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useQueryClient} from '@tanstack/react-query'
import {truncateAndInvalidate} from '#/state/queries/util'
import {Text} from '#/view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {SectionRef} from './types'
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
isFocused: boolean
scrollElRef: ListRef
ignoreFilterFor?: string
}
export const ProfileFeedSection = React.forwardRef<
SectionRef,
FeedSectionProps
>(function FeedSectionImpl(
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
ref,
) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = React.useCallback(() => {
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_])
return (
<View>
<Feed
testID="postsFeed"
enabled={isFocused}
feed={feed}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
})
function ProfileEndOfFeed() {
const pal = usePalette('default')
return (
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
<Trans>End of feed</Trans>
</Text>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export interface SectionRef {
scrollToTop: () => void
}
-34
View File
@@ -1,34 +0,0 @@
import {useQuery} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {STALE} from '#/state/queries'
// TODO refactor invalidate on mutate?
export const RQKEY = (did: string) => ['profile-extra-info', did]
/**
* Fetches some additional information for the profile screen which
* is not available in the API's ProfileView
*/
export function useProfileExtraInfoQuery(did: string) {
return useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(did),
async queryFn() {
const [listsRes, feedsRes] = await Promise.all([
getAgent().app.bsky.graph.getLists({
actor: did,
limit: 1,
}),
getAgent().app.bsky.feed.getActorFeeds({
actor: did,
limit: 1,
}),
])
return {
hasLists: listsRes.data.lists.length > 0,
hasFeedgens: feedsRes.data.feeds.length > 0,
}
},
})
}
-798
View File
@@ -1,798 +0,0 @@
import React, {memo, useMemo} from 'react'
import {
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {
AppBskyActorDefs,
ModerationOpts,
moderateProfile,
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, isWeb} from 'platform/detection'
import {BlurView} from '../util/BlurView'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {ThemedText} from '../util/text/ThemedText'
import {RichText} from '#/components/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
} from '#/state/queries/profile'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {useRequireAuth} from '#/state/session'
import {LabelsOnMe} from '../util/moderation/LabelsOnMe'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
import {atoms as a} from '#/alf'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
return (
<View style={pal.view}>
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
</View>
</View>
</View>
)
}
ProfileHeaderLoading = memo(ProfileHeaderLoading)
export {ProfileHeaderLoading}
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
descriptionRT: RichTextAPI | null
moderationOpts: ModerationOpts
hideBackButton?: boolean
isPlaceholderProfile?: boolean
}
let ProfileHeader = ({
profile: profileUnshadowed,
descriptionRT,
moderationOpts,
hideBackButton = false,
isPlaceholderProfile,
}: Props): React.ReactNode => {
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed)
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {currentAccount, hasSession} = useSession()
const requireAuth = useRequireAuth()
const {_} = useLingui()
const {openModal} = useModalControls()
const {openLightbox} = useLightboxControls()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(profile.handle)
const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const queryClient = useQueryClient()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const control = useReportDialogControl()
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const onPressAvi = React.useCallback(() => {
const modui = moderation.ui('avatar')
if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox(new ProfileImageLightbox(profile))
}
}, [openLightbox, profile, moderation])
const onPressFollow = () => {
requireAuth(async () => {
try {
track('ProfileHeader:FollowButtonClicked')
await queueFollow()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
}
const onPressUnfollow = () => {
requireAuth(async () => {
try {
track('ProfileHeader:UnfollowButtonClicked')
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {message: String(e)})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
}
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
openModal({
name: 'edit-profile',
profile,
})
}, [track, openModal, profile])
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [track, profile])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [track, profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueMute, _])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueUnmute, _])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Block Account`),
message: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
onPressConfirm: async () => {
try {
await queueBlock()
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueBlock, openModal, _])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueUnblock, openModal, _])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
if (NEW_REPORT_DIALOG_ENABLED) {
control.open()
} else {
openModal({
name: 'report',
did: profile.did,
})
}
}, [track, openModal, profile, control])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
]
if (hasSession) {
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: _(msg`Add to Lists`),
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
if (!profile.viewer?.mutedByList) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`),
onPress: profile.viewer?.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
}
if (!profile.viewer?.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: profile.viewer?.blocking
? _(msg`Unblock Account`)
: _(msg`Block Account`),
onPress: profile.viewer?.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: _(msg`Report Account`),
onPress: onPressReportAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
})
}
}
return items
}, [
isMe,
hasSession,
profile.viewer?.muted,
profile.viewer?.mutedByList,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
const blockHide =
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
return (
<View style={[pal.view]} pointerEvents="box-none">
<ReportDialog
control={control}
params={{type: 'profile', did: profile.did}}
/>
<View pointerEvents="none">
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height={150}
style={{borderRadius: 0}}
/>
) : (
<UserBanner
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
</View>
<View style={styles.content} pointerEvents="box-none">
<View style={[styles.buttonsLine]} pointerEvents="box-none">
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit profile`)}
accessibilityHint={_(
msg`Opens editor for profile display name, avatar, background image, and description`,
)}>
<Text type="button" style={pal.text}>
<Trans>Edit Profile</Trans>
</Text>
</TouchableOpacity>
) : profile.viewer?.blocking ? (
profile.viewer?.blockingByList ? null : (
<TouchableOpacity
testID="unblockBtn"
onPress={onPressUnblockAccount}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Unblock`)}
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
<Trans context="action">Unblock</Trans>
</Text>
</TouchableOpacity>
)
) : !profile.viewer?.blockedBy ? (
<>
{hasSession && (
<TouchableOpacity
testID="suggestedFollowsBtn"
onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
style={[
styles.btn,
styles.mainBtn,
pal.btn,
{
paddingHorizontal: 10,
backgroundColor: showSuggestedFollows
? pal.colors.text
: pal.colors.backgroundLight,
},
]}
accessibilityRole="button"
accessibilityLabel={_(
msg`Show follows similar to ${profile.handle}`,
)}
accessibilityHint={_(
msg`Shows a list of users similar to this user.`,
)}>
<FontAwesomeIcon
icon="user-plus"
style={[
pal.text,
{
color: showSuggestedFollows
? pal.textInverted.color
: pal.text.color,
},
]}
size={14}
/>
</TouchableOpacity>
)}
{profile.viewer?.following ? (
<TouchableOpacity
testID="unfollowBtn"
onPress={onPressUnfollow}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Unfollow ${profile.handle}`)}
accessibilityHint={_(
msg`Hides posts from ${profile.handle} in your feed`,
)}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
size={14}
/>
<Text type="button" style={pal.text}>
<Trans>Following</Trans>
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
testID="followBtn"
onPress={onPressFollow}
style={[styles.btn, styles.mainBtn, palInverted.view]}
accessibilityRole="button"
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
accessibilityHint={_(
msg`Shows posts from ${profile.handle} in your feed`,
)}>
<FontAwesomeIcon
icon="plus"
style={[palInverted.text, s.mr5]}
/>
<Text type="button" style={[palInverted.text, s.bold]}>
<Trans>Follow</Trans>
</Text>
</TouchableOpacity>
)}
</>
) : null}
{dropdownItems?.length ? (
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
</View>
</NativeDropdown>
) : undefined}
</View>
<View pointerEvents="none">
<Text
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
</View>
<View style={styles.handleLine} pointerEvents="none">
{profile.viewer?.followedBy && !blockHide ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
<Trans>Follows you</Trans>
</Text>
</View>
) : undefined}
<ThemedText
type={invalidHandle ? 'xs' : 'md'}
fg={invalidHandle ? 'error' : 'light'}
border={invalidHandle ? 'error' : undefined}
style={[
invalidHandle ? styles.invalidHandle : undefined,
styles.handle,
]}>
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
</ThemedText>
</View>
{!isPlaceholderProfile && !blockHide && (
<>
<View style={styles.metricsLine} pointerEvents="box-none">
<Link
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
href={makeProfileLink(profile, 'followers')}
onPressOut={() =>
track(`ProfileHeader:FollowersButtonClicked`, {
handle: profile.handle,
})
}
asAnchor
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={_(msg`Opens followers list`)}>
<Text type="md" style={[s.bold, pal.text]}>
{followers}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
{pluralizedFollowers}
</Text>
</Link>
<Link
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
href={makeProfileLink(profile, 'follows')}
onPressOut={() =>
track(`ProfileHeader:FollowsButtonClicked`, {
handle: profile.handle,
})
}
asAnchor
accessibilityLabel={_(msg`${following} following`)}
accessibilityHint={_(msg`Opens following list`)}>
<Trans>
<Text type="md" style={[s.bold, pal.text]}>
{following}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</Trans>
</Link>
<Text type="md" style={[s.bold, pal.text]}>
{formatCount(profile.postsCount || 0)}{' '}
<Text type="md" style={[pal.textLight]}>
{pluralize(profile.postsCount || 0, 'post')}
</Text>
</Text>
</View>
{descriptionRT && !moderation.ui('profileView').blur ? (
<View pointerEvents="auto" style={[styles.description]}>
<RichText
testID="profileHeaderDescription"
style={[a.text_md]}
numberOfLines={15}
value={descriptionRT}
/>
</View>
) : undefined}
</>
)}
<ProfileHeaderAlerts moderation={moderation} />
{isMe && (
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
)}
</View>
{showSuggestedFollows && (
<ProfileHeaderSuggestedFollows
actorDid={profile.did}
requestDismiss={() => {
if (showSuggestedFollows) {
setShowSuggestedFollows(false)
} else {
track('ProfileHeader:SuggestedFollowsOpened')
setShowSuggestedFollows(true)
}
}}
/>
)}
{!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<View style={styles.backBtnWrapper}>
<BlurView style={styles.backBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
</BlurView>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<UserAvatar
size={80}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
}
ProfileHeader = memo(ProfileHeader)
export {ProfileHeader}
const styles = StyleSheet.create({
banner: {
width: '100%',
height: 120,
},
backBtnWrapper: {
position: 'absolute',
top: 10,
left: 10,
width: 30,
height: 30,
overflow: 'hidden',
borderRadius: 15,
// @ts-ignore web only
cursor: 'pointer',
},
backBtn: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center',
},
avi: {
position: 'absolute',
top: 110,
left: 10,
width: 84,
height: 84,
borderRadius: 42,
borderWidth: 2,
},
content: {
paddingTop: 8,
paddingHorizontal: 14,
paddingBottom: 4,
},
buttonsLine: {
flexDirection: 'row',
marginLeft: 'auto',
marginBottom: 12,
},
primaryBtn: {
backgroundColor: colors.blue3,
paddingHorizontal: 24,
paddingVertical: 6,
},
mainBtn: {
paddingHorizontal: 24,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
},
title: {lineHeight: 38},
// Word wrapping appears fine on
// mobile but overflows on desktop
handle: isNative
? {}
: {
// @ts-ignore web only -prf
wordBreak: 'break-all',
},
invalidHandle: {
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 4,
},
handleLine: {
flexDirection: 'row',
marginBottom: 8,
},
metricsLine: {
flexDirection: 'row',
marginBottom: 8,
},
description: {
marginBottom: 8,
},
detailLine: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 5,
},
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
br40: {borderRadius: 40},
br50: {borderRadius: 50},
})
+1 -1
View File
@@ -38,7 +38,7 @@ import {
ChevronTop_Stroke2_Corner0_Rounded as ChevronTop,
} from '#/components/icons/Chevron'
import {ScreenHider} from '../com/util/moderation/ScreenHider'
import {ProfileHeader} from '../com/profile/ProfileHeader'
import {ProfileHeader} from '#/screens/Profile/Header'
import {ProfileCard} from '../com/profile/ProfileCard'
import {FeedItem} from '../com/posts/FeedItem'
import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem'
+12 -440
View File
@@ -1,5 +1,5 @@
import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {StyleSheet} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
AppBskyActorDefs,
@@ -7,54 +7,38 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {CenteredView, ScrollView} from '../com/util/Views'
import {CenteredView} from '../com/util/Views'
import {ListRef} from '../com/util/List'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {Feed} from 'view/com/posts/Feed'
import {ProfileLists} from '../com/lists/ProfileLists'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {EmptyState} from '../com/util/EmptyState'
import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {ComposeIcon2} from 'lib/icons'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
import {
FeedDescriptor,
resetProfilePostsQueries,
} from '#/state/queries/post-feed'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useProfileQuery} from '#/state/queries/profile'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {cleanError} from '#/lib/strings/errors'
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
import {useQueryClient} from '@tanstack/react-query'
import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset} from '#/state/events'
import {truncateAndInvalidate} from '#/state/queries/util'
import {Text} from '#/view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {isInvalidHandle} from '#/lib/strings/handles'
import {useTheme, atoms as a} from '#/alf'
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileContentFiltersSection} from '#/screens/Profile/Sections/ContentFilters'
import {ProfileHeader as ProfileHeaderV2} from '#/screens/Profile/Header'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
interface SectionRef {
scrollToTop: () => void
@@ -119,7 +103,7 @@ export function ProfileScreen({route}: Props) {
}
if (profile && moderationOpts) {
return (
<ProfileScreenLoadedV2
<ProfileScreenLoaded
profile={profile}
moderationOpts={moderationOpts}
isPlaceholderProfile={isPlaceholderProfile}
@@ -149,298 +133,6 @@ function ProfileScreenLoaded({
moderationOpts: ModerationOpts
hideBackButton: boolean
isPlaceholderProfile: boolean
}) {
const profile = useProfileShadow(profileUnshadowed)
const {hasSession, currentAccount} = useSession()
const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics()
const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
const postsSectionRef = React.useRef<SectionRef>(null)
const repliesSectionRef = React.useRef<SectionRef>(null)
const mediaSectionRef = React.useRef<SectionRef>(null)
const likesSectionRef = React.useRef<SectionRef>(null)
const feedsSectionRef = React.useRef<SectionRef>(null)
const listsSectionRef = React.useRef<SectionRef>(null)
const moderationSectionRef = React.useRef<SectionRef>(null)
useSetTitle(combinedDisplayName(profile))
const description = profile.description ?? ''
const hasDescription = description !== ''
const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isMe = profile.did === currentAccount?.did
const showRepliesTab = hasSession
const showLikesTab = isMe
const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens)
const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists)
const showModerationTab =
hasSession && (isMe || true) /* TODO true should be associated modservice */
const sectionTitles = useMemo<string[]>(() => {
return [
_(msg`Posts`),
showRepliesTab ? _(msg`Replies`) : undefined,
_(msg`Media`),
showLikesTab ? _(msg`Likes`) : undefined,
showFeedsTab ? _(msg`Feeds`) : undefined,
showListsTab ? _(msg`Lists`) : undefined,
showModerationTab ? _(msg`Moderation`) : undefined,
].filter(Boolean) as string[]
}, [
showRepliesTab,
showLikesTab,
showFeedsTab,
showListsTab,
showModerationTab,
_,
])
let nextIndex = 0
const postsIndex = nextIndex++
let repliesIndex: number | null = null
if (showRepliesTab) {
repliesIndex = nextIndex++
}
const mediaIndex = nextIndex++
let likesIndex: number | null = null
if (showLikesTab) {
likesIndex = nextIndex++
}
let feedsIndex: number | null = null
if (showFeedsTab) {
feedsIndex = nextIndex++
}
let listsIndex: number | null = null
if (showListsTab) {
listsIndex = nextIndex++
}
let moderationIndex: number | null = null
if (showModerationTab) {
moderationIndex = nextIndex++
}
const scrollSectionToTop = React.useCallback(
(index: number) => {
if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
repliesSectionRef.current?.scrollToTop()
} else if (index === mediaIndex) {
mediaSectionRef.current?.scrollToTop()
} else if (index === likesIndex) {
likesSectionRef.current?.scrollToTop()
} else if (index === feedsIndex) {
feedsSectionRef.current?.scrollToTop()
} else if (index === listsIndex) {
listsSectionRef.current?.scrollToTop()
} else if (index === moderationIndex) {
moderationSectionRef.current?.scrollToTop()
}
},
[
postsIndex,
repliesIndex,
mediaIndex,
likesIndex,
feedsIndex,
listsIndex,
moderationIndex,
],
)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
screen('Profile')
return listenSoftReset(() => {
scrollSectionToTop(currentPage)
})
}, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]),
)
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 ||
isInvalidHandle(profile.handle)
? undefined
: profile.handle
openComposer({mention})
}, [openComposer, currentAccount, track, profile])
const onPageSelected = React.useCallback(
(i: number) => {
setCurrentPage(i)
},
[setCurrentPage],
)
const onCurrentPageSelected = React.useCallback(
(index: number) => {
scrollSectionToTop(index)
},
[scrollSectionToTop],
)
// rendering
// =
const renderHeader = React.useCallback(() => {
return (
<ProfileHeader
profile={profile}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/>
)
}, [
profile,
descriptionRT,
hasDescription,
moderationOpts,
hideBackButton,
showPlaceholder,
])
return (
<ScreenHider
testID="profileView"
style={styles.container}
screenDescription="profile"
modui={moderation.ui('profileView')}>
<PagerWithHeader
testID="profilePager"
isHeaderReady={!showPlaceholder}
items={sectionTitles}
onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}>
{({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={postsSectionRef}
feed={`author|${profile.did}|posts_and_author_threads`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)}
{showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)}
{showLikesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
ref={likesSectionRef}
feed={`likes|${profile.did}`}
headerHeight={headerHeight}
isFocused={isFocused}
scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did}
/>
)
: null}
{showFeedsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileFeedgens
ref={feedsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
{showListsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
{showModerationTab
? ({headerHeight, isFocused, scrollElRef}) => (
<ModerationSection
// ref={moderationSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
enabled={isFocused}
/>
)
: null}
</PagerWithHeader>
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
</ScreenHider>
)
}
function ProfileScreenLoadedV2({
profile: profileUnshadowed,
isPlaceholderProfile,
moderationOpts,
hideBackButton,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
hideBackButton: boolean
isPlaceholderProfile: boolean
}) {
const profile = useProfileShadow(profileUnshadowed)
const {hasSession, currentAccount} = useSession()
@@ -618,7 +310,7 @@ function ProfileScreenLoadedV2({
const renderHeader = React.useCallback(() => {
return (
<ProfileHeaderV2
<ProfileHeader
profile={profile}
modservice={modServiceQuery.data}
descriptionRT={hasDescription ? descriptionRT : null}
@@ -675,7 +367,7 @@ function ProfileScreenLoadedV2({
: null}
{showPostsTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={postsSectionRef}
feed={`author|${profile.did}|posts_and_author_threads`}
headerHeight={headerHeight}
@@ -687,7 +379,7 @@ function ProfileScreenLoadedV2({
: null}
{showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`}
headerHeight={headerHeight}
@@ -699,7 +391,7 @@ function ProfileScreenLoadedV2({
: null}
{showMediaTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`}
headerHeight={headerHeight}
@@ -711,7 +403,7 @@ function ProfileScreenLoadedV2({
: null}
{showLikesTab
? ({headerHeight, isFocused, scrollElRef}) => (
<FeedSection
<ProfileFeedSection
ref={likesSectionRef}
feed={`likes|${profile.did}`}
headerHeight={headerHeight}
@@ -758,126 +450,6 @@ function ProfileScreenLoadedV2({
)
}
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
isFocused: boolean
scrollElRef: ListRef
ignoreFilterFor?: string
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
ref,
) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = React.useCallback(() => {
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_])
return (
<View>
<Feed
testID="postsFeed"
enabled={isFocused}
feed={feed}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
},
)
function ProfileEndOfFeed() {
const pal = usePalette('default')
return (
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
<Trans>End of feed</Trans>
</Text>
</View>
)
}
function ModerationSection({did}: {did: string}) {
const t = useTheme()
return (
<ScrollView>
<ModerationServiceCard.Loader
did={did}
component={({modservice}) => (
<ModerationServiceCard.Link modservice={modservice}>
{ctx => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_md,
a.p_md,
a.border_t,
t.atoms.border_contrast_low,
...(ctx.focused || ctx.hovered
? [t.atoms.bg_contrast_25]
: []),
]}>
<View
style={[
{backgroundColor: t.palette.negative_25},
a.p_lg,
a.rounded_sm,
]}>
<RaisingHand
width={36}
style={[a.z_10]}
fill={t.palette.negative_500}
/>
</View>
<ModerationServiceCard.Card.Content
title="Moderation service"
description={modservice.description}
handle={modservice.creator.handle}
likeCount={modservice.likeCount}
/>
</View>
)}
</ModerationServiceCard.Link>
)}
/>
</ScrollView>
)
}
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))