Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
20 kB · 746 lines
TSX
at twelve
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'import { TextInput, useWindowDimensions, View, type ViewToken,} from 'react-native'import {type ModerationOpts} from '@atproto/api'import {msg, Trans} from '@lingui/macro'import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'import {useModerationOpts} from '#/state/preferences/moderation-opts'import {useActorSearch} from '#/state/queries/actor-search'import {usePreferencesQuery} from '#/state/queries/preferences'import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'import {useSession} from '#/state/session'import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'import {type ListMethods} from '#/view/com/util/List'import { atoms as a, native, useBreakpoints, useTheme, utils, type ViewStyleProp, web,} from '#/alf'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import * as Dialog from '#/components/Dialog'import {useInteractionState} from '#/components/hooks/useInteractionState'import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'import {boostInterests, InterestTabs} from '#/components/InterestTabs'import * as ProfileCard from '#/components/ProfileCard'import {Text} from '#/components/Typography'import {useAnalytics} from '#/analytics'import {IS_WEB} from '#/env'import type * as bsky from '#/types/bsky'import {ProgressGuideTask} from './Task'
type Item = | { type: 'profile' key: string profile: bsky.profile.AnyProfileView } | { type: 'empty' key: string message: string } | { type: 'placeholder' key: string } | { type: 'error' key: string }
export function FollowDialog({ guide, showArrow,}: { guide: Follow10ProgressGuide showArrow?: boolean}) { const ax = useAnalytics() const {_} = useLingui() const control = Dialog.useDialogControl() const {gtPhone} = useBreakpoints() const {height: minHeight} = useWindowDimensions()
return ( <> <Button label={_(msg`Find people to follow`)} onPress={() => { control.open() ax.metric('progressGuide:followDialog:open', {}) }} size={gtPhone ? 'small' : 'large'} color="primary"> <ButtonText> <Trans>Find people to follow</Trans> </ButtonText> {showArrow && <ButtonIcon icon={ArrowRightIcon} />} </Button> <Dialog.Outer control={control} nativeOptions={{minHeight}}> <Dialog.Handle /> <DialogInner guide={guide} /> </Dialog.Outer> </> )}
/** * Same as {@link FollowDialog} but without a progress guide. */export function FollowDialogWithoutGuide({ control,}: { control: Dialog.DialogOuterProps['control']}) { const {height: minHeight} = useWindowDimensions() return ( <Dialog.Outer control={control} nativeOptions={{minHeight}}> <Dialog.Handle /> <DialogInner /> </Dialog.Outer> )}
// Fine to keep this top-level.let lastSelectedInterest = ''let lastSearchText = ''
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const {_} = useLingui() const ax = useAnalytics() const interestsDisplayNames = useInterestsDisplayNames() const {data: preferences} = usePreferencesQuery() const personalizedInterests = preferences?.interests?.tags const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(personalizedInterests)) const [selectedInterest, setSelectedInterest] = useState( () => lastSelectedInterest || (personalizedInterests && interests.includes(personalizedInterests[0]) ? personalizedInterests[0] : interests[0]), ) const [searchText, setSearchText] = useState(lastSearchText) const moderationOpts = useModerationOpts() const listRef = useRef<ListMethods>(null) const inputRef = useRef<TextInput>(null) const [headerHeight, setHeaderHeight] = useState(0) const {currentAccount} = useSession()
useEffect(() => { lastSearchText = searchText lastSelectedInterest = selectedInterest }, [searchText, selectedInterest])
const { data: suggestions, isFetching: isFetchingSuggestions, error: suggestionsError, } = useGetSuggestedUsersQuery({ category: selectedInterest, limit: 50, }) const { data: searchResults, isFetching: isFetchingSearchResults, error: searchResultsError, isError: isSearchResultsError, } = useActorSearch({ enabled: !!searchText, query: searchText, })
const hasSearchText = !!searchText const resultsKey = searchText || selectedInterest const items = useMemo(() => { const results = hasSearchText ? searchResults?.pages.flatMap(p => p.actors) : suggestions?.actors let _items: Item[] = []
if (isFetchingSuggestions || isFetchingSearchResults) { const placeholders: Item[] = Array(10) .fill(0) .map((__, i) => ({ type: 'placeholder', key: i + '', }))
_items.push(...placeholders) } else if ( (hasSearchText && searchResultsError) || (!hasSearchText && suggestionsError) || !results?.length ) { _items.push({ type: 'empty', key: 'empty', message: _(msg`We're having network issues, try again`), }) } else { const seen = new Set<string>() for (const profile of results) { if (seen.has(profile.did)) continue if (profile.did === currentAccount?.did) continue if (profile.viewer?.following) continue
seen.add(profile.did)
_items.push({ type: 'profile', // Don't share identity across tabs or typing attempts key: resultsKey + ':' + profile.did, profile, }) } }
if ( hasSearchText && !isFetchingSearchResults && !_items.length && !isSearchResultsError ) { _items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) }
return _items }, [ _, suggestions, suggestionsError, isFetchingSuggestions, searchResults, searchResultsError, isFetchingSearchResults, currentAccount?.did, hasSearchText, resultsKey, isSearchResultsError, ])
const renderItems = useCallback( ({item, index}: {item: Item; index: number}) => { switch (item.type) { case 'profile': { return ( <FollowProfileCard profile={item.profile} moderationOpts={moderationOpts!} noBorder={index === 0} /> ) } case 'placeholder': { return <ProfileCardSkeleton key={item.key} /> } case 'empty': { return <Empty key={item.key} message={item.message} /> } default: return null } }, [moderationOpts], )
// Track seen profiles const seenProfilesRef = useRef<Set<string>>(new Set()) const itemsRef = useRef(items) itemsRef.current = items const selectedInterestRef = useRef(selectedInterest) selectedInterestRef.current = selectedInterest
const onViewableItemsChanged = useNonReactiveCallback( ({viewableItems}: {viewableItems: ViewToken[]}) => { for (const viewableItem of viewableItems) { const item = viewableItem.item as Item if (item.type === 'profile') { if (!seenProfilesRef.current.has(item.profile.did)) { seenProfilesRef.current.add(item.profile.did) const position = itemsRef.current.findIndex( i => i.type === 'profile' && i.profile.did === item.profile.did, ) ax.metric('suggestedUser:seen', { logContext: 'ProgressGuide', recId: hasSearchText ? undefined : suggestions?.recId, position: position !== -1 ? position : 0, suggestedDid: item.profile.did, category: selectedInterestRef.current, }) } } } }, ) const viewabilityConfig = useMemo( () => ({ itemVisiblePercentThreshold: 50, }), [], )
const onSelectTab = useCallback( (interest: string) => { setSelectedInterest(interest) inputRef.current?.clear() setSearchText('') listRef.current?.scrollToOffset({ offset: 0, animated: false, }) }, [setSelectedInterest, setSearchText], )
const listHeader = ( <Header guide={guide} inputRef={inputRef} listRef={listRef} searchText={searchText} onSelectTab={onSelectTab} setHeaderHeight={setHeaderHeight} setSearchText={setSearchText} interests={interests} selectedInterest={selectedInterest} interestsDisplayNames={interestsDisplayNames} /> )
return ( <Dialog.InnerFlatList ref={listRef} data={items} renderItem={renderItems} ListHeaderComponent={listHeader} stickyHeaderIndices={[0]} keyExtractor={(item: Item) => item.key} style={[ a.px_0, web([a.py_0, {height: '100vh', maxHeight: 600}]), native({height: '100%'}), ]} webInnerContentContainerStyle={a.py_0} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} keyboardDismissMode="on-drag" scrollIndicatorInsets={{top: headerHeight}} initialNumToRender={8} maxToRenderPerBatch={8} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} /> )}
let Header = ({ guide, inputRef, listRef, searchText, onSelectTab, setHeaderHeight, setSearchText, interests, selectedInterest, interestsDisplayNames,}: { guide?: Follow10ProgressGuide inputRef: React.RefObject<TextInput | null> listRef: React.RefObject<ListMethods | null> onSelectTab: (v: string) => void searchText: string setHeaderHeight: (v: number) => void setSearchText: (v: string) => void interests: string[] selectedInterest: string interestsDisplayNames: Record<string, string>}): React.ReactNode => { const t = useTheme() const control = Dialog.useDialogContext() return ( <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} style={[ a.relative, web(a.pt_lg), native(a.pt_4xl), a.pb_xs, a.border_b, t.atoms.border_contrast_low, t.atoms.bg, ]}> <HeaderTop guide={guide} />
<View style={[web(a.pt_xs), a.pb_xs]}> <SearchInput inputRef={inputRef} defaultValue={searchText} onChangeText={text => { setSearchText(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onEscape={control.close} /> <InterestTabs onSelectTab={onSelectTab} interests={interests} selectedInterest={selectedInterest} disabled={!!searchText} interestsDisplayNames={interestsDisplayNames} TabComponent={Tab} /> </View> </View> )}Header = memo(Header)
function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) { const {_} = useLingui() const t = useTheme() const control = Dialog.useDialogContext() return ( <View style={[ a.px_lg, a.relative, a.flex_row, a.justify_between, a.align_center, ]}> <Text style={[ a.z_10, a.text_lg, a.font_bold, a.leading_tight, t.atoms.text_contrast_high, ]}> <Trans>Find people to follow</Trans> </Text> {guide && ( <View style={IS_WEB && {paddingRight: 36}}> <ProgressGuideTask current={guide.numFollows + 1} total={10 + 1} title={`${guide.numFollows} / 10`} tabularNumsTitle /> </View> )} {IS_WEB ? ( <Button label={_(msg`Close`)} size="small" shape="round" variant={IS_WEB ? 'ghost' : 'solid'} color="secondary" style={[ a.absolute, a.z_20, web({right: 8}), native({right: 0}), native({height: 32, width: 32, borderRadius: 16}), ]} onPress={() => control.close()}> <ButtonIcon icon={X} size="md" /> </Button> ) : null} </View> )}
let Tab = ({ onSelectTab, interest, active, index, interestsDisplayName, onLayout,}: { onSelectTab: (index: number) => void interest: string active: boolean index: number interestsDisplayName: string onLayout: (index: number, x: number, width: number) => void}): React.ReactNode => { const t = useTheme() const {_} = useLingui() const label = active ? _( msg({ message: `Search for "${interestsDisplayName}" (active)`, comment: 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.', }), ) : _( msg({ message: `Search for "${interestsDisplayName}"`, comment: 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.', }), ) return ( <View key={interest} onLayout={e => onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) }> <Button label={label} onPress={() => onSelectTab(index)}> {({hovered, pressed}) => ( <View style={[ a.rounded_full, a.px_lg, a.py_sm, a.border, active || hovered || pressed ? [ t.atoms.bg_contrast_25, {borderColor: t.atoms.bg_contrast_25.backgroundColor}, ] : [t.atoms.bg, t.atoms.border_contrast_low], ]}> <Text style={[ a.font_medium, active || hovered || pressed ? t.atoms.text : t.atoms.text_contrast_medium, ]}> {interestsDisplayName} </Text> </View> )} </Button> </View> )}Tab = memo(Tab)
let FollowProfileCard = ({ profile, moderationOpts, noBorder,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts noBorder?: boolean}): React.ReactNode => { return ( <FollowProfileCardInner profile={profile} moderationOpts={moderationOpts} noBorder={noBorder} /> )}FollowProfileCard = memo(FollowProfileCard)
function FollowProfileCardInner({ profile, moderationOpts, onFollow, noBorder,}: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onFollow?: () => void noBorder?: boolean}) { const control = Dialog.useDialogContext() const t = useTheme() return ( <ProfileCard.Link profile={profile} style={[a.flex_1]} onPress={() => control.close()}> {({hovered, pressed}) => ( <CardOuter style={[ a.flex_1, noBorder && a.border_t_0, (hovered || pressed) && t.atoms.bg_contrast_25, ]}> <ProfileCard.Outer> <ProfileCard.Header> <ProfileCard.Avatar disabledPreview={!IS_WEB} profile={profile} moderationOpts={moderationOpts} /> <ProfileCard.NameAndHandle profile={profile} moderationOpts={moderationOpts} /> <ProfileCard.FollowButton profile={profile} moderationOpts={moderationOpts} logContext="PostOnboardingFindFollows" shape="round" onPress={onFollow} colorInverted /> </ProfileCard.Header> <ProfileCard.Description profile={profile} numberOfLines={2} /> </ProfileCard.Outer> </CardOuter> )} </ProfileCard.Link> )}
function CardOuter({ children, style,}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { const t = useTheme() return ( <View style={[ a.w_full, a.py_md, a.px_lg, a.border_t, t.atoms.border_contrast_low, style, ]}> {children} </View> )}
function SearchInput({ onChangeText, onEscape, inputRef, defaultValue,}: { onChangeText: (text: string) => void onEscape: () => void inputRef: React.RefObject<TextInput | null> defaultValue: string}) { const t = useTheme() const {_} = useLingui() const { state: hovered, onIn: onMouseEnter, onOut: onMouseLeave, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const interacted = hovered || focused
return ( <View {...web({ onMouseEnter, onMouseLeave, })} style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> <SearchIcon size="md" fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} />
<TextInput ref={inputRef} placeholder={_(msg`Search by name or interest`)} defaultValue={defaultValue} onChangeText={onChangeText} onFocus={onFocus} onBlur={onBlur} selectionColor={utils.alpha(t.palette.primary_500, 0.4)} cursorColor={t.palette.primary_500} selectionHandleColor={t.palette.primary_500} style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} returnKeyType="search" clearButtonMode="while-editing" maxLength={50} onKeyPress={({nativeEvent}) => { if (nativeEvent.key === 'Escape') { onEscape() } }} autoCorrect={false} autoComplete="off" autoCapitalize="none" accessibilityLabel={_(msg`Search profiles`)} accessibilityHint={_(msg`Searches for profiles`)} /> </View> )}
function ProfileCardSkeleton() { const t = useTheme()
return ( <View style={[ a.flex_1, a.py_md, a.px_lg, a.gap_md, a.align_center, a.flex_row, ]}> <View style={[ a.rounded_full, {width: 42, height: 42}, t.atoms.bg_contrast_25, ]} />
<View style={[a.flex_1, a.gap_sm]}> <View style={[ a.rounded_xs, {width: 80, height: 14}, t.atoms.bg_contrast_25, ]} /> <View style={[ a.rounded_xs, {width: 120, height: 10}, t.atoms.bg_contrast_25, ]} /> </View> </View> )}
function Empty({message}: {message: string}) { const t = useTheme() return ( <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> {message} </Text>
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> </View> )}