From 6432667f608fae447b59e41b9f8bb64b564205a1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 9 Sep 2025 20:20:33 +0300 Subject: ALF lists screen (#8941) * alf list screens * relocate to `#/screens`, balkanize * use useBreakpoints * showCancel on subscribe menu * fix typo --- src/Navigation.tsx | 2 +- src/screens/ProfileList/AboutSection.tsx | 136 +++ src/screens/ProfileList/FeedSection.tsx | 111 ++ src/screens/ProfileList/components/ErrorScreen.tsx | 46 + src/screens/ProfileList/components/Header.tsx | 208 ++++ .../ProfileList/components/MoreOptionsMenu.tsx | 298 ++++++ .../ProfileList/components/SubscribeMenu.tsx | 130 +++ src/screens/ProfileList/index.tsx | 296 ++++++ src/view/com/util/LoadingScreen.tsx | 17 - src/view/screens/ProfileList.tsx | 1061 -------------------- 10 files changed, 1226 insertions(+), 1079 deletions(-) create mode 100644 src/screens/ProfileList/AboutSection.tsx create mode 100644 src/screens/ProfileList/FeedSection.tsx create mode 100644 src/screens/ProfileList/components/ErrorScreen.tsx create mode 100644 src/screens/ProfileList/components/Header.tsx create mode 100644 src/screens/ProfileList/components/MoreOptionsMenu.tsx create mode 100644 src/screens/ProfileList/components/SubscribeMenu.tsx create mode 100644 src/screens/ProfileList/index.tsx delete mode 100644 src/view/com/util/LoadingScreen.tsx delete mode 100644 src/view/screens/ProfileList.tsx diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 003f9c2e8..45f078625 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -64,7 +64,6 @@ import {PostThreadScreen} from '#/view/screens/PostThread' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' -import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' @@ -92,6 +91,7 @@ import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' +import {ProfileListScreen} from '#/screens/ProfileList' import {SearchScreen} from '#/screens/Search' import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' diff --git a/src/screens/ProfileList/AboutSection.tsx b/src/screens/ProfileList/AboutSection.tsx new file mode 100644 index 000000000..47f29b838 --- /dev/null +++ b/src/screens/ProfileList/AboutSection.tsx @@ -0,0 +1,136 @@ +import {useCallback, useImperativeHandle, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isNative} from '#/platform/detection' +import {useSession} from '#/state/session' +import {ListMembers} from '#/view/com/lists/ListMembers' +import {EmptyState} from '#/view/com/util/EmptyState' +import {type ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' + +interface SectionRef { + scrollToTop: () => void +} + +interface AboutSectionProps { + ref?: React.Ref + list: AppBskyGraphDefs.ListView + onPressAddUser: () => void + headerHeight: number + scrollElRef: ListRef +} + +export function AboutSection({ + ref, + list, + onPressAddUser, + headerHeight, + scrollElRef, +}: AboutSectionProps) { + const {_} = useLingui() + const {currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const [isScrolledDown, setIsScrolledDown] = useState(false) + const isOwner = list.creator.did === currentAccount?.did + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderHeader = useCallback(() => { + if (!isOwner) { + return + } + if (!gtMobile) { + return ( + + + + ) + } + return ( + + + + ) + }, [isOwner, _, onPressAddUser, gtMobile]) + + const renderEmptyState = useCallback(() => { + return ( + + + {isOwner && ( + + )} + + ) + }, [_, onPressAddUser, isOwner]) + + return ( + + + {isScrolledDown && ( + + )} + + ) +} diff --git a/src/screens/ProfileList/FeedSection.tsx b/src/screens/ProfileList/FeedSection.tsx new file mode 100644 index 000000000..96b1452e2 --- /dev/null +++ b/src/screens/ProfileList/FeedSection.tsx @@ -0,0 +1,111 @@ +import {useCallback, useEffect, useImperativeHandle, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useIsFocused} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {isNative} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {type FeedDescriptor} from '#/state/queries/post-feed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {PostFeed} from '#/view/com/posts/PostFeed' +import {EmptyState} from '#/view/com/util/EmptyState' +import {type ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' + +interface SectionRef { + scrollToTop: () => void +} + +interface FeedSectionProps { + ref?: React.Ref + feed: FeedDescriptor + headerHeight: number + scrollElRef: ListRef + isFocused: boolean + isOwner: boolean + onPressAddUser: () => void +} + +export function FeedSection({ + ref, + feed, + scrollElRef, + headerHeight, + isFocused, + isOwner, + onPressAddUser, +}: FeedSectionProps) { + const queryClient = useQueryClient() + const [hasNew, setHasNew] = useState(false) + const [isScrolledDown, setIsScrolledDown] = useState(false) + const isScreenFocused = useIsFocused() + const {_} = useLingui() + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + useEffect(() => { + if (!isScreenFocused) { + return + } + return listenSoftReset(onScrollToTop) + }, [onScrollToTop, isScreenFocused]) + + const renderPostsEmpty = useCallback(() => { + return ( + + + {isOwner && ( + + )} + + ) + }, [_, onPressAddUser, isOwner]) + + return ( + + + {(isScrolledDown || hasNew) && ( + + )} + + ) +} diff --git a/src/screens/ProfileList/components/ErrorScreen.tsx b/src/screens/ProfileList/components/ErrorScreen.tsx new file mode 100644 index 000000000..7ce343def --- /dev/null +++ b/src/screens/ProfileList/components/ErrorScreen.tsx @@ -0,0 +1,46 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function ErrorScreen({error}: {error: React.ReactNode}) { + const t = useTheme() + const navigation = useNavigation() + const {_} = useLingui() + const onPressBack = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + } + + return ( + + + Could not load list + + + {error} + + + + + + + ) +} diff --git a/src/screens/ProfileList/components/Header.tsx b/src/screens/ProfileList/components/Header.tsx new file mode 100644 index 000000000..fe4b33c75 --- /dev/null +++ b/src/screens/ProfileList/components/Header.tsx @@ -0,0 +1,208 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {makeListLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' +import { + useAddSavedFeedsMutation, + type UsePreferencesQueryResponse, + useUpdateSavedFeedsMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Loader} from '#/components/Loader' +import {RichText} from '#/components/RichText' +import * as Toast from '#/components/Toast' +import {MoreOptionsMenu} from './MoreOptionsMenu' +import {SubscribeMenu} from './SubscribeMenu' + +export function Header({ + rkey, + list, + preferences, +}: { + rkey: string + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const playHaptic = useHaptics() + + const {mutateAsync: muteList, isPending: isMutePending} = + useListMuteMutation() + const {mutateAsync: blockList, isPending: isBlockPending} = + useListBlockMutation() + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = + useUpdateSavedFeedsMutation() + + const isPending = isAddSavedFeedPending || isUpdatingSavedFeeds + + const savedFeedConfig = preferences?.savedFeeds?.find( + f => f.value === list.uri, + ) + const isPinned = Boolean(savedFeedConfig?.pinned) + + const onTogglePinned = async () => { + playHaptic() + + try { + if (savedFeedConfig) { + const pinned = !savedFeedConfig.pinned + await updateSavedFeeds([ + { + ...savedFeedConfig, + pinned, + }, + ]) + Toast.show( + pinned + ? _(msg`Pinned to your feeds`) + : _(msg`Unpinned from your feeds`), + ) + } else { + await addSavedFeeds([ + { + type: 'list', + value: list.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Saved to your feeds`)) + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), { + type: 'error', + }) + logger.error('Failed to toggle pinned feed', {message: e}) + } + } + + const onUnsubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: false}) + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const onUnsubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: false}) + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) + + return ( + <> + + {isCurateList ? ( + + ) : isModList ? ( + isBlocking ? ( + + ) : isMuting ? ( + + ) : ( + + ) + ) : null} + + + {descriptionRT ? ( + + + + ) : null} + + ) +} diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx new file mode 100644 index 000000000..17ca43a82 --- /dev/null +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -0,0 +1,298 @@ +import {type AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import { + useListBlockMutation, + useListDeleteMutation, + useListMuteMutation, +} from '#/state/queries/list' +import {useRemoveFeedMutation} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' +import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' + +export function MoreOptionsMenu({ + list, + savedFeedConfig, +}: { + list: AppBskyGraphDefs.ListView + savedFeedConfig?: AppBskyActorDefs.SavedFeed +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const {openModal} = useModalControls() + const deleteListPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const navigation = useNavigation() + + const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() + const {mutateAsync: deleteList} = useListDeleteMutation() + const {mutateAsync: muteList} = useListMuteMutation() + const {mutateAsync: blockList} = useListBlockMutation() + + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isPinned = Boolean(savedFeedConfig?.pinned) + const isOwner = currentAccount?.did === list.creator.did + + const onPressShare = () => { + const {rkey} = new AtUri(list.uri) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) + shareUrl(url) + } + + const onRemoveFromSavedFeeds = async () => { + if (!savedFeedConfig) return + try { + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Removed from your feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), { + type: 'error', + }) + logger.error('Failed to remove pinned list', {message: e}) + } + } + + const onPressEdit = () => { + openModal({ + name: 'create-or-edit-list', + list, + }) + } + + const onPressDelete = async () => { + await deleteList({uri: list.uri}) + + if (savedFeedConfig) { + await removeSavedFeed(savedFeedConfig) + } + + Toast.show(_(msg({message: 'List deleted', context: 'toast'}))) + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + } + + const onUnpinModList = async () => { + try { + if (!savedFeedConfig) return + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Unpinned list`)) + } catch { + Toast.show(_(msg`Failed to unpin list`), { + type: 'error', + }) + } + } + + const onUnsubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: false}) + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const onUnsubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: false}) + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + return ( + <> + + + {({props}) => ( + + )} + + + + + + {isWeb ? ( + Copy link to list + ) : ( + Share via... + )} + + + + {savedFeedConfig && ( + + + Remove from my feeds + + + + )} + + + + + {isOwner ? ( + + + + Edit list details + + + + + + Delete list + + + + + ) : ( + + + + Report list + + + + + )} + + {isModList && isPinned && ( + <> + + + + + Unpin moderation list + + + + + + )} + + {isCurateList && (isBlocking || isMuting) && ( + <> + + + {isBlocking && ( + + + Unblock list + + + + )} + {isMuting && ( + + + Unmute list + + + + )} + + + )} + + + + + + + + ) +} diff --git a/src/screens/ProfileList/components/SubscribeMenu.tsx b/src/screens/ProfileList/components/SubscribeMenu.tsx new file mode 100644 index 000000000..5b6b9ba09 --- /dev/null +++ b/src/screens/ProfileList/components/SubscribeMenu.tsx @@ -0,0 +1,130 @@ +import {type AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' + +export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { + const {_} = useLingui() + const subscribeMutePromptControl = Prompt.usePromptControl() + const subscribeBlockPromptControl = Prompt.usePromptControl() + + const {mutateAsync: muteList, isPending: isMutePending} = + useListMuteMutation() + const {mutateAsync: blockList, isPending: isBlockPending} = + useListBlockMutation() + + const isPending = isMutePending || isBlockPending + + const onSubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: true}) + Toast.show(_(msg({message: 'List muted', context: 'toast'}))) + logger.metric( + 'moderation:subscribedToList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + {type: 'error'}, + ) + } + } + + const onSubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: true}) + Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) + logger.metric( + 'moderation:subscribedToList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + {type: 'error'}, + ) + } + } + + return ( + <> + + + {({props}) => ( + + )} + + + + + + Mute accounts + + + + + + Block accounts + + + + + + + + + + + + ) +} diff --git a/src/screens/ProfileList/index.tsx b/src/screens/ProfileList/index.tsx new file mode 100644 index 000000000..b3928c3d0 --- /dev/null +++ b/src/screens/ProfileList/index.tsx @@ -0,0 +1,296 @@ +import {useCallback, useMemo, useRef} from 'react' +import {View} from 'react-native' +import {useAnimatedRef} from 'react-native-reanimated' +import { + AppBskyGraphDefs, + AtUri, + moderateUserList, + type ModerationOpts, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {useSetTitle} from '#/lib/hooks/useSetTitle' +import {ComposeIcon2} from '#/lib/icons' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useListQuery} from '#/state/queries/list' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import { + usePreferencesQuery, + type UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {truncateAndInvalidate} from '#/state/queries/util' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' +import {FAB} from '#/view/com/util/fab/FAB' +import {type ListRef} from '#/view/com/util/List' +import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' +import {atoms as a, platform} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Hider from '#/components/moderation/Hider' +import {AboutSection} from './AboutSection' +import {ErrorScreen} from './components/ErrorScreen' +import {Header} from './components/Header' +import {FeedSection} from './FeedSection' + +interface SectionRef { + scrollToTop: () => void +} + +type Props = NativeStackScreenProps +export function ProfileListScreen(props: Props) { + return ( + + + + ) +} + +function ProfileListScreenInner(props: Props) { + const {_} = useLingui() + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: preferences} = usePreferencesQuery() + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) + const moderationOpts = useModerationOpts() + + if (resolveError) { + return ( + <> + + + + + Could not load list + + + + + + + + + ) + } + if (listError) { + return ( + <> + + + + + Could not load list + + + + + + + + + ) + } + + return resolvedUri && list && moderationOpts && preferences ? ( + + ) : ( + <> + + + + + + + + + + ) +} + +function ProfileListScreenLoaded({ + route, + uri, + list, + moderationOpts, + preferences, +}: Props & { + uri: string + list: AppBskyGraphDefs.ListView + moderationOpts: ModerationOpts + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const {openComposer} = useOpenComposer() + const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() + const {rkey} = route.params + const feedSectionRef = useRef(null) + const aboutSectionRef = useRef(null) + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST + const isScreenFocused = useIsFocused() + const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 + const isOwner = currentAccount?.did === list.creator.did + const scrollElRef = useAnimatedRef() + const addUserDialogControl = useDialogControl() + const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] + + const moderation = useMemo(() => { + return moderateUserList(list, moderationOpts) + }, [list, moderationOpts]) + + useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onChangeMembers = () => { + if (isCurateList) { + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) + } + } + + const onCurrentPageSelected = useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } else if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + const renderHeader = useCallback(() => { + return
+ }, [rkey, list, preferences]) + + if (isCurateList) { + return ( + + + + + + + + {({headerHeight, scrollElRef, isFocused}) => ( + + )} + {({headerHeight, scrollElRef}) => ( + + )} + + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + + + + ) + } + return ( + + + + + + + {renderHeader()} + + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + + + + ) +} diff --git a/src/view/com/util/LoadingScreen.tsx b/src/view/com/util/LoadingScreen.tsx deleted file mode 100644 index 1086c9d17..000000000 --- a/src/view/com/util/LoadingScreen.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {ActivityIndicator, View} from 'react-native' - -import {s} from '#/lib/styles' -import * as Layout from '#/components/Layout' - -/** - * @deprecated use Layout compoenents directly - */ -export function LoadingScreen() { - return ( - - - - - - ) -} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx deleted file mode 100644 index 78cf5d11e..000000000 --- a/src/view/screens/ProfileList.tsx +++ /dev/null @@ -1,1061 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {StyleSheet, View} from 'react-native' -import {useAnimatedRef} from 'react-native-reanimated' -import { - AppBskyGraphDefs, - AtUri, - moderateUserList, - type ModerationOpts, - RichText as RichTextAPI, -} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect, useIsFocused} from '@react-navigation/native' -import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {useHaptics} from '#/lib/haptics' -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {usePalette} from '#/lib/hooks/usePalette' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {ComposeIcon2} from '#/lib/icons' -import {makeListLink} from '#/lib/routes/links' -import { - type CommonNavigatorParams, - type NativeStackScreenProps, -} from '#/lib/routes/types' -import {type NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {cleanError} from '#/lib/strings/errors' -import {toShareUrl} from '#/lib/strings/url-helpers' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative, isWeb} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {useModalControls} from '#/state/modals' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import { - useListBlockMutation, - useListDeleteMutation, - useListMuteMutation, - useListQuery, -} from '#/state/queries/list' -import {type FeedDescriptor} from '#/state/queries/post-feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import { - useAddSavedFeedsMutation, - usePreferencesQuery, - type UsePreferencesQueryResponse, - useRemoveFeedMutation, - useUpdateSavedFeedsMutation, -} from '#/state/queries/preferences' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {truncateAndInvalidate} from '#/state/queries/util' -import {useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' -import {ListMembers} from '#/view/com/lists/ListMembers' -import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' -import {PostFeed} from '#/view/com/posts/PostFeed' -import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' -import {EmptyState} from '#/view/com/util/EmptyState' -import {FAB} from '#/view/com/util/fab/FAB' -import {Button} from '#/view/com/util/forms/Button' -import { - type DropdownItem, - NativeDropdown, -} from '#/view/com/util/forms/NativeDropdown' -import {type ListRef} from '#/view/com/util/List' -import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' -import {LoadingScreen} from '#/view/com/util/LoadingScreen' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' -import {atoms as a} from '#/alf' -import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' -import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' -import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' -import * as Layout from '#/components/Layout' -import * as Hider from '#/components/moderation/Hider' -import { - ReportDialog, - useReportDialogControl, -} from '#/components/moderation/ReportDialog' -import * as Prompt from '#/components/Prompt' -import {RichText} from '#/components/RichText' - -interface SectionRef { - scrollToTop: () => void -} - -type Props = NativeStackScreenProps -export function ProfileListScreen(props: Props) { - return ( - - - - ) -} - -function ProfileListScreenInner(props: Props) { - const {_} = useLingui() - const {name: handleOrDid, rkey} = props.route.params - const {data: resolvedUri, error: resolveError} = useResolveUriQuery( - AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), - ) - const {data: preferences} = usePreferencesQuery() - const {data: list, error: listError} = useListQuery(resolvedUri?.uri) - const moderationOpts = useModerationOpts() - - if (resolveError) { - return ( - - - - ) - } - if (listError) { - return ( - - - - ) - } - - return resolvedUri && list && moderationOpts && preferences ? ( - - ) : ( - - ) -} - -function ProfileListScreenLoaded({ - route, - uri, - list, - moderationOpts, - preferences, -}: Props & { - uri: string - list: AppBskyGraphDefs.ListView - moderationOpts: ModerationOpts - preferences: UsePreferencesQueryResponse -}) { - const {_} = useLingui() - const queryClient = useQueryClient() - const {openComposer} = useOpenComposer() - const setMinimalShellMode = useSetMinimalShellMode() - const {currentAccount} = useSession() - const {rkey} = route.params - const feedSectionRef = React.useRef(null) - const aboutSectionRef = React.useRef(null) - const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST - const isScreenFocused = useIsFocused() - const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 - const isOwner = currentAccount?.did === list.creator.did - const scrollElRef = useAnimatedRef() - const addUserDialogControl = useDialogControl() - const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] - - const moderation = React.useMemo(() => { - return moderateUserList(list, moderationOpts) - }, [list, moderationOpts]) - - useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onChangeMembers = useCallback(() => { - if (isCurateList) { - truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) - } - }, [list.uri, isCurateList, queryClient]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } else if (index === 1) { - aboutSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - - const renderHeader = useCallback(() => { - return
- }, [rkey, list, preferences]) - - if (isCurateList) { - return ( - - - - - - - - {({headerHeight, scrollElRef, isFocused}) => ( - - )} - {({headerHeight, scrollElRef}) => ( - - )} - - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - - - - - ) - } - return ( - - - - - - - {renderHeader()} - - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - - - - - ) -} - -function Header({ - rkey, - list, - preferences, -}: { - rkey: string - list: AppBskyGraphDefs.ListView - preferences: UsePreferencesQueryResponse -}) { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {_} = useLingui() - const navigation = useNavigation() - const {currentAccount} = useSession() - const reportDialogControl = useReportDialogControl() - const {openModal} = useModalControls() - const listMuteMutation = useListMuteMutation() - const listBlockMutation = useListBlockMutation() - const listDeleteMutation = useListDeleteMutation() - const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' - const isModList = list.purpose === 'app.bsky.graph.defs#modlist' - const isBlocking = !!list.viewer?.blocked - const isMuting = !!list.viewer?.muted - const isOwner = list.creator.did === currentAccount?.did - const playHaptic = useHaptics() - - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = - useAddSavedFeedsMutation() - const {mutateAsync: removeSavedFeed, isPending: isRemovePending} = - useRemoveFeedMutation() - const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = - useUpdateSavedFeedsMutation() - - const isPending = - isAddSavedFeedPending || isRemovePending || isUpdatingSavedFeeds - - const deleteListPromptControl = useDialogControl() - const subscribeMutePromptControl = useDialogControl() - const subscribeBlockPromptControl = useDialogControl() - - const savedFeedConfig = preferences?.savedFeeds?.find( - f => f.value === list.uri, - ) - const isPinned = Boolean(savedFeedConfig?.pinned) - - const onTogglePinned = React.useCallback(async () => { - playHaptic() - - try { - if (savedFeedConfig) { - const pinned = !savedFeedConfig.pinned - await updateSavedFeeds([ - { - ...savedFeedConfig, - pinned, - }, - ]) - Toast.show( - pinned - ? _(msg`Pinned to your feeds`) - : _(msg`Unpinned from your feeds`), - ) - } else { - await addSavedFeeds([ - { - type: 'list', - value: list.uri, - pinned: true, - }, - ]) - Toast.show(_(msg`Saved to your feeds`)) - } - } catch (e) { - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') - logger.error('Failed to toggle pinned feed', {message: e}) - } - }, [ - playHaptic, - addSavedFeeds, - updateSavedFeeds, - list.uri, - _, - savedFeedConfig, - ]) - - const onRemoveFromSavedFeeds = React.useCallback(async () => { - playHaptic() - if (!savedFeedConfig) return - try { - await removeSavedFeed(savedFeedConfig) - Toast.show(_(msg`Removed from your feeds`)) - } catch (e) { - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') - logger.error('Failed to remove pinned list', {message: e}) - } - }, [playHaptic, removeSavedFeed, _, savedFeedConfig]) - - const onSubscribeMute = useCallback(async () => { - try { - await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) - Toast.show(_(msg({message: 'List muted', context: 'toast'}))) - logger.metric( - 'moderation:subscribedToList', - {listType: 'mute'}, - {statsig: true}, - ) - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, [list, listMuteMutation, _]) - - const onUnsubscribeMute = useCallback(async () => { - try { - await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) - Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) - logger.metric( - 'moderation:unsubscribedFromList', - {listType: 'mute'}, - {statsig: true}, - ) - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, [list, listMuteMutation, _]) - - const onSubscribeBlock = useCallback(async () => { - try { - await listBlockMutation.mutateAsync({uri: list.uri, block: true}) - Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) - logger.metric( - 'moderation:subscribedToList', - {listType: 'block'}, - {statsig: true}, - ) - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, [list, listBlockMutation, _]) - - const onUnsubscribeBlock = useCallback(async () => { - try { - await listBlockMutation.mutateAsync({uri: list.uri, block: false}) - Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) - logger.metric( - 'moderation:unsubscribedFromList', - {listType: 'block'}, - {statsig: true}, - ) - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, [list, listBlockMutation, _]) - - const onPressEdit = useCallback(() => { - openModal({ - name: 'create-or-edit-list', - list, - }) - }, [openModal, list]) - - const onPressDelete = useCallback(async () => { - await listDeleteMutation.mutateAsync({uri: list.uri}) - - if (savedFeedConfig) { - await removeSavedFeed(savedFeedConfig) - } - - Toast.show(_(msg({message: 'List deleted', context: 'toast'}))) - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [ - list, - listDeleteMutation, - navigation, - _, - removeSavedFeed, - savedFeedConfig, - ]) - - const onPressReport = useCallback(() => { - reportDialogControl.open() - }, [reportDialogControl]) - - const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) - shareUrl(url) - }, [list, rkey]) - - const dropdownItems: DropdownItem[] = useMemo(() => { - let items: DropdownItem[] = [ - { - testID: 'listHeaderDropdownShareBtn', - label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`), - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: '', - web: 'share', - }, - }, - ] - - if (savedFeedConfig) { - items.push({ - testID: 'listHeaderDropdownRemoveFromFeedsBtn', - label: _(msg`Remove from my feeds`), - onPress: onRemoveFromSavedFeeds, - icon: { - ios: { - name: 'trash', - }, - android: '', - web: ['far', 'trash-can'], - }, - }) - } - - if (isOwner) { - items.push({label: 'separator'}) - items.push({ - testID: 'listHeaderDropdownEditBtn', - label: _(msg`Edit list details`), - onPress: onPressEdit, - icon: { - ios: { - name: 'pencil', - }, - android: '', - web: 'pen', - }, - }) - items.push({ - testID: 'listHeaderDropdownDeleteBtn', - label: _(msg`Delete list`), - onPress: deleteListPromptControl.open, - icon: { - ios: { - name: 'trash', - }, - android: '', - web: ['far', 'trash-can'], - }, - }) - } else { - items.push({label: 'separator'}) - items.push({ - testID: 'listHeaderDropdownReportBtn', - label: _(msg`Report list`), - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: '', - web: 'circle-exclamation', - }, - }) - } - if (isModList && isPinned) { - items.push({label: 'separator'}) - items.push({ - testID: 'listHeaderDropdownUnpinBtn', - label: _(msg`Unpin moderation list`), - onPress: - isPending || !savedFeedConfig - ? undefined - : () => removeSavedFeed(savedFeedConfig), - icon: { - ios: { - name: 'pin', - }, - android: '', - web: 'thumbtack', - }, - }) - } - if (isCurateList && (isBlocking || isMuting)) { - items.push({label: 'separator'}) - - if (isMuting) { - items.push({ - testID: 'listHeaderDropdownMuteBtn', - label: _(msg`Unmute list`), - onPress: onUnsubscribeMute, - icon: { - ios: { - name: 'eye', - }, - android: '', - web: 'eye', - }, - }) - } - - if (isBlocking) { - items.push({ - testID: 'listHeaderDropdownBlockBtn', - label: _(msg`Unblock list`), - onPress: onUnsubscribeBlock, - icon: { - ios: { - name: 'person.fill.xmark', - }, - android: '', - web: 'user-slash', - }, - }) - } - } - return items - }, [ - _, - onPressShare, - isOwner, - isModList, - isPinned, - isCurateList, - onPressEdit, - deleteListPromptControl.open, - onPressReport, - isPending, - isBlocking, - isMuting, - onUnsubscribeMute, - onUnsubscribeBlock, - removeSavedFeed, - savedFeedConfig, - onRemoveFromSavedFeeds, - ]) - - const subscribeDropdownItems: DropdownItem[] = useMemo(() => { - return [ - { - testID: 'subscribeDropdownMuteBtn', - label: _(msg`Mute accounts`), - onPress: subscribeMutePromptControl.open, - icon: { - ios: { - name: 'speaker.slash', - }, - android: '', - web: 'user-slash', - }, - }, - { - testID: 'subscribeDropdownBlockBtn', - label: _(msg`Block accounts`), - onPress: subscribeBlockPromptControl.open, - icon: { - ios: { - name: 'person.fill.xmark', - }, - android: '', - web: 'ban', - }, - }, - ] - }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) - - const descriptionRT = useMemo( - () => - list.description - ? new RichTextAPI({ - text: list.description, - facets: list.descriptionFacets, - }) - : undefined, - [list], - ) - - return ( - <> - - - {isCurateList ? ( - - - - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 7, - paddingHorizontal: 14, - borderRadius: 50, - marginLeft: 6, - }, -}) -- cgit 1.4.1