import React, {useCallback, useMemo} from 'react' import {Pressable, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs, AtUri, 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 {useAnalytics} from '#/lib/analytics/analytics' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {useModalControls} from '#/state/modals' import { useListBlockMutation, useListDeleteMutation, useListMuteMutation, useListQuery, } from '#/state/queries/list' import {FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { useAddSavedFeedsMutation, usePreferencesQuery, 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 {useComposerControls} from '#/state/shell/composer' import {useHaptics} from 'lib/haptics' 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, makeProfileLink} from 'lib/routes/links' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' import {shareUrl} from 'lib/sharing' import {sanitizeHandle} from 'lib/strings/handles' import {toShareUrl} from 'lib/strings/url-helpers' import {s} from 'lib/styles' import {ListMembers} from '#/view/com/lists/ListMembers' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {Feed} from 'view/com/posts/Feed' 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 {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown' import {TextLink} from 'view/com/util/Link' import {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 {CenteredView} from 'view/com/util/Views' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' import hairlineWidth = StyleSheet.hairlineWidth const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] interface SectionRef { scrollToTop: () => void } type Props = NativeStackScreenProps export function ProfileListScreen(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: list, error: listError} = useListQuery(resolvedUri?.uri) if (resolveError) { return ( ) } if (listError) { return ( ) } return resolvedUri && list ? ( ) : ( ) } function ProfileListScreenLoaded({ route, uri, list, }: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const queryClient = useQueryClient() const {openComposer} = useComposerControls() const setMinimalShellMode = useSetMinimalShellMode() const {rkey} = route.params const feedSectionRef = React.useRef(null) const aboutSectionRef = React.useRef(null) const {openModal} = useModalControls() const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' const isScreenFocused = useIsFocused() useSetTitle(list.name) useFocusEffect( useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) const onPressAddUser = useCallback(() => { openModal({ name: 'list-add-remove-users', list, onChange() { if (isCurateList) { truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) } }, }) }, [openModal, list, 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]) if (isCurateList) { return ( {({headerHeight, scrollElRef, isFocused}) => ( )} {({headerHeight, scrollElRef}) => ( )} openComposer({})} icon={ } accessibilityRole="button" accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> ) } return ( {({headerHeight, scrollElRef}) => ( )} openComposer({})} icon={ } accessibilityRole="button" accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> ) } function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { 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 {data: preferences} = usePreferencesQuery() const {track} = useAnalytics() 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`)) 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`)) 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`List muted`)) track('Lists:Mute') } catch { Toast.show( _( msg`There was an issue. Please check your internet connection and try again.`, ), ) } }, [list, listMuteMutation, track, _]) const onUnsubscribeMute = useCallback(async () => { try { await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show(_(msg`List unmuted`)) track('Lists:Unmute') } catch { Toast.show( _( msg`There was an issue. Please check your internet connection and try again.`, ), ) } }, [list, listMuteMutation, track, _]) const onSubscribeBlock = useCallback(async () => { try { await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show(_(msg`List blocked`)) track('Lists:Block') } catch { Toast.show( _( msg`There was an issue. Please check your internet connection and try again.`, ), ) } }, [list, listBlockMutation, track, _]) const onUnsubscribeBlock = useCallback(async () => { try { await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show(_(msg`List unblocked`)) track('Lists:Unblock') } catch { Toast.show( _( msg`There was an issue. Please check your internet connection and try again.`, ), ) } }, [list, listBlockMutation, track, _]) 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`List deleted`)) track('Lists:Delete') if (navigation.canGoBack()) { navigation.goBack() } else { navigation.navigate('Home') } }, [ list, listDeleteMutation, navigation, track, _, removeSavedFeed, savedFeedConfig, ]) const onPressReport = useCallback(() => { reportDialogControl.open() }, [reportDialogControl]) const onPressShare = useCallback(() => { const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) track('Lists:Share') }, [list, rkey, track]) 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`Un-mute list`), onPress: onUnsubscribeMute, icon: { ios: { name: 'eye', }, android: '', web: 'eye', }, }) } if (isBlocking) { items.push({ testID: 'listHeaderDropdownBlockBtn', label: _(msg`Un-block 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]) return ( {isCurateList || isPinned ? ( ) } const styles = StyleSheet.create({ btn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 7, paddingHorizontal: 14, borderRadius: 50, marginLeft: 6, }, })