Bluesky app fork with some witchin' additions 💫

ALF lists screen (#8941)

* alf list screens

* relocate to `#/screens`, balkanize

* use useBreakpoints

* showCancel on subscribe menu

* fix typo

authored by samuel.fm and committed by

GitHub 6432667f 4a1b1f17

+1226 -1079
+1 -1
src/Navigation.tsx
··· 64 64 import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' 65 65 import {ProfileScreen} from '#/view/screens/Profile' 66 66 import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 67 - import {ProfileListScreen} from '#/view/screens/ProfileList' 68 67 import {SavedFeeds} from '#/view/screens/SavedFeeds' 69 68 import {Storybook} from '#/view/screens/Storybook' 70 69 import {SupportScreen} from '#/view/screens/Support' ··· 92 91 import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' 93 92 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 94 93 import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' 94 + import {ProfileListScreen} from '#/screens/ProfileList' 95 95 import {SearchScreen} from '#/screens/Search' 96 96 import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' 97 97 import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
+136
src/screens/ProfileList/AboutSection.tsx
··· 1 + import {useCallback, useImperativeHandle, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type AppBskyGraphDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {isNative} from '#/platform/detection' 8 + import {useSession} from '#/state/session' 9 + import {ListMembers} from '#/view/com/lists/ListMembers' 10 + import {EmptyState} from '#/view/com/util/EmptyState' 11 + import {type ListRef} from '#/view/com/util/List' 12 + import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 13 + import {atoms as a, useBreakpoints} from '#/alf' 14 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 + import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 16 + 17 + interface SectionRef { 18 + scrollToTop: () => void 19 + } 20 + 21 + interface AboutSectionProps { 22 + ref?: React.Ref<SectionRef> 23 + list: AppBskyGraphDefs.ListView 24 + onPressAddUser: () => void 25 + headerHeight: number 26 + scrollElRef: ListRef 27 + } 28 + 29 + export function AboutSection({ 30 + ref, 31 + list, 32 + onPressAddUser, 33 + headerHeight, 34 + scrollElRef, 35 + }: AboutSectionProps) { 36 + const {_} = useLingui() 37 + const {currentAccount} = useSession() 38 + const {gtMobile} = useBreakpoints() 39 + const [isScrolledDown, setIsScrolledDown] = useState(false) 40 + const isOwner = list.creator.did === currentAccount?.did 41 + 42 + const onScrollToTop = useCallback(() => { 43 + scrollElRef.current?.scrollToOffset({ 44 + animated: isNative, 45 + offset: -headerHeight, 46 + }) 47 + }, [scrollElRef, headerHeight]) 48 + 49 + useImperativeHandle(ref, () => ({ 50 + scrollToTop: onScrollToTop, 51 + })) 52 + 53 + const renderHeader = useCallback(() => { 54 + if (!isOwner) { 55 + return <View /> 56 + } 57 + if (!gtMobile) { 58 + return ( 59 + <View style={[a.px_sm, a.py_sm]}> 60 + <Button 61 + testID="addUserBtn" 62 + label={_(msg`Add a user to this list`)} 63 + onPress={onPressAddUser} 64 + color="primary" 65 + size="small" 66 + variant="outline" 67 + style={[a.py_md]}> 68 + <ButtonIcon icon={PersonPlusIcon} /> 69 + <ButtonText> 70 + <Trans>Add people</Trans> 71 + </ButtonText> 72 + </Button> 73 + </View> 74 + ) 75 + } 76 + return ( 77 + <View style={[a.px_lg, a.py_md, a.flex_row_reverse]}> 78 + <Button 79 + testID="addUserBtn" 80 + label={_(msg`Add a user to this list`)} 81 + onPress={onPressAddUser} 82 + color="primary" 83 + size="small" 84 + variant="ghost" 85 + style={[a.py_sm]}> 86 + <ButtonIcon icon={PersonPlusIcon} /> 87 + <ButtonText> 88 + <Trans>Add people</Trans> 89 + </ButtonText> 90 + </Button> 91 + </View> 92 + ) 93 + }, [isOwner, _, onPressAddUser, gtMobile]) 94 + 95 + const renderEmptyState = useCallback(() => { 96 + return ( 97 + <View style={[a.gap_xl, a.align_center]}> 98 + <EmptyState icon="users-slash" message={_(msg`This list is empty.`)} /> 99 + {isOwner && ( 100 + <Button 101 + testID="emptyStateAddUserBtn" 102 + label={_(msg`Start adding people`)} 103 + onPress={onPressAddUser} 104 + color="primary" 105 + size="small"> 106 + <ButtonIcon icon={PersonPlusIcon} /> 107 + <ButtonText> 108 + <Trans>Start adding people!</Trans> 109 + </ButtonText> 110 + </Button> 111 + )} 112 + </View> 113 + ) 114 + }, [_, onPressAddUser, isOwner]) 115 + 116 + return ( 117 + <View> 118 + <ListMembers 119 + testID="listItems" 120 + list={list.uri} 121 + scrollElRef={scrollElRef} 122 + renderHeader={renderHeader} 123 + renderEmptyState={renderEmptyState} 124 + headerOffset={headerHeight} 125 + onScrolledDownChange={setIsScrolledDown} 126 + /> 127 + {isScrolledDown && ( 128 + <LoadLatestBtn 129 + onPress={onScrollToTop} 130 + label={_(msg`Scroll to top`)} 131 + showIndicator={false} 132 + /> 133 + )} 134 + </View> 135 + ) 136 + }
+111
src/screens/ProfileList/FeedSection.tsx
··· 1 + import {useCallback, useEffect, useImperativeHandle, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useIsFocused} from '@react-navigation/native' 6 + import {useQueryClient} from '@tanstack/react-query' 7 + 8 + import {isNative} from '#/platform/detection' 9 + import {listenSoftReset} from '#/state/events' 10 + import {type FeedDescriptor} from '#/state/queries/post-feed' 11 + import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 12 + import {PostFeed} from '#/view/com/posts/PostFeed' 13 + import {EmptyState} from '#/view/com/util/EmptyState' 14 + import {type ListRef} from '#/view/com/util/List' 15 + import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 16 + import {atoms as a} from '#/alf' 17 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 + import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 19 + 20 + interface SectionRef { 21 + scrollToTop: () => void 22 + } 23 + 24 + interface FeedSectionProps { 25 + ref?: React.Ref<SectionRef> 26 + feed: FeedDescriptor 27 + headerHeight: number 28 + scrollElRef: ListRef 29 + isFocused: boolean 30 + isOwner: boolean 31 + onPressAddUser: () => void 32 + } 33 + 34 + export function FeedSection({ 35 + ref, 36 + feed, 37 + scrollElRef, 38 + headerHeight, 39 + isFocused, 40 + isOwner, 41 + onPressAddUser, 42 + }: FeedSectionProps) { 43 + const queryClient = useQueryClient() 44 + const [hasNew, setHasNew] = useState(false) 45 + const [isScrolledDown, setIsScrolledDown] = useState(false) 46 + const isScreenFocused = useIsFocused() 47 + const {_} = useLingui() 48 + 49 + const onScrollToTop = useCallback(() => { 50 + scrollElRef.current?.scrollToOffset({ 51 + animated: isNative, 52 + offset: -headerHeight, 53 + }) 54 + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) 55 + setHasNew(false) 56 + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) 57 + useImperativeHandle(ref, () => ({ 58 + scrollToTop: onScrollToTop, 59 + })) 60 + 61 + useEffect(() => { 62 + if (!isScreenFocused) { 63 + return 64 + } 65 + return listenSoftReset(onScrollToTop) 66 + }, [onScrollToTop, isScreenFocused]) 67 + 68 + const renderPostsEmpty = useCallback(() => { 69 + return ( 70 + <View style={[a.gap_xl, a.align_center]}> 71 + <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> 72 + {isOwner && ( 73 + <Button 74 + label={_(msg`Start adding people`)} 75 + onPress={onPressAddUser} 76 + color="primary" 77 + size="small"> 78 + <ButtonIcon icon={PersonPlusIcon} /> 79 + <ButtonText> 80 + <Trans>Start adding people!</Trans> 81 + </ButtonText> 82 + </Button> 83 + )} 84 + </View> 85 + ) 86 + }, [_, onPressAddUser, isOwner]) 87 + 88 + return ( 89 + <View> 90 + <PostFeed 91 + testID="listFeed" 92 + enabled={isFocused} 93 + feed={feed} 94 + pollInterval={60e3} 95 + disablePoll={hasNew} 96 + scrollElRef={scrollElRef} 97 + onHasNew={setHasNew} 98 + onScrolledDownChange={setIsScrolledDown} 99 + renderEmptyState={renderPostsEmpty} 100 + headerOffset={headerHeight} 101 + /> 102 + {(isScrolledDown || hasNew) && ( 103 + <LoadLatestBtn 104 + onPress={onScrollToTop} 105 + label={_(msg`Load new posts`)} 106 + showIndicator={hasNew} 107 + /> 108 + )} 109 + </View> 110 + ) 111 + }
+46
src/screens/ProfileList/components/ErrorScreen.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + import {useNavigation} from '@react-navigation/native' 5 + 6 + import {type NavigationProp} from '#/lib/routes/types' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Button, ButtonText} from '#/components/Button' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function ErrorScreen({error}: {error: React.ReactNode}) { 12 + const t = useTheme() 13 + const navigation = useNavigation<NavigationProp>() 14 + const {_} = useLingui() 15 + const onPressBack = () => { 16 + if (navigation.canGoBack()) { 17 + navigation.goBack() 18 + } else { 19 + navigation.navigate('Home') 20 + } 21 + } 22 + 23 + return ( 24 + <View style={[a.px_xl, a.py_md, a.gap_md]}> 25 + <Text style={[a.text_4xl, a.font_heavy]}> 26 + <Trans>Could not load list</Trans> 27 + </Text> 28 + <Text style={[a.text_md, t.atoms.text_contrast_high, a.leading_snug]}> 29 + {error} 30 + </Text> 31 + 32 + <View style={[a.flex_row, a.mt_lg]}> 33 + <Button 34 + label={_(msg`Go back`)} 35 + accessibilityHint={_(msg`Returns to previous page`)} 36 + onPress={onPressBack} 37 + size="small" 38 + color="secondary"> 39 + <ButtonText> 40 + <Trans>Go back</Trans> 41 + </ButtonText> 42 + </Button> 43 + </View> 44 + </View> 45 + ) 46 + }
+208
src/screens/ProfileList/components/Header.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {useHaptics} from '#/lib/haptics' 8 + import {makeListLink} from '#/lib/routes/links' 9 + import {logger} from '#/logger' 10 + import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' 11 + import { 12 + useAddSavedFeedsMutation, 13 + type UsePreferencesQueryResponse, 14 + useUpdateSavedFeedsMutation, 15 + } from '#/state/queries/preferences' 16 + import {useSession} from '#/state/session' 17 + import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' 18 + import {atoms as a} from '#/alf' 19 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 20 + import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 21 + import {Loader} from '#/components/Loader' 22 + import {RichText} from '#/components/RichText' 23 + import * as Toast from '#/components/Toast' 24 + import {MoreOptionsMenu} from './MoreOptionsMenu' 25 + import {SubscribeMenu} from './SubscribeMenu' 26 + 27 + export function Header({ 28 + rkey, 29 + list, 30 + preferences, 31 + }: { 32 + rkey: string 33 + list: AppBskyGraphDefs.ListView 34 + preferences: UsePreferencesQueryResponse 35 + }) { 36 + const {_} = useLingui() 37 + const {currentAccount} = useSession() 38 + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 39 + const isModList = list.purpose === AppBskyGraphDefs.MODLIST 40 + const isBlocking = !!list.viewer?.blocked 41 + const isMuting = !!list.viewer?.muted 42 + const playHaptic = useHaptics() 43 + 44 + const {mutateAsync: muteList, isPending: isMutePending} = 45 + useListMuteMutation() 46 + const {mutateAsync: blockList, isPending: isBlockPending} = 47 + useListBlockMutation() 48 + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 49 + useAddSavedFeedsMutation() 50 + const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = 51 + useUpdateSavedFeedsMutation() 52 + 53 + const isPending = isAddSavedFeedPending || isUpdatingSavedFeeds 54 + 55 + const savedFeedConfig = preferences?.savedFeeds?.find( 56 + f => f.value === list.uri, 57 + ) 58 + const isPinned = Boolean(savedFeedConfig?.pinned) 59 + 60 + const onTogglePinned = async () => { 61 + playHaptic() 62 + 63 + try { 64 + if (savedFeedConfig) { 65 + const pinned = !savedFeedConfig.pinned 66 + await updateSavedFeeds([ 67 + { 68 + ...savedFeedConfig, 69 + pinned, 70 + }, 71 + ]) 72 + Toast.show( 73 + pinned 74 + ? _(msg`Pinned to your feeds`) 75 + : _(msg`Unpinned from your feeds`), 76 + ) 77 + } else { 78 + await addSavedFeeds([ 79 + { 80 + type: 'list', 81 + value: list.uri, 82 + pinned: true, 83 + }, 84 + ]) 85 + Toast.show(_(msg`Saved to your feeds`)) 86 + } 87 + } catch (e) { 88 + Toast.show(_(msg`There was an issue contacting the server`), { 89 + type: 'error', 90 + }) 91 + logger.error('Failed to toggle pinned feed', {message: e}) 92 + } 93 + } 94 + 95 + const onUnsubscribeMute = async () => { 96 + try { 97 + await muteList({uri: list.uri, mute: false}) 98 + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 99 + logger.metric( 100 + 'moderation:unsubscribedFromList', 101 + {listType: 'mute'}, 102 + {statsig: true}, 103 + ) 104 + } catch { 105 + Toast.show( 106 + _( 107 + msg`There was an issue. Please check your internet connection and try again.`, 108 + ), 109 + ) 110 + } 111 + } 112 + 113 + const onUnsubscribeBlock = async () => { 114 + try { 115 + await blockList({uri: list.uri, block: false}) 116 + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 117 + logger.metric( 118 + 'moderation:unsubscribedFromList', 119 + {listType: 'block'}, 120 + {statsig: true}, 121 + ) 122 + } catch { 123 + Toast.show( 124 + _( 125 + msg`There was an issue. Please check your internet connection and try again.`, 126 + ), 127 + ) 128 + } 129 + } 130 + 131 + const descriptionRT = useMemo( 132 + () => 133 + list.description 134 + ? new RichTextAPI({ 135 + text: list.description, 136 + facets: list.descriptionFacets, 137 + }) 138 + : undefined, 139 + [list], 140 + ) 141 + 142 + return ( 143 + <> 144 + <ProfileSubpageHeader 145 + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} 146 + title={list.name} 147 + avatar={list.avatar} 148 + isOwner={list.creator.did === currentAccount?.did} 149 + creator={list.creator} 150 + purpose={list.purpose} 151 + avatarType="list"> 152 + {isCurateList ? ( 153 + <Button 154 + testID={isPinned ? 'unpinBtn' : 'pinBtn'} 155 + color={isPinned ? 'secondary' : 'primary_subtle'} 156 + label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} 157 + onPress={onTogglePinned} 158 + disabled={isPending} 159 + size="small" 160 + style={[a.rounded_full]}> 161 + {!isPinned && <ButtonIcon icon={isPending ? Loader : PinIcon} />} 162 + <ButtonText> 163 + {isPinned ? <Trans>Unpin</Trans> : <Trans>Pin to home</Trans>} 164 + </ButtonText> 165 + </Button> 166 + ) : isModList ? ( 167 + isBlocking ? ( 168 + <Button 169 + testID="unblockBtn" 170 + color="secondary" 171 + label={_(msg`Unblock`)} 172 + onPress={onUnsubscribeBlock} 173 + size="small" 174 + style={[a.rounded_full]} 175 + disabled={isBlockPending}> 176 + {isBlockPending && <ButtonIcon icon={Loader} />} 177 + <ButtonText> 178 + <Trans>Unblock</Trans> 179 + </ButtonText> 180 + </Button> 181 + ) : isMuting ? ( 182 + <Button 183 + testID="unmuteBtn" 184 + color="secondary" 185 + label={_(msg`Unmute`)} 186 + onPress={onUnsubscribeMute} 187 + size="small" 188 + style={[a.rounded_full]} 189 + disabled={isMutePending}> 190 + {isMutePending && <ButtonIcon icon={Loader} />} 191 + <ButtonText> 192 + <Trans>Unmute</Trans> 193 + </ButtonText> 194 + </Button> 195 + ) : ( 196 + <SubscribeMenu list={list} /> 197 + ) 198 + ) : null} 199 + <MoreOptionsMenu list={list} /> 200 + </ProfileSubpageHeader> 201 + {descriptionRT ? ( 202 + <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.gap_md]}> 203 + <RichText value={descriptionRT} style={[a.text_md, a.leading_snug]} /> 204 + </View> 205 + ) : null} 206 + </> 207 + ) 208 + }
+298
src/screens/ProfileList/components/MoreOptionsMenu.tsx
··· 1 + import {type AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + import {useNavigation} from '@react-navigation/native' 5 + 6 + import {type NavigationProp} from '#/lib/routes/types' 7 + import {shareUrl} from '#/lib/sharing' 8 + import {toShareUrl} from '#/lib/strings/url-helpers' 9 + import {logger} from '#/logger' 10 + import {isWeb} from '#/platform/detection' 11 + import {useModalControls} from '#/state/modals' 12 + import { 13 + useListBlockMutation, 14 + useListDeleteMutation, 15 + useListMuteMutation, 16 + } from '#/state/queries/list' 17 + import {useRemoveFeedMutation} from '#/state/queries/preferences' 18 + import {useSession} from '#/state/session' 19 + import {Button, ButtonIcon} from '#/components/Button' 20 + import {useDialogControl} from '#/components/Dialog' 21 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' 22 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' 23 + import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' 24 + import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' 25 + import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' 26 + import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 27 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 28 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 29 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 30 + import * as Menu from '#/components/Menu' 31 + import { 32 + ReportDialog, 33 + useReportDialogControl, 34 + } from '#/components/moderation/ReportDialog' 35 + import * as Prompt from '#/components/Prompt' 36 + import * as Toast from '#/components/Toast' 37 + 38 + export function MoreOptionsMenu({ 39 + list, 40 + savedFeedConfig, 41 + }: { 42 + list: AppBskyGraphDefs.ListView 43 + savedFeedConfig?: AppBskyActorDefs.SavedFeed 44 + }) { 45 + const {_} = useLingui() 46 + const {currentAccount} = useSession() 47 + const {openModal} = useModalControls() 48 + const deleteListPromptControl = useDialogControl() 49 + const reportDialogControl = useReportDialogControl() 50 + const navigation = useNavigation<NavigationProp>() 51 + 52 + const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() 53 + const {mutateAsync: deleteList} = useListDeleteMutation() 54 + const {mutateAsync: muteList} = useListMuteMutation() 55 + const {mutateAsync: blockList} = useListBlockMutation() 56 + 57 + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 58 + const isModList = list.purpose === AppBskyGraphDefs.MODLIST 59 + const isBlocking = !!list.viewer?.blocked 60 + const isMuting = !!list.viewer?.muted 61 + const isPinned = Boolean(savedFeedConfig?.pinned) 62 + const isOwner = currentAccount?.did === list.creator.did 63 + 64 + const onPressShare = () => { 65 + const {rkey} = new AtUri(list.uri) 66 + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) 67 + shareUrl(url) 68 + } 69 + 70 + const onRemoveFromSavedFeeds = async () => { 71 + if (!savedFeedConfig) return 72 + try { 73 + await removeSavedFeed(savedFeedConfig) 74 + Toast.show(_(msg`Removed from your feeds`)) 75 + } catch (e) { 76 + Toast.show(_(msg`There was an issue contacting the server`), { 77 + type: 'error', 78 + }) 79 + logger.error('Failed to remove pinned list', {message: e}) 80 + } 81 + } 82 + 83 + const onPressEdit = () => { 84 + openModal({ 85 + name: 'create-or-edit-list', 86 + list, 87 + }) 88 + } 89 + 90 + const onPressDelete = async () => { 91 + await deleteList({uri: list.uri}) 92 + 93 + if (savedFeedConfig) { 94 + await removeSavedFeed(savedFeedConfig) 95 + } 96 + 97 + Toast.show(_(msg({message: 'List deleted', context: 'toast'}))) 98 + if (navigation.canGoBack()) { 99 + navigation.goBack() 100 + } else { 101 + navigation.navigate('Home') 102 + } 103 + } 104 + 105 + const onUnpinModList = async () => { 106 + try { 107 + if (!savedFeedConfig) return 108 + await removeSavedFeed(savedFeedConfig) 109 + Toast.show(_(msg`Unpinned list`)) 110 + } catch { 111 + Toast.show(_(msg`Failed to unpin list`), { 112 + type: 'error', 113 + }) 114 + } 115 + } 116 + 117 + const onUnsubscribeMute = async () => { 118 + try { 119 + await muteList({uri: list.uri, mute: false}) 120 + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 121 + logger.metric( 122 + 'moderation:unsubscribedFromList', 123 + {listType: 'mute'}, 124 + {statsig: true}, 125 + ) 126 + } catch { 127 + Toast.show( 128 + _( 129 + msg`There was an issue. Please check your internet connection and try again.`, 130 + ), 131 + ) 132 + } 133 + } 134 + 135 + const onUnsubscribeBlock = async () => { 136 + try { 137 + await blockList({uri: list.uri, block: false}) 138 + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 139 + logger.metric( 140 + 'moderation:unsubscribedFromList', 141 + {listType: 'block'}, 142 + {statsig: true}, 143 + ) 144 + } catch { 145 + Toast.show( 146 + _( 147 + msg`There was an issue. Please check your internet connection and try again.`, 148 + ), 149 + ) 150 + } 151 + } 152 + 153 + return ( 154 + <> 155 + <Menu.Root> 156 + <Menu.Trigger label={_(msg`More options`)}> 157 + {({props}) => ( 158 + <Button 159 + label={props.accessibilityLabel} 160 + testID="moreOptionsBtn" 161 + size="small" 162 + color="secondary" 163 + shape="round" 164 + {...props}> 165 + <ButtonIcon icon={DotGridIcon} /> 166 + </Button> 167 + )} 168 + </Menu.Trigger> 169 + <Menu.Outer> 170 + <Menu.Group> 171 + <Menu.Item 172 + label={isWeb ? _(msg`Copy link to list`) : _(msg`Share via...`)} 173 + onPress={onPressShare}> 174 + <Menu.ItemText> 175 + {isWeb ? ( 176 + <Trans>Copy link to list</Trans> 177 + ) : ( 178 + <Trans>Share via...</Trans> 179 + )} 180 + </Menu.ItemText> 181 + <Menu.ItemIcon 182 + position="right" 183 + icon={isWeb ? ChainLink : ShareIcon} 184 + /> 185 + </Menu.Item> 186 + {savedFeedConfig && ( 187 + <Menu.Item 188 + label={_(msg`Remove from my feeds`)} 189 + onPress={onRemoveFromSavedFeeds}> 190 + <Menu.ItemText> 191 + <Trans>Remove from my feeds</Trans> 192 + </Menu.ItemText> 193 + <Menu.ItemIcon position="right" icon={TrashIcon} /> 194 + </Menu.Item> 195 + )} 196 + </Menu.Group> 197 + 198 + <Menu.Divider /> 199 + 200 + {isOwner ? ( 201 + <Menu.Group> 202 + <Menu.Item 203 + label={_(msg`Edit list details`)} 204 + onPress={onPressEdit}> 205 + <Menu.ItemText> 206 + <Trans>Edit list details</Trans> 207 + </Menu.ItemText> 208 + <Menu.ItemIcon position="right" icon={PencilLineIcon} /> 209 + </Menu.Item> 210 + <Menu.Item 211 + label={_(msg`Delete list`)} 212 + onPress={deleteListPromptControl.open}> 213 + <Menu.ItemText> 214 + <Trans>Delete list</Trans> 215 + </Menu.ItemText> 216 + <Menu.ItemIcon position="right" icon={TrashIcon} /> 217 + </Menu.Item> 218 + </Menu.Group> 219 + ) : ( 220 + <Menu.Group> 221 + <Menu.Item 222 + label={_(msg`Report list`)} 223 + onPress={reportDialogControl.open}> 224 + <Menu.ItemText> 225 + <Trans>Report list</Trans> 226 + </Menu.ItemText> 227 + <Menu.ItemIcon position="right" icon={WarningIcon} /> 228 + </Menu.Item> 229 + </Menu.Group> 230 + )} 231 + 232 + {isModList && isPinned && ( 233 + <> 234 + <Menu.Divider /> 235 + <Menu.Group> 236 + <Menu.Item 237 + label={_(msg`Unpin moderation list`)} 238 + onPress={onUnpinModList}> 239 + <Menu.ItemText> 240 + <Trans>Unpin moderation list</Trans> 241 + </Menu.ItemText> 242 + <Menu.ItemIcon icon={PinIcon} /> 243 + </Menu.Item> 244 + </Menu.Group> 245 + </> 246 + )} 247 + 248 + {isCurateList && (isBlocking || isMuting) && ( 249 + <> 250 + <Menu.Divider /> 251 + <Menu.Group> 252 + {isBlocking && ( 253 + <Menu.Item 254 + label={_(msg`Unblock list`)} 255 + onPress={onUnsubscribeBlock}> 256 + <Menu.ItemText> 257 + <Trans>Unblock list</Trans> 258 + </Menu.ItemText> 259 + <Menu.ItemIcon icon={PersonCheckIcon} /> 260 + </Menu.Item> 261 + )} 262 + {isMuting && ( 263 + <Menu.Item 264 + label={_(msg`Unmute list`)} 265 + onPress={onUnsubscribeMute}> 266 + <Menu.ItemText> 267 + <Trans>Unmute list</Trans> 268 + </Menu.ItemText> 269 + <Menu.ItemIcon icon={UnmuteIcon} /> 270 + </Menu.Item> 271 + )} 272 + </Menu.Group> 273 + </> 274 + )} 275 + </Menu.Outer> 276 + </Menu.Root> 277 + 278 + <Prompt.Basic 279 + control={deleteListPromptControl} 280 + title={_(msg`Delete this list?`)} 281 + description={_( 282 + msg`If you delete this list, you won't be able to recover it.`, 283 + )} 284 + onConfirm={onPressDelete} 285 + confirmButtonCta={_(msg`Delete`)} 286 + confirmButtonColor="negative" 287 + /> 288 + 289 + <ReportDialog 290 + control={reportDialogControl} 291 + subject={{ 292 + ...list, 293 + $type: 'app.bsky.graph.defs#listView', 294 + }} 295 + /> 296 + </> 297 + ) 298 + }
+130
src/screens/ProfileList/components/SubscribeMenu.tsx
··· 1 + import {type AppBskyGraphDefs} from '@atproto/api' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {logger} from '#/logger' 6 + import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' 7 + import {atoms as a} from '#/alf' 8 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 + import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 10 + import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' 11 + import {Loader} from '#/components/Loader' 12 + import * as Menu from '#/components/Menu' 13 + import * as Prompt from '#/components/Prompt' 14 + import * as Toast from '#/components/Toast' 15 + 16 + export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { 17 + const {_} = useLingui() 18 + const subscribeMutePromptControl = Prompt.usePromptControl() 19 + const subscribeBlockPromptControl = Prompt.usePromptControl() 20 + 21 + const {mutateAsync: muteList, isPending: isMutePending} = 22 + useListMuteMutation() 23 + const {mutateAsync: blockList, isPending: isBlockPending} = 24 + useListBlockMutation() 25 + 26 + const isPending = isMutePending || isBlockPending 27 + 28 + const onSubscribeMute = async () => { 29 + try { 30 + await muteList({uri: list.uri, mute: true}) 31 + Toast.show(_(msg({message: 'List muted', context: 'toast'}))) 32 + logger.metric( 33 + 'moderation:subscribedToList', 34 + {listType: 'mute'}, 35 + {statsig: true}, 36 + ) 37 + } catch { 38 + Toast.show( 39 + _( 40 + msg`There was an issue. Please check your internet connection and try again.`, 41 + ), 42 + {type: 'error'}, 43 + ) 44 + } 45 + } 46 + 47 + const onSubscribeBlock = async () => { 48 + try { 49 + await blockList({uri: list.uri, block: true}) 50 + Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) 51 + logger.metric( 52 + 'moderation:subscribedToList', 53 + {listType: 'block'}, 54 + {statsig: true}, 55 + ) 56 + } catch { 57 + Toast.show( 58 + _( 59 + msg`There was an issue. Please check your internet connection and try again.`, 60 + ), 61 + {type: 'error'}, 62 + ) 63 + } 64 + } 65 + 66 + return ( 67 + <> 68 + <Menu.Root> 69 + <Menu.Trigger label={_(msg`Subscribe to this list`)}> 70 + {({props}) => ( 71 + <Button 72 + label={props.accessibilityLabel} 73 + testID="subscribeBtn" 74 + size="small" 75 + color="primary_subtle" 76 + style={[a.rounded_full]} 77 + disabled={isPending} 78 + {...props}> 79 + {isPending && <ButtonIcon icon={Loader} />} 80 + <ButtonText> 81 + <Trans>Subscribe</Trans> 82 + </ButtonText> 83 + </Button> 84 + )} 85 + </Menu.Trigger> 86 + <Menu.Outer showCancel> 87 + <Menu.Group> 88 + <Menu.Item 89 + label={_(msg`Mute accounts`)} 90 + onPress={subscribeMutePromptControl.open}> 91 + <Menu.ItemText> 92 + <Trans>Mute accounts</Trans> 93 + </Menu.ItemText> 94 + <Menu.ItemIcon position="right" icon={MuteIcon} /> 95 + </Menu.Item> 96 + <Menu.Item 97 + label={_(msg`Block accounts`)} 98 + onPress={subscribeBlockPromptControl.open}> 99 + <Menu.ItemText> 100 + <Trans>Block accounts</Trans> 101 + </Menu.ItemText> 102 + <Menu.ItemIcon position="right" icon={PersonXIcon} /> 103 + </Menu.Item> 104 + </Menu.Group> 105 + </Menu.Outer> 106 + </Menu.Root> 107 + 108 + <Prompt.Basic 109 + control={subscribeMutePromptControl} 110 + title={_(msg`Mute these accounts?`)} 111 + description={_( 112 + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, 113 + )} 114 + onConfirm={onSubscribeMute} 115 + confirmButtonCta={_(msg`Mute list`)} 116 + /> 117 + 118 + <Prompt.Basic 119 + control={subscribeBlockPromptControl} 120 + title={_(msg`Block these accounts?`)} 121 + description={_( 122 + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 123 + )} 124 + onConfirm={onSubscribeBlock} 125 + confirmButtonCta={_(msg`Block list`)} 126 + confirmButtonColor="negative" 127 + /> 128 + </> 129 + ) 130 + }
+296
src/screens/ProfileList/index.tsx
··· 1 + import {useCallback, useMemo, useRef} from 'react' 2 + import {View} from 'react-native' 3 + import {useAnimatedRef} from 'react-native-reanimated' 4 + import { 5 + AppBskyGraphDefs, 6 + AtUri, 7 + moderateUserList, 8 + type ModerationOpts, 9 + } from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + import {useFocusEffect, useIsFocused} from '@react-navigation/native' 13 + import {useQueryClient} from '@tanstack/react-query' 14 + 15 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 + import {useSetTitle} from '#/lib/hooks/useSetTitle' 17 + import {ComposeIcon2} from '#/lib/icons' 18 + import { 19 + type CommonNavigatorParams, 20 + type NativeStackScreenProps, 21 + } from '#/lib/routes/types' 22 + import {cleanError} from '#/lib/strings/errors' 23 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 + import {useListQuery} from '#/state/queries/list' 25 + import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 26 + import { 27 + usePreferencesQuery, 28 + type UsePreferencesQueryResponse, 29 + } from '#/state/queries/preferences' 30 + import {useResolveUriQuery} from '#/state/queries/resolve-uri' 31 + import {truncateAndInvalidate} from '#/state/queries/util' 32 + import {useSession} from '#/state/session' 33 + import {useSetMinimalShellMode} from '#/state/shell' 34 + import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 35 + import {FAB} from '#/view/com/util/fab/FAB' 36 + import {type ListRef} from '#/view/com/util/List' 37 + import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' 38 + import {atoms as a, platform} from '#/alf' 39 + import {useDialogControl} from '#/components/Dialog' 40 + import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' 41 + import * as Layout from '#/components/Layout' 42 + import {Loader} from '#/components/Loader' 43 + import * as Hider from '#/components/moderation/Hider' 44 + import {AboutSection} from './AboutSection' 45 + import {ErrorScreen} from './components/ErrorScreen' 46 + import {Header} from './components/Header' 47 + import {FeedSection} from './FeedSection' 48 + 49 + interface SectionRef { 50 + scrollToTop: () => void 51 + } 52 + 53 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> 54 + export function ProfileListScreen(props: Props) { 55 + return ( 56 + <Layout.Screen testID="profileListScreen"> 57 + <ProfileListScreenInner {...props} /> 58 + </Layout.Screen> 59 + ) 60 + } 61 + 62 + function ProfileListScreenInner(props: Props) { 63 + const {_} = useLingui() 64 + const {name: handleOrDid, rkey} = props.route.params 65 + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( 66 + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), 67 + ) 68 + const {data: preferences} = usePreferencesQuery() 69 + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) 70 + const moderationOpts = useModerationOpts() 71 + 72 + if (resolveError) { 73 + return ( 74 + <> 75 + <Layout.Header.Outer> 76 + <Layout.Header.BackButton /> 77 + <Layout.Header.Content> 78 + <Layout.Header.TitleText> 79 + <Trans>Could not load list</Trans> 80 + </Layout.Header.TitleText> 81 + </Layout.Header.Content> 82 + <Layout.Header.Slot /> 83 + </Layout.Header.Outer> 84 + <Layout.Content centerContent> 85 + <ErrorScreen 86 + error={_( 87 + msg`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, 88 + )} 89 + /> 90 + </Layout.Content> 91 + </> 92 + ) 93 + } 94 + if (listError) { 95 + return ( 96 + <> 97 + <Layout.Header.Outer> 98 + <Layout.Header.BackButton /> 99 + <Layout.Header.Content> 100 + <Layout.Header.TitleText> 101 + <Trans>Could not load list</Trans> 102 + </Layout.Header.TitleText> 103 + </Layout.Header.Content> 104 + <Layout.Header.Slot /> 105 + </Layout.Header.Outer> 106 + <Layout.Content centerContent> 107 + <ErrorScreen error={cleanError(listError)} /> 108 + </Layout.Content> 109 + </> 110 + ) 111 + } 112 + 113 + return resolvedUri && list && moderationOpts && preferences ? ( 114 + <ProfileListScreenLoaded 115 + {...props} 116 + uri={resolvedUri.uri} 117 + list={list} 118 + moderationOpts={moderationOpts} 119 + preferences={preferences} 120 + /> 121 + ) : ( 122 + <> 123 + <Layout.Header.Outer> 124 + <Layout.Header.BackButton /> 125 + <Layout.Header.Content /> 126 + <Layout.Header.Slot /> 127 + </Layout.Header.Outer> 128 + <Layout.Content 129 + centerContent 130 + contentContainerStyle={platform({ 131 + web: [a.mx_auto], 132 + native: [a.align_center], 133 + })}> 134 + <Loader size="2xl" /> 135 + </Layout.Content> 136 + </> 137 + ) 138 + } 139 + 140 + function ProfileListScreenLoaded({ 141 + route, 142 + uri, 143 + list, 144 + moderationOpts, 145 + preferences, 146 + }: Props & { 147 + uri: string 148 + list: AppBskyGraphDefs.ListView 149 + moderationOpts: ModerationOpts 150 + preferences: UsePreferencesQueryResponse 151 + }) { 152 + const {_} = useLingui() 153 + const queryClient = useQueryClient() 154 + const {openComposer} = useOpenComposer() 155 + const setMinimalShellMode = useSetMinimalShellMode() 156 + const {currentAccount} = useSession() 157 + const {rkey} = route.params 158 + const feedSectionRef = useRef<SectionRef>(null) 159 + const aboutSectionRef = useRef<SectionRef>(null) 160 + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 161 + const isScreenFocused = useIsFocused() 162 + const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 163 + const isOwner = currentAccount?.did === list.creator.did 164 + const scrollElRef = useAnimatedRef() 165 + const addUserDialogControl = useDialogControl() 166 + const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] 167 + 168 + const moderation = useMemo(() => { 169 + return moderateUserList(list, moderationOpts) 170 + }, [list, moderationOpts]) 171 + 172 + useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) 173 + 174 + useFocusEffect( 175 + useCallback(() => { 176 + setMinimalShellMode(false) 177 + }, [setMinimalShellMode]), 178 + ) 179 + 180 + const onChangeMembers = () => { 181 + if (isCurateList) { 182 + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) 183 + } 184 + } 185 + 186 + const onCurrentPageSelected = useCallback( 187 + (index: number) => { 188 + if (index === 0) { 189 + feedSectionRef.current?.scrollToTop() 190 + } else if (index === 1) { 191 + aboutSectionRef.current?.scrollToTop() 192 + } 193 + }, 194 + [feedSectionRef], 195 + ) 196 + 197 + const renderHeader = useCallback(() => { 198 + return <Header rkey={rkey} list={list} preferences={preferences} /> 199 + }, [rkey, list, preferences]) 200 + 201 + if (isCurateList) { 202 + return ( 203 + <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> 204 + <Hider.Mask> 205 + <ListHiddenScreen list={list} preferences={preferences} /> 206 + </Hider.Mask> 207 + <Hider.Content> 208 + <View style={[a.util_screen_outer]}> 209 + <PagerWithHeader 210 + items={sectionTitlesCurate} 211 + isHeaderReady={true} 212 + renderHeader={renderHeader} 213 + onCurrentPageSelected={onCurrentPageSelected}> 214 + {({headerHeight, scrollElRef, isFocused}) => ( 215 + <FeedSection 216 + ref={feedSectionRef} 217 + feed={`list|${uri}`} 218 + scrollElRef={scrollElRef as ListRef} 219 + headerHeight={headerHeight} 220 + isFocused={isScreenFocused && isFocused} 221 + isOwner={isOwner} 222 + onPressAddUser={addUserDialogControl.open} 223 + /> 224 + )} 225 + {({headerHeight, scrollElRef}) => ( 226 + <AboutSection 227 + ref={aboutSectionRef} 228 + scrollElRef={scrollElRef as ListRef} 229 + list={list} 230 + onPressAddUser={addUserDialogControl.open} 231 + headerHeight={headerHeight} 232 + /> 233 + )} 234 + </PagerWithHeader> 235 + <FAB 236 + testID="composeFAB" 237 + onPress={() => openComposer({})} 238 + icon={ 239 + <ComposeIcon2 240 + strokeWidth={1.5} 241 + size={29} 242 + style={{color: 'white'}} 243 + /> 244 + } 245 + accessibilityRole="button" 246 + accessibilityLabel={_(msg`New post`)} 247 + accessibilityHint="" 248 + /> 249 + </View> 250 + <ListAddRemoveUsersDialog 251 + control={addUserDialogControl} 252 + list={list} 253 + onChange={onChangeMembers} 254 + /> 255 + </Hider.Content> 256 + </Hider.Outer> 257 + ) 258 + } 259 + return ( 260 + <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> 261 + <Hider.Mask> 262 + <ListHiddenScreen list={list} preferences={preferences} /> 263 + </Hider.Mask> 264 + <Hider.Content> 265 + <View style={[a.util_screen_outer]}> 266 + <Layout.Center>{renderHeader()}</Layout.Center> 267 + <AboutSection 268 + list={list} 269 + scrollElRef={scrollElRef as ListRef} 270 + onPressAddUser={addUserDialogControl.open} 271 + headerHeight={0} 272 + /> 273 + <FAB 274 + testID="composeFAB" 275 + onPress={() => openComposer({})} 276 + icon={ 277 + <ComposeIcon2 278 + strokeWidth={1.5} 279 + size={29} 280 + style={{color: 'white'}} 281 + /> 282 + } 283 + accessibilityRole="button" 284 + accessibilityLabel={_(msg`New post`)} 285 + accessibilityHint="" 286 + /> 287 + </View> 288 + <ListAddRemoveUsersDialog 289 + control={addUserDialogControl} 290 + list={list} 291 + onChange={onChangeMembers} 292 + /> 293 + </Hider.Content> 294 + </Hider.Outer> 295 + ) 296 + }
-17
src/view/com/util/LoadingScreen.tsx
··· 1 - import {ActivityIndicator, View} from 'react-native' 2 - 3 - import {s} from '#/lib/styles' 4 - import * as Layout from '#/components/Layout' 5 - 6 - /** 7 - * @deprecated use Layout compoenents directly 8 - */ 9 - export function LoadingScreen() { 10 - return ( 11 - <Layout.Content> 12 - <View style={s.p20}> 13 - <ActivityIndicator size="large" /> 14 - </View> 15 - </Layout.Content> 16 - ) 17 - }
-1061
src/view/screens/ProfileList.tsx
··· 1 - import React, {useCallback, useMemo} from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {useAnimatedRef} from 'react-native-reanimated' 4 - import { 5 - AppBskyGraphDefs, 6 - AtUri, 7 - moderateUserList, 8 - type ModerationOpts, 9 - RichText as RichTextAPI, 10 - } from '@atproto/api' 11 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 - import {msg, Trans} from '@lingui/macro' 13 - import {useLingui} from '@lingui/react' 14 - import {useFocusEffect, useIsFocused} from '@react-navigation/native' 15 - import {useNavigation} from '@react-navigation/native' 16 - import {useQueryClient} from '@tanstack/react-query' 17 - 18 - import {useHaptics} from '#/lib/haptics' 19 - import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 20 - import {usePalette} from '#/lib/hooks/usePalette' 21 - import {useSetTitle} from '#/lib/hooks/useSetTitle' 22 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 23 - import {ComposeIcon2} from '#/lib/icons' 24 - import {makeListLink} from '#/lib/routes/links' 25 - import { 26 - type CommonNavigatorParams, 27 - type NativeStackScreenProps, 28 - } from '#/lib/routes/types' 29 - import {type NavigationProp} from '#/lib/routes/types' 30 - import {shareUrl} from '#/lib/sharing' 31 - import {cleanError} from '#/lib/strings/errors' 32 - import {toShareUrl} from '#/lib/strings/url-helpers' 33 - import {s} from '#/lib/styles' 34 - import {logger} from '#/logger' 35 - import {isNative, isWeb} from '#/platform/detection' 36 - import {listenSoftReset} from '#/state/events' 37 - import {useModalControls} from '#/state/modals' 38 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 39 - import { 40 - useListBlockMutation, 41 - useListDeleteMutation, 42 - useListMuteMutation, 43 - useListQuery, 44 - } from '#/state/queries/list' 45 - import {type FeedDescriptor} from '#/state/queries/post-feed' 46 - import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 47 - import { 48 - useAddSavedFeedsMutation, 49 - usePreferencesQuery, 50 - type UsePreferencesQueryResponse, 51 - useRemoveFeedMutation, 52 - useUpdateSavedFeedsMutation, 53 - } from '#/state/queries/preferences' 54 - import {useResolveUriQuery} from '#/state/queries/resolve-uri' 55 - import {truncateAndInvalidate} from '#/state/queries/util' 56 - import {useSession} from '#/state/session' 57 - import {useSetMinimalShellMode} from '#/state/shell' 58 - import {ListMembers} from '#/view/com/lists/ListMembers' 59 - import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 60 - import {PostFeed} from '#/view/com/posts/PostFeed' 61 - import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' 62 - import {EmptyState} from '#/view/com/util/EmptyState' 63 - import {FAB} from '#/view/com/util/fab/FAB' 64 - import {Button} from '#/view/com/util/forms/Button' 65 - import { 66 - type DropdownItem, 67 - NativeDropdown, 68 - } from '#/view/com/util/forms/NativeDropdown' 69 - import {type ListRef} from '#/view/com/util/List' 70 - import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 71 - import {LoadingScreen} from '#/view/com/util/LoadingScreen' 72 - import {Text} from '#/view/com/util/text/Text' 73 - import * as Toast from '#/view/com/util/Toast' 74 - import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' 75 - import {atoms as a} from '#/alf' 76 - import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button' 77 - import {useDialogControl} from '#/components/Dialog' 78 - import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog' 79 - import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 80 - import * as Layout from '#/components/Layout' 81 - import * as Hider from '#/components/moderation/Hider' 82 - import { 83 - ReportDialog, 84 - useReportDialogControl, 85 - } from '#/components/moderation/ReportDialog' 86 - import * as Prompt from '#/components/Prompt' 87 - import {RichText} from '#/components/RichText' 88 - 89 - interface SectionRef { 90 - scrollToTop: () => void 91 - } 92 - 93 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> 94 - export function ProfileListScreen(props: Props) { 95 - return ( 96 - <Layout.Screen testID="profileListScreen"> 97 - <ProfileListScreenInner {...props} /> 98 - </Layout.Screen> 99 - ) 100 - } 101 - 102 - function ProfileListScreenInner(props: Props) { 103 - const {_} = useLingui() 104 - const {name: handleOrDid, rkey} = props.route.params 105 - const {data: resolvedUri, error: resolveError} = useResolveUriQuery( 106 - AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), 107 - ) 108 - const {data: preferences} = usePreferencesQuery() 109 - const {data: list, error: listError} = useListQuery(resolvedUri?.uri) 110 - const moderationOpts = useModerationOpts() 111 - 112 - if (resolveError) { 113 - return ( 114 - <Layout.Content> 115 - <ErrorScreen 116 - error={_( 117 - msg`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, 118 - )} 119 - /> 120 - </Layout.Content> 121 - ) 122 - } 123 - if (listError) { 124 - return ( 125 - <Layout.Content> 126 - <ErrorScreen error={cleanError(listError)} /> 127 - </Layout.Content> 128 - ) 129 - } 130 - 131 - return resolvedUri && list && moderationOpts && preferences ? ( 132 - <ProfileListScreenLoaded 133 - {...props} 134 - uri={resolvedUri.uri} 135 - list={list} 136 - moderationOpts={moderationOpts} 137 - preferences={preferences} 138 - /> 139 - ) : ( 140 - <LoadingScreen /> 141 - ) 142 - } 143 - 144 - function ProfileListScreenLoaded({ 145 - route, 146 - uri, 147 - list, 148 - moderationOpts, 149 - preferences, 150 - }: Props & { 151 - uri: string 152 - list: AppBskyGraphDefs.ListView 153 - moderationOpts: ModerationOpts 154 - preferences: UsePreferencesQueryResponse 155 - }) { 156 - const {_} = useLingui() 157 - const queryClient = useQueryClient() 158 - const {openComposer} = useOpenComposer() 159 - const setMinimalShellMode = useSetMinimalShellMode() 160 - const {currentAccount} = useSession() 161 - const {rkey} = route.params 162 - const feedSectionRef = React.useRef<SectionRef>(null) 163 - const aboutSectionRef = React.useRef<SectionRef>(null) 164 - const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 165 - const isScreenFocused = useIsFocused() 166 - const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 167 - const isOwner = currentAccount?.did === list.creator.did 168 - const scrollElRef = useAnimatedRef() 169 - const addUserDialogControl = useDialogControl() 170 - const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)] 171 - 172 - const moderation = React.useMemo(() => { 173 - return moderateUserList(list, moderationOpts) 174 - }, [list, moderationOpts]) 175 - 176 - useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) 177 - 178 - useFocusEffect( 179 - useCallback(() => { 180 - setMinimalShellMode(false) 181 - }, [setMinimalShellMode]), 182 - ) 183 - 184 - const onChangeMembers = useCallback(() => { 185 - if (isCurateList) { 186 - truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) 187 - } 188 - }, [list.uri, isCurateList, queryClient]) 189 - 190 - const onCurrentPageSelected = React.useCallback( 191 - (index: number) => { 192 - if (index === 0) { 193 - feedSectionRef.current?.scrollToTop() 194 - } else if (index === 1) { 195 - aboutSectionRef.current?.scrollToTop() 196 - } 197 - }, 198 - [feedSectionRef], 199 - ) 200 - 201 - const renderHeader = useCallback(() => { 202 - return <Header rkey={rkey} list={list} preferences={preferences} /> 203 - }, [rkey, list, preferences]) 204 - 205 - if (isCurateList) { 206 - return ( 207 - <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> 208 - <Hider.Mask> 209 - <ListHiddenScreen list={list} preferences={preferences} /> 210 - </Hider.Mask> 211 - <Hider.Content> 212 - <View style={s.hContentRegion}> 213 - <PagerWithHeader 214 - items={sectionTitlesCurate} 215 - isHeaderReady={true} 216 - renderHeader={renderHeader} 217 - onCurrentPageSelected={onCurrentPageSelected}> 218 - {({headerHeight, scrollElRef, isFocused}) => ( 219 - <FeedSection 220 - ref={feedSectionRef} 221 - feed={`list|${uri}`} 222 - scrollElRef={scrollElRef as ListRef} 223 - headerHeight={headerHeight} 224 - isFocused={isScreenFocused && isFocused} 225 - isOwner={isOwner} 226 - onPressAddUser={addUserDialogControl.open} 227 - /> 228 - )} 229 - {({headerHeight, scrollElRef}) => ( 230 - <AboutSection 231 - ref={aboutSectionRef} 232 - scrollElRef={scrollElRef as ListRef} 233 - list={list} 234 - onPressAddUser={addUserDialogControl.open} 235 - headerHeight={headerHeight} 236 - /> 237 - )} 238 - </PagerWithHeader> 239 - <FAB 240 - testID="composeFAB" 241 - onPress={() => openComposer({})} 242 - icon={ 243 - <ComposeIcon2 244 - strokeWidth={1.5} 245 - size={29} 246 - style={{color: 'white'}} 247 - /> 248 - } 249 - accessibilityRole="button" 250 - accessibilityLabel={_(msg`New post`)} 251 - accessibilityHint="" 252 - /> 253 - </View> 254 - <ListAddRemoveUsersDialog 255 - control={addUserDialogControl} 256 - list={list} 257 - onChange={onChangeMembers} 258 - /> 259 - </Hider.Content> 260 - </Hider.Outer> 261 - ) 262 - } 263 - return ( 264 - <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> 265 - <Hider.Mask> 266 - <ListHiddenScreen list={list} preferences={preferences} /> 267 - </Hider.Mask> 268 - <Hider.Content> 269 - <View style={s.hContentRegion}> 270 - <Layout.Center>{renderHeader()}</Layout.Center> 271 - <AboutSection 272 - list={list} 273 - scrollElRef={scrollElRef as ListRef} 274 - onPressAddUser={addUserDialogControl.open} 275 - headerHeight={0} 276 - /> 277 - <FAB 278 - testID="composeFAB" 279 - onPress={() => openComposer({})} 280 - icon={ 281 - <ComposeIcon2 282 - strokeWidth={1.5} 283 - size={29} 284 - style={{color: 'white'}} 285 - /> 286 - } 287 - accessibilityRole="button" 288 - accessibilityLabel={_(msg`New post`)} 289 - accessibilityHint="" 290 - /> 291 - </View> 292 - <ListAddRemoveUsersDialog 293 - control={addUserDialogControl} 294 - list={list} 295 - onChange={onChangeMembers} 296 - /> 297 - </Hider.Content> 298 - </Hider.Outer> 299 - ) 300 - } 301 - 302 - function Header({ 303 - rkey, 304 - list, 305 - preferences, 306 - }: { 307 - rkey: string 308 - list: AppBskyGraphDefs.ListView 309 - preferences: UsePreferencesQueryResponse 310 - }) { 311 - const pal = usePalette('default') 312 - const palInverted = usePalette('inverted') 313 - const {_} = useLingui() 314 - const navigation = useNavigation<NavigationProp>() 315 - const {currentAccount} = useSession() 316 - const reportDialogControl = useReportDialogControl() 317 - const {openModal} = useModalControls() 318 - const listMuteMutation = useListMuteMutation() 319 - const listBlockMutation = useListBlockMutation() 320 - const listDeleteMutation = useListDeleteMutation() 321 - const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' 322 - const isModList = list.purpose === 'app.bsky.graph.defs#modlist' 323 - const isBlocking = !!list.viewer?.blocked 324 - const isMuting = !!list.viewer?.muted 325 - const isOwner = list.creator.did === currentAccount?.did 326 - const playHaptic = useHaptics() 327 - 328 - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = 329 - useAddSavedFeedsMutation() 330 - const {mutateAsync: removeSavedFeed, isPending: isRemovePending} = 331 - useRemoveFeedMutation() 332 - const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = 333 - useUpdateSavedFeedsMutation() 334 - 335 - const isPending = 336 - isAddSavedFeedPending || isRemovePending || isUpdatingSavedFeeds 337 - 338 - const deleteListPromptControl = useDialogControl() 339 - const subscribeMutePromptControl = useDialogControl() 340 - const subscribeBlockPromptControl = useDialogControl() 341 - 342 - const savedFeedConfig = preferences?.savedFeeds?.find( 343 - f => f.value === list.uri, 344 - ) 345 - const isPinned = Boolean(savedFeedConfig?.pinned) 346 - 347 - const onTogglePinned = React.useCallback(async () => { 348 - playHaptic() 349 - 350 - try { 351 - if (savedFeedConfig) { 352 - const pinned = !savedFeedConfig.pinned 353 - await updateSavedFeeds([ 354 - { 355 - ...savedFeedConfig, 356 - pinned, 357 - }, 358 - ]) 359 - Toast.show( 360 - pinned 361 - ? _(msg`Pinned to your feeds`) 362 - : _(msg`Unpinned from your feeds`), 363 - ) 364 - } else { 365 - await addSavedFeeds([ 366 - { 367 - type: 'list', 368 - value: list.uri, 369 - pinned: true, 370 - }, 371 - ]) 372 - Toast.show(_(msg`Saved to your feeds`)) 373 - } 374 - } catch (e) { 375 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 376 - logger.error('Failed to toggle pinned feed', {message: e}) 377 - } 378 - }, [ 379 - playHaptic, 380 - addSavedFeeds, 381 - updateSavedFeeds, 382 - list.uri, 383 - _, 384 - savedFeedConfig, 385 - ]) 386 - 387 - const onRemoveFromSavedFeeds = React.useCallback(async () => { 388 - playHaptic() 389 - if (!savedFeedConfig) return 390 - try { 391 - await removeSavedFeed(savedFeedConfig) 392 - Toast.show(_(msg`Removed from your feeds`)) 393 - } catch (e) { 394 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 395 - logger.error('Failed to remove pinned list', {message: e}) 396 - } 397 - }, [playHaptic, removeSavedFeed, _, savedFeedConfig]) 398 - 399 - const onSubscribeMute = useCallback(async () => { 400 - try { 401 - await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) 402 - Toast.show(_(msg({message: 'List muted', context: 'toast'}))) 403 - logger.metric( 404 - 'moderation:subscribedToList', 405 - {listType: 'mute'}, 406 - {statsig: true}, 407 - ) 408 - } catch { 409 - Toast.show( 410 - _( 411 - msg`There was an issue. Please check your internet connection and try again.`, 412 - ), 413 - ) 414 - } 415 - }, [list, listMuteMutation, _]) 416 - 417 - const onUnsubscribeMute = useCallback(async () => { 418 - try { 419 - await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) 420 - Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 421 - logger.metric( 422 - 'moderation:unsubscribedFromList', 423 - {listType: 'mute'}, 424 - {statsig: true}, 425 - ) 426 - } catch { 427 - Toast.show( 428 - _( 429 - msg`There was an issue. Please check your internet connection and try again.`, 430 - ), 431 - ) 432 - } 433 - }, [list, listMuteMutation, _]) 434 - 435 - const onSubscribeBlock = useCallback(async () => { 436 - try { 437 - await listBlockMutation.mutateAsync({uri: list.uri, block: true}) 438 - Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) 439 - logger.metric( 440 - 'moderation:subscribedToList', 441 - {listType: 'block'}, 442 - {statsig: true}, 443 - ) 444 - } catch { 445 - Toast.show( 446 - _( 447 - msg`There was an issue. Please check your internet connection and try again.`, 448 - ), 449 - ) 450 - } 451 - }, [list, listBlockMutation, _]) 452 - 453 - const onUnsubscribeBlock = useCallback(async () => { 454 - try { 455 - await listBlockMutation.mutateAsync({uri: list.uri, block: false}) 456 - Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 457 - logger.metric( 458 - 'moderation:unsubscribedFromList', 459 - {listType: 'block'}, 460 - {statsig: true}, 461 - ) 462 - } catch { 463 - Toast.show( 464 - _( 465 - msg`There was an issue. Please check your internet connection and try again.`, 466 - ), 467 - ) 468 - } 469 - }, [list, listBlockMutation, _]) 470 - 471 - const onPressEdit = useCallback(() => { 472 - openModal({ 473 - name: 'create-or-edit-list', 474 - list, 475 - }) 476 - }, [openModal, list]) 477 - 478 - const onPressDelete = useCallback(async () => { 479 - await listDeleteMutation.mutateAsync({uri: list.uri}) 480 - 481 - if (savedFeedConfig) { 482 - await removeSavedFeed(savedFeedConfig) 483 - } 484 - 485 - Toast.show(_(msg({message: 'List deleted', context: 'toast'}))) 486 - if (navigation.canGoBack()) { 487 - navigation.goBack() 488 - } else { 489 - navigation.navigate('Home') 490 - } 491 - }, [ 492 - list, 493 - listDeleteMutation, 494 - navigation, 495 - _, 496 - removeSavedFeed, 497 - savedFeedConfig, 498 - ]) 499 - 500 - const onPressReport = useCallback(() => { 501 - reportDialogControl.open() 502 - }, [reportDialogControl]) 503 - 504 - const onPressShare = useCallback(() => { 505 - const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) 506 - shareUrl(url) 507 - }, [list, rkey]) 508 - 509 - const dropdownItems: DropdownItem[] = useMemo(() => { 510 - let items: DropdownItem[] = [ 511 - { 512 - testID: 'listHeaderDropdownShareBtn', 513 - label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`), 514 - onPress: onPressShare, 515 - icon: { 516 - ios: { 517 - name: 'square.and.arrow.up', 518 - }, 519 - android: '', 520 - web: 'share', 521 - }, 522 - }, 523 - ] 524 - 525 - if (savedFeedConfig) { 526 - items.push({ 527 - testID: 'listHeaderDropdownRemoveFromFeedsBtn', 528 - label: _(msg`Remove from my feeds`), 529 - onPress: onRemoveFromSavedFeeds, 530 - icon: { 531 - ios: { 532 - name: 'trash', 533 - }, 534 - android: '', 535 - web: ['far', 'trash-can'], 536 - }, 537 - }) 538 - } 539 - 540 - if (isOwner) { 541 - items.push({label: 'separator'}) 542 - items.push({ 543 - testID: 'listHeaderDropdownEditBtn', 544 - label: _(msg`Edit list details`), 545 - onPress: onPressEdit, 546 - icon: { 547 - ios: { 548 - name: 'pencil', 549 - }, 550 - android: '', 551 - web: 'pen', 552 - }, 553 - }) 554 - items.push({ 555 - testID: 'listHeaderDropdownDeleteBtn', 556 - label: _(msg`Delete list`), 557 - onPress: deleteListPromptControl.open, 558 - icon: { 559 - ios: { 560 - name: 'trash', 561 - }, 562 - android: '', 563 - web: ['far', 'trash-can'], 564 - }, 565 - }) 566 - } else { 567 - items.push({label: 'separator'}) 568 - items.push({ 569 - testID: 'listHeaderDropdownReportBtn', 570 - label: _(msg`Report list`), 571 - onPress: onPressReport, 572 - icon: { 573 - ios: { 574 - name: 'exclamationmark.triangle', 575 - }, 576 - android: '', 577 - web: 'circle-exclamation', 578 - }, 579 - }) 580 - } 581 - if (isModList && isPinned) { 582 - items.push({label: 'separator'}) 583 - items.push({ 584 - testID: 'listHeaderDropdownUnpinBtn', 585 - label: _(msg`Unpin moderation list`), 586 - onPress: 587 - isPending || !savedFeedConfig 588 - ? undefined 589 - : () => removeSavedFeed(savedFeedConfig), 590 - icon: { 591 - ios: { 592 - name: 'pin', 593 - }, 594 - android: '', 595 - web: 'thumbtack', 596 - }, 597 - }) 598 - } 599 - if (isCurateList && (isBlocking || isMuting)) { 600 - items.push({label: 'separator'}) 601 - 602 - if (isMuting) { 603 - items.push({ 604 - testID: 'listHeaderDropdownMuteBtn', 605 - label: _(msg`Unmute list`), 606 - onPress: onUnsubscribeMute, 607 - icon: { 608 - ios: { 609 - name: 'eye', 610 - }, 611 - android: '', 612 - web: 'eye', 613 - }, 614 - }) 615 - } 616 - 617 - if (isBlocking) { 618 - items.push({ 619 - testID: 'listHeaderDropdownBlockBtn', 620 - label: _(msg`Unblock list`), 621 - onPress: onUnsubscribeBlock, 622 - icon: { 623 - ios: { 624 - name: 'person.fill.xmark', 625 - }, 626 - android: '', 627 - web: 'user-slash', 628 - }, 629 - }) 630 - } 631 - } 632 - return items 633 - }, [ 634 - _, 635 - onPressShare, 636 - isOwner, 637 - isModList, 638 - isPinned, 639 - isCurateList, 640 - onPressEdit, 641 - deleteListPromptControl.open, 642 - onPressReport, 643 - isPending, 644 - isBlocking, 645 - isMuting, 646 - onUnsubscribeMute, 647 - onUnsubscribeBlock, 648 - removeSavedFeed, 649 - savedFeedConfig, 650 - onRemoveFromSavedFeeds, 651 - ]) 652 - 653 - const subscribeDropdownItems: DropdownItem[] = useMemo(() => { 654 - return [ 655 - { 656 - testID: 'subscribeDropdownMuteBtn', 657 - label: _(msg`Mute accounts`), 658 - onPress: subscribeMutePromptControl.open, 659 - icon: { 660 - ios: { 661 - name: 'speaker.slash', 662 - }, 663 - android: '', 664 - web: 'user-slash', 665 - }, 666 - }, 667 - { 668 - testID: 'subscribeDropdownBlockBtn', 669 - label: _(msg`Block accounts`), 670 - onPress: subscribeBlockPromptControl.open, 671 - icon: { 672 - ios: { 673 - name: 'person.fill.xmark', 674 - }, 675 - android: '', 676 - web: 'ban', 677 - }, 678 - }, 679 - ] 680 - }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) 681 - 682 - const descriptionRT = useMemo( 683 - () => 684 - list.description 685 - ? new RichTextAPI({ 686 - text: list.description, 687 - facets: list.descriptionFacets, 688 - }) 689 - : undefined, 690 - [list], 691 - ) 692 - 693 - return ( 694 - <> 695 - <ProfileSubpageHeader 696 - href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} 697 - title={list.name} 698 - avatar={list.avatar} 699 - isOwner={list.creator.did === currentAccount?.did} 700 - creator={list.creator} 701 - purpose={list.purpose} 702 - avatarType="list"> 703 - <ReportDialog 704 - control={reportDialogControl} 705 - subject={{ 706 - ...list, 707 - $type: 'app.bsky.graph.defs#listView', 708 - }} 709 - /> 710 - {isCurateList ? ( 711 - <Button 712 - testID={isPinned ? 'unpinBtn' : 'pinBtn'} 713 - type={isPinned ? 'default' : 'inverted'} 714 - label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} 715 - onPress={onTogglePinned} 716 - disabled={isPending} 717 - /> 718 - ) : isModList ? ( 719 - isBlocking ? ( 720 - <Button 721 - testID="unblockBtn" 722 - type="default" 723 - label={_(msg`Unblock`)} 724 - onPress={onUnsubscribeBlock} 725 - /> 726 - ) : isMuting ? ( 727 - <Button 728 - testID="unmuteBtn" 729 - type="default" 730 - label={_(msg`Unmute`)} 731 - onPress={onUnsubscribeMute} 732 - /> 733 - ) : ( 734 - <NativeDropdown 735 - testID="subscribeBtn" 736 - items={subscribeDropdownItems} 737 - accessibilityLabel={_(msg`Subscribe to this list`)} 738 - accessibilityHint=""> 739 - <View style={[palInverted.view, styles.btn]}> 740 - <Text style={palInverted.text}> 741 - <Trans>Subscribe</Trans> 742 - </Text> 743 - </View> 744 - </NativeDropdown> 745 - ) 746 - ) : null} 747 - <NativeDropdown 748 - testID="headerDropdownBtn" 749 - items={dropdownItems} 750 - accessibilityLabel={_(msg`More options`)} 751 - accessibilityHint=""> 752 - <View style={[pal.viewLight, styles.btn]}> 753 - <FontAwesomeIcon 754 - icon="ellipsis" 755 - size={20} 756 - color={pal.colors.text} 757 - /> 758 - </View> 759 - </NativeDropdown> 760 - 761 - <Prompt.Basic 762 - control={deleteListPromptControl} 763 - title={_(msg`Delete this list?`)} 764 - description={_( 765 - msg`If you delete this list, you won't be able to recover it.`, 766 - )} 767 - onConfirm={onPressDelete} 768 - confirmButtonCta={_(msg`Delete`)} 769 - confirmButtonColor="negative" 770 - /> 771 - 772 - <Prompt.Basic 773 - control={subscribeMutePromptControl} 774 - title={_(msg`Mute these accounts?`)} 775 - description={_( 776 - msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, 777 - )} 778 - onConfirm={onSubscribeMute} 779 - confirmButtonCta={_(msg`Mute list`)} 780 - /> 781 - 782 - <Prompt.Basic 783 - control={subscribeBlockPromptControl} 784 - title={_(msg`Block these accounts?`)} 785 - description={_( 786 - msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 787 - )} 788 - onConfirm={onSubscribeBlock} 789 - confirmButtonCta={_(msg`Block list`)} 790 - confirmButtonColor="negative" 791 - /> 792 - </ProfileSubpageHeader> 793 - {descriptionRT ? ( 794 - <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.gap_md]}> 795 - <RichText value={descriptionRT} style={[a.text_md, a.leading_snug]} /> 796 - </View> 797 - ) : null} 798 - </> 799 - ) 800 - } 801 - 802 - interface FeedSectionProps { 803 - feed: FeedDescriptor 804 - headerHeight: number 805 - scrollElRef: ListRef 806 - isFocused: boolean 807 - isOwner: boolean 808 - onPressAddUser: () => void 809 - } 810 - const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( 811 - function FeedSectionImpl( 812 - {feed, scrollElRef, headerHeight, isFocused, isOwner, onPressAddUser}, 813 - ref, 814 - ) { 815 - const queryClient = useQueryClient() 816 - const [hasNew, setHasNew] = React.useState(false) 817 - const [isScrolledDown, setIsScrolledDown] = React.useState(false) 818 - const isScreenFocused = useIsFocused() 819 - const {_} = useLingui() 820 - 821 - const onScrollToTop = useCallback(() => { 822 - scrollElRef.current?.scrollToOffset({ 823 - animated: isNative, 824 - offset: -headerHeight, 825 - }) 826 - queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) 827 - setHasNew(false) 828 - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) 829 - React.useImperativeHandle(ref, () => ({ 830 - scrollToTop: onScrollToTop, 831 - })) 832 - 833 - React.useEffect(() => { 834 - if (!isScreenFocused) { 835 - return 836 - } 837 - return listenSoftReset(onScrollToTop) 838 - }, [onScrollToTop, isScreenFocused]) 839 - 840 - const renderPostsEmpty = useCallback(() => { 841 - return ( 842 - <View style={[a.gap_xl, a.align_center]}> 843 - <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} /> 844 - {isOwner && ( 845 - <NewButton 846 - label={_(msg`Start adding people`)} 847 - onPress={onPressAddUser} 848 - color="primary" 849 - size="small" 850 - variant="solid"> 851 - <ButtonIcon icon={PersonPlusIcon} /> 852 - <ButtonText> 853 - <Trans>Start adding people!</Trans> 854 - </ButtonText> 855 - </NewButton> 856 - )} 857 - </View> 858 - ) 859 - }, [_, onPressAddUser, isOwner]) 860 - 861 - return ( 862 - <View> 863 - <PostFeed 864 - testID="listFeed" 865 - enabled={isFocused} 866 - feed={feed} 867 - pollInterval={60e3} 868 - disablePoll={hasNew} 869 - scrollElRef={scrollElRef} 870 - onHasNew={setHasNew} 871 - onScrolledDownChange={setIsScrolledDown} 872 - renderEmptyState={renderPostsEmpty} 873 - headerOffset={headerHeight} 874 - /> 875 - {(isScrolledDown || hasNew) && ( 876 - <LoadLatestBtn 877 - onPress={onScrollToTop} 878 - label={_(msg`Load new posts`)} 879 - showIndicator={hasNew} 880 - /> 881 - )} 882 - </View> 883 - ) 884 - }, 885 - ) 886 - 887 - interface AboutSectionProps { 888 - list: AppBskyGraphDefs.ListView 889 - onPressAddUser: () => void 890 - headerHeight: number 891 - scrollElRef: ListRef 892 - } 893 - const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 894 - function AboutSectionImpl( 895 - {list, onPressAddUser, headerHeight, scrollElRef}, 896 - ref, 897 - ) { 898 - const {_} = useLingui() 899 - const {currentAccount} = useSession() 900 - const {isMobile} = useWebMediaQueries() 901 - const [isScrolledDown, setIsScrolledDown] = React.useState(false) 902 - const isOwner = list.creator.did === currentAccount?.did 903 - 904 - const onScrollToTop = useCallback(() => { 905 - scrollElRef.current?.scrollToOffset({ 906 - animated: isNative, 907 - offset: -headerHeight, 908 - }) 909 - }, [scrollElRef, headerHeight]) 910 - 911 - React.useImperativeHandle(ref, () => ({ 912 - scrollToTop: onScrollToTop, 913 - })) 914 - 915 - const renderHeader = React.useCallback(() => { 916 - if (!isOwner) { 917 - return <View /> 918 - } 919 - if (isMobile) { 920 - return ( 921 - <View style={[a.px_sm, a.py_sm]}> 922 - <NewButton 923 - testID="addUserBtn" 924 - label={_(msg`Add a user to this list`)} 925 - onPress={onPressAddUser} 926 - color="primary" 927 - size="small" 928 - variant="outline" 929 - style={[a.py_md]}> 930 - <ButtonIcon icon={PersonPlusIcon} /> 931 - <ButtonText> 932 - <Trans>Add people</Trans> 933 - </ButtonText> 934 - </NewButton> 935 - </View> 936 - ) 937 - } 938 - return ( 939 - <View style={[a.px_lg, a.py_md, a.flex_row_reverse]}> 940 - <NewButton 941 - testID="addUserBtn" 942 - label={_(msg`Add a user to this list`)} 943 - onPress={onPressAddUser} 944 - color="primary" 945 - size="small" 946 - variant="ghost" 947 - style={[a.py_sm]}> 948 - <ButtonIcon icon={PersonPlusIcon} /> 949 - <ButtonText> 950 - <Trans>Add people</Trans> 951 - </ButtonText> 952 - </NewButton> 953 - </View> 954 - ) 955 - }, [isOwner, _, onPressAddUser, isMobile]) 956 - 957 - const renderEmptyState = useCallback(() => { 958 - return ( 959 - <View style={[a.gap_xl, a.align_center]}> 960 - <EmptyState 961 - icon="users-slash" 962 - message={_(msg`This list is empty.`)} 963 - /> 964 - {isOwner && ( 965 - <NewButton 966 - testID="emptyStateAddUserBtn" 967 - label={_(msg`Start adding people`)} 968 - onPress={onPressAddUser} 969 - color="primary" 970 - size="small" 971 - variant="solid"> 972 - <ButtonIcon icon={PersonPlusIcon} /> 973 - <ButtonText> 974 - <Trans>Start adding people!</Trans> 975 - </ButtonText> 976 - </NewButton> 977 - )} 978 - </View> 979 - ) 980 - }, [_, onPressAddUser, isOwner]) 981 - 982 - return ( 983 - <View> 984 - <ListMembers 985 - testID="listItems" 986 - list={list.uri} 987 - scrollElRef={scrollElRef} 988 - renderHeader={renderHeader} 989 - renderEmptyState={renderEmptyState} 990 - headerOffset={headerHeight} 991 - onScrolledDownChange={setIsScrolledDown} 992 - /> 993 - {isScrolledDown && ( 994 - <LoadLatestBtn 995 - onPress={onScrollToTop} 996 - label={_(msg`Scroll to top`)} 997 - showIndicator={false} 998 - /> 999 - )} 1000 - </View> 1001 - ) 1002 - }, 1003 - ) 1004 - 1005 - function ErrorScreen({error}: {error: string}) { 1006 - const pal = usePalette('default') 1007 - const navigation = useNavigation<NavigationProp>() 1008 - const {_} = useLingui() 1009 - const onPressBack = useCallback(() => { 1010 - if (navigation.canGoBack()) { 1011 - navigation.goBack() 1012 - } else { 1013 - navigation.navigate('Home') 1014 - } 1015 - }, [navigation]) 1016 - 1017 - return ( 1018 - <View 1019 - style={[ 1020 - pal.view, 1021 - pal.border, 1022 - { 1023 - paddingHorizontal: 18, 1024 - paddingVertical: 14, 1025 - borderTopWidth: StyleSheet.hairlineWidth, 1026 - }, 1027 - ]}> 1028 - <Text type="title-lg" style={[pal.text, s.mb10]}> 1029 - <Trans>Could not load list</Trans> 1030 - </Text> 1031 - <Text type="md" style={[pal.text, s.mb20]}> 1032 - {error} 1033 - </Text> 1034 - 1035 - <View style={{flexDirection: 'row'}}> 1036 - <Button 1037 - type="default" 1038 - accessibilityLabel={_(msg`Go back`)} 1039 - accessibilityHint={_(msg`Returns to previous page`)} 1040 - onPress={onPressBack} 1041 - style={{flexShrink: 1}}> 1042 - <Text type="button" style={pal.text}> 1043 - <Trans>Go Back</Trans> 1044 - </Text> 1045 - </Button> 1046 - </View> 1047 - </View> 1048 - ) 1049 - } 1050 - 1051 - const styles = StyleSheet.create({ 1052 - btn: { 1053 - flexDirection: 'row', 1054 - alignItems: 'center', 1055 - gap: 6, 1056 - paddingVertical: 7, 1057 - paddingHorizontal: 14, 1058 - borderRadius: 50, 1059 - marginLeft: 6, 1060 - }, 1061 - })