diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 81d08c7da3..44b7830640 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -76,6 +76,7 @@ import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' +import {Wizard as FeedsWizard} from '#/screens/Feeds/Wizard' import HashtagScreen from '#/screens/Hashtag' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' @@ -351,6 +352,16 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => FeedsScreen} options={{title: title(msg`Feeds`)}} /> + FeedsWizard} + options={{title: title(msg`Create a feed`), requireAuth: true}} + /> + FeedsWizard} + options={{title: title(msg`Edit your feed`), requireAuth: true}} + /> StarterPackScreen} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 426665d07d..531ad3849e 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -46,6 +46,8 @@ export type CommonNavigatorParams = { MessagesSettings: undefined NotificationsSettings: undefined Feeds: undefined + FeedsWizard: undefined + FeedsEdit: {rkey?: string} Start: {name: string; rkey: string} StarterPack: {name: string; rkey: string; new?: boolean} StarterPackShort: {code: string} @@ -96,6 +98,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { SearchTab: undefined Search: {q?: string} Feeds: undefined + FeedsEdit: {rkey?: string} NotificationsTab: undefined Notifications: {show?: 'all'} MyProfileTab: undefined diff --git a/src/routes.ts b/src/routes.ts index 2ae4126ace..6e92e5b07e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,9 +1,11 @@ -import {Router} from 'lib/routes/router' +import {Router} from '#/lib/routes/router' export const router = new Router({ Home: '/', Search: '/search', Feeds: '/feeds', + FeedsWizard: '/feeds/create', + FeedsEdit: '/feeds/edit/:rkey', Notifications: '/notifications', NotificationsSettings: '/notifications/settings', Settings: '/settings', diff --git a/src/screens/Feeds/Wizard/State.tsx b/src/screens/Feeds/Wizard/State.tsx new file mode 100644 index 0000000000..f65933fbb0 --- /dev/null +++ b/src/screens/Feeds/Wizard/State.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AppBskyGraphStarterpack, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' + +import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' + +const steps = ['Details', 'Profiles', 'Feeds'] as const +type Step = (typeof steps)[number] + +type Action = + | {type: 'Next'} + | {type: 'Back'} + | {type: 'SetCanNext'; canNext: boolean} + | {type: 'SetName'; name: string} + | {type: 'SetDescription'; description: string} + | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic} + | {type: 'RemoveProfile'; profileDid: string} + | {type: 'AddFeed'; feed: GeneratorView} + | {type: 'RemoveFeed'; feedUri: string} + | {type: 'SetProcessing'; processing: boolean} + | {type: 'SetError'; error: string} + +interface State { + canNext: boolean + currentStep: Step + name?: string + description?: string + profiles: AppBskyActorDefs.ProfileViewBasic[] + feeds: GeneratorView[] + processing: boolean + error?: string + transitionDirection: 'Backward' | 'Forward' +} + +type TStateContext = [State, (action: Action) => void] + +const StateContext = React.createContext([ + {} as State, + (_: Action) => {}, +]) +export const useWizardState = () => React.useContext(StateContext) + +function reducer(state: State, action: Action): State { + let updatedState = state + + // -- Navigation + const currentIndex = steps.indexOf(state.currentStep) + if (action.type === 'Next' && state.currentStep !== 'Feeds') { + updatedState = { + ...state, + currentStep: steps[currentIndex + 1], + transitionDirection: 'Forward', + } + } else if (action.type === 'Back' && state.currentStep !== 'Details') { + updatedState = { + ...state, + currentStep: steps[currentIndex - 1], + transitionDirection: 'Backward', + } + } + + switch (action.type) { + case 'SetName': + updatedState = {...state, name: action.name.slice(0, 50)} + break + case 'SetDescription': + updatedState = {...state, description: action.description} + break + case 'AddProfile': + if (state.profiles.length > STARTER_PACK_MAX_SIZE) { + Toast.show( + msg`You may only add up to ${STARTER_PACK_MAX_SIZE} profiles` + .message ?? '', + 'info', + ) + } else { + updatedState = {...state, profiles: [...state.profiles, action.profile]} + } + break + case 'RemoveProfile': + updatedState = { + ...state, + profiles: state.profiles.filter( + profile => profile.did !== action.profileDid, + ), + } + break + case 'AddFeed': + if (state.feeds.length >= 3) { + Toast.show(msg`You may only add up to 3 feeds`.message ?? '', 'info') + } else { + updatedState = {...state, feeds: [...state.feeds, action.feed]} + } + break + case 'RemoveFeed': + updatedState = { + ...state, + feeds: state.feeds.filter(f => f.uri !== action.feedUri), + } + break + case 'SetProcessing': + updatedState = {...state, processing: action.processing} + break + } + + return updatedState +} + +// TODO supply the initial state to this component +export function Provider({ + starterPack, + listItems, + children, +}: { + starterPack?: AppBskyGraphDefs.StarterPackView + listItems?: AppBskyGraphDefs.ListItemView[] + children: React.ReactNode +}) { + const {currentAccount} = useSession() + + const createInitialState = (): State => { + if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return { + canNext: true, + currentStep: 'Details', + name: starterPack.record.name, + description: starterPack.record.description, + profiles: + listItems + ?.map(i => i.subject) + .filter(p => p.did !== currentAccount?.did) ?? [], + feeds: starterPack.feeds ?? [], + processing: false, + transitionDirection: 'Forward', + } + } + + return { + canNext: true, + currentStep: 'Details', + profiles: [], + feeds: [], + processing: false, + transitionDirection: 'Forward', + } + } + + const [state, dispatch] = React.useReducer(reducer, null, createInitialState) + + return ( + + {children} + + ) +} + +export { + type Action as WizardAction, + type State as WizardState, + type Step as WizardStep, +} diff --git a/src/screens/Feeds/Wizard/StepDetails.tsx b/src/screens/Feeds/Wizard/StepDetails.tsx new file mode 100644 index 0000000000..4ee2cada97 --- /dev/null +++ b/src/screens/Feeds/Wizard/StepDetails.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {StarterPack} from '#/components/icons/StarterPack' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {Text} from '#/components/Typography' + +export function StepDetails() { + const {_} = useLingui() + const t = useTheme() + const [state, dispatch] = useWizardState() + + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 300, + }) + + return ( + + + + + + Invites, but personal + + + + Invite your friends to follow your favorite feeds and people + + + + + + What do you want to call your starter pack? + + + dispatch({type: 'SetName', name: text})} + /> + + + {state.name?.length ?? 0}/50 + + + + + + + Tell us a little more + + + + dispatch({type: 'SetDescription', description: text}) + } + multiline + style={{minHeight: 150}} + /> + + + + + ) +} diff --git a/src/screens/Feeds/Wizard/StepFeeds.tsx b/src/screens/Feeds/Wizard/StepFeeds.tsx new file mode 100644 index 0000000000..0cf6ab2312 --- /dev/null +++ b/src/screens/Feeds/Wizard/StepFeeds.tsx @@ -0,0 +1,126 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {DISCOVER_FEED_URI} from '#/lib/constants' +import {useA11y} from '#/state/a11y' +import { + useGetPopularFeedsQuery, + usePopularFeedsSearch, + useSavedFeeds, +} from '#/state/queries/feed' +import {List} from '#/view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {SearchInput} from '#/components/forms/SearchInput' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { + return item.uri +} + +export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const throttledQuery = useThrottledValue(query, 500) + const {screenReaderEnabled} = useA11y() + + const {data: savedFeedsAndLists, isFetchedAfterMount: isFetchedSavedFeeds} = + useSavedFeeds() + const savedFeeds = savedFeedsAndLists?.feeds + .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI) + .map(f => f.view) as AppBskyFeedDefs.GeneratorView[] + + const { + data: popularFeedsPages, + fetchNextPage, + isLoading: isLoadingPopularFeeds, + } = useGetPopularFeedsQuery({ + limit: 30, + }) + const popularFeeds = popularFeedsPages?.pages.flatMap(p => p.feeds) ?? [] + + // If we have saved feeds already loaded, display them immediately + // Then, when popular feeds have loaded we can concat them to the saved feeds + const suggestedFeeds = + savedFeeds || isFetchedSavedFeeds + ? popularFeeds + ? savedFeeds.concat( + popularFeeds.filter(f => !savedFeeds.some(sf => sf.uri === f.uri)), + ) + : savedFeeds + : undefined + + const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} = + usePopularFeedsSearch({query: throttledQuery}) + + const isLoading = + !isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds + + const renderItem = ({ + item, + }: ListRenderItemInfo) => { + return ( + + ) + } + + return ( + + + + setQuery(t)} + onClearText={() => setQuery('')} + /> + + + fetchNextPage() : undefined + } + onEndReachedThreshold={2} + renderScrollComponent={props => } + keyboardShouldPersistTaps="handled" + disableFullWindowScroll={true} + sideBorders={false} + style={{flex: 1}} + ListEmptyComponent={ + + {isLoading ? ( + + ) : ( + + No feeds found. Try searching for something else. + + )} + + } + /> + + ) +} diff --git a/src/screens/Feeds/Wizard/StepFinished.tsx b/src/screens/Feeds/Wizard/StepFinished.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/screens/Feeds/Wizard/StepProfiles.tsx b/src/screens/Feeds/Wizard/StepProfiles.tsx new file mode 100644 index 0000000000..054a6a63e2 --- /dev/null +++ b/src/screens/Feeds/Wizard/StepProfiles.tsx @@ -0,0 +1,110 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {isNative} from '#/platform/detection' +import {useA11y} from '#/state/a11y' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {List} from '#/view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {SearchInput} from '#/components/forms/SearchInput' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { + return item?.did ?? '' +} + +export function StepProfiles({ + moderationOpts, +}: { + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const {screenReaderEnabled} = useA11y() + + const { + data: topPages, + fetchNextPage, + isLoading: isLoadingTopPages, + } = useActorSearchPaginated({ + query: encodeURIComponent('*'), + }) + const topFollowers = topPages?.pages + .flatMap(p => p.actors) + .filter(p => !p.associated?.labeler) + + const {data: resultsUnfiltered, isFetching: isFetchingResults} = + useActorAutocompleteQuery(query, true, 12) + const results = resultsUnfiltered?.filter(p => !p.associated?.labeler) + + const isLoading = isLoadingTopPages || isFetchingResults + + const renderItem = ({ + item, + }: ListRenderItemInfo) => { + return ( + + ) + } + + return ( + + + + setQuery('')} + /> + + + } + keyboardShouldPersistTaps="handled" + disableFullWindowScroll={true} + sideBorders={false} + style={[a.flex_1]} + onEndReached={ + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={isNative ? 2 : 0.25} + ListEmptyComponent={ + + {isLoading ? ( + + ) : ( + + Nobody was found. Try searching for someone else. + + )} + + } + /> + + ) +} diff --git a/src/screens/Feeds/Wizard/index.tsx b/src/screens/Feeds/Wizard/index.tsx new file mode 100644 index 0000000000..d7948cec0d --- /dev/null +++ b/src/screens/Feeds/Wizard/index.tsx @@ -0,0 +1,608 @@ +import React from 'react' +import {Keyboard, TouchableOpacity, View} from 'react-native' +import { + KeyboardAwareScrollView, + useKeyboardController, +} from 'react-native-keyboard-controller' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Image} from 'expo-image' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {HITSLOP_10, STARTER_PACK_MAX_SIZE} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {logEvent} from '#/lib/statsig/statsig' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {enforceLen} from '#/lib/strings/helpers' +import { + getStarterPackOgCard, + parseStarterPackUri, +} from '#/lib/strings/starter-pack' +import {logger} from '#/logger' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAllListMembersQuery} from '#/state/queries/list-members' +import {useProfileQuery} from '#/state/queries/profile' +import { + useCreateStarterPackMutation, + useEditStarterPackMutation, + useStarterPackQuery, +} from '#/state/queries/starter-packs' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {CenteredView} from '#/view/com/util/Views' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import * as Layout from '#/components/Layout' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' +import {Text} from '#/components/Typography' +import {useWizardState, WizardStep} from './State' +import {Provider} from './State' +import {StepDetails} from './StepDetails' +import {StepFeeds} from './StepFeeds' +import {StepProfiles} from './StepProfiles' + +export function Wizard({ + route, +}: NativeStackScreenProps) { + const {rkey} = route.params ?? {} + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + const {_} = useLingui() + + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did: currentAccount!.did, rkey}) + const listUri = starterPack?.list?.uri + + const { + data: listItems, + isLoading: isLoadingProfiles, + isError: isErrorProfiles, + } = useAllListMembersQuery(listUri) + + const { + data: profile, + isLoading: isLoadingProfile, + isError: isErrorProfile, + } = useProfileQuery({did: currentAccount?.did}) + + const isEdit = Boolean(rkey) + const isReady = + (!isEdit || (isEdit && starterPack && listItems)) && + profile && + moderationOpts + + if (!isReady) { + return ( + + + + ) + } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) { + return ( + + + + ) + } + + return ( + + + + + + ) +} + +function WizardInner({ + currentStarterPack, + currentListItems, + profile, + moderationOpts, +}: { + currentStarterPack?: AppBskyGraphDefs.StarterPackView + currentListItems?: AppBskyGraphDefs.ListItemView[] + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const navigation = useNavigation() + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {setEnabled} = useKeyboardController() + const [state, dispatch] = useWizardState() + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 0, + }) + const parsed = parseStarterPackUri(currentStarterPack?.uri) + + React.useEffect(() => { + navigation.setOptions({ + gestureEnabled: false, + }) + }, [navigation]) + + useFocusEffect( + React.useCallback(() => { + setEnabled(true) + setMinimalShellMode(true) + + return () => { + setMinimalShellMode(false) + setEnabled(false) + } + }, [setMinimalShellMode, setEnabled]), + ) + + const getDefaultName = () => { + const displayName = createSanitizedDisplayName(currentProfile!, true) + return _(msg`${displayName}'s Starter Pack`).slice(0, 50) + } + + const wizardUiStrings: Record< + WizardStep, + {header: string; nextBtn: string; subtitle?: string} + > = { + Details: { + header: _(msg`Starter Pack`), + nextBtn: _(msg`Next`), + }, + Profiles: { + header: _(msg`Choose People`), + nextBtn: _(msg`Next`), + }, + Feeds: { + header: _(msg`Choose Feeds`), + nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`), + }, + } + const currUiStrings = wizardUiStrings[state.currentStep] + + const onSuccessCreate = (data: {uri: string; cid: string}) => { + const rkey = new AtUri(data.uri).rkey + logEvent('starterPack:create', { + setName: state.name != null, + setDescription: state.description != null, + profilesCount: state.profiles.length, + feedsCount: state.feeds.length, + }) + Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) + dispatch({type: 'SetProcessing', processing: false}) + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey, + new: true, + }) + } + + const onSuccessEdit = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey: parsed!.rkey, + }) + } + } + + const {mutate: createStarterPack} = useCreateStarterPackMutation({ + onSuccess: onSuccessCreate, + onError: e => { + logger.error('Failed to create starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`), 'xmark') + }, + }) + const {mutate: editStarterPack} = useEditStarterPackMutation({ + onSuccess: onSuccessEdit, + onError: e => { + logger.error('Failed to edit starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`), 'xmark') + }, + }) + + const submit = async () => { + dispatch({type: 'SetProcessing', processing: true}) + if (currentStarterPack && currentListItems) { + editStarterPack({ + name: state.name?.trim() || getDefaultName(), + description: state.description?.trim(), + profiles: state.profiles, + feeds: state.feeds, + currentStarterPack: currentStarterPack, + currentListItems: currentListItems, + }) + } else { + createStarterPack({ + name: state.name?.trim() || getDefaultName(), + description: state.description?.trim(), + profiles: state.profiles, + feeds: state.feeds, + }) + } + } + + const onNext = () => { + if (state.currentStep === 'Feeds') { + submit() + return + } + + const keyboardVisible = Keyboard.isVisible() + Keyboard.dismiss() + setTimeout( + () => { + dispatch({type: 'Next'}) + }, + keyboardVisible ? 16 : 0, + ) + } + + return ( + + + + { + if (state.currentStep === 'Details') { + navigation.pop() + } else { + dispatch({type: 'Back'}) + } + }}> + + + + + {currUiStrings.header} + + + + + + {state.currentStep === 'Details' ? ( + + ) : state.currentStep === 'Profiles' ? ( + + ) : state.currentStep === 'Feeds' ? ( + + ) : null} + + + {state.currentStep !== 'Details' && ( +