diff --git a/.eslintrc.js b/.eslintrc.js index c7c98775..bc4a2a39 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,4 +26,7 @@ module.exports = { '*.html', 'bskyweb', ], + settings: { + componentWrapperFunctions: ['observer'], + }, } diff --git a/src/App.native.tsx b/src/App.native.tsx index ad37aa09..09782a87 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -19,7 +19,7 @@ import {handleLink} from './Navigation' SplashScreen.preventAutoHideAsync() -const App = observer(() => { +const App = observer(function AppImpl() { const [rootStore, setRootStore] = useState( undefined, ) diff --git a/src/App.web.tsx b/src/App.web.tsx index b0f949b8..41a7189d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -10,7 +10,7 @@ import {ToastContainer} from './view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' import {observer} from 'mobx-react-lite' -const App = observer(() => { +const App = observer(function AppImpl() { const [rootStore, setRootStore] = useState( undefined, ) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2422491e..dac70dfc 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -330,7 +330,7 @@ function NotificationsTabNavigator() { ) } -const MyProfileTabNavigator = observer(() => { +const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const store = useStores() return ( @@ -360,7 +360,7 @@ const MyProfileTabNavigator = observer(() => { * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ -const FlatNavigator = observer(() => { +const FlatNavigator = observer(function FlatNavigatorImpl() { const pal = usePalette('default') const unreadCountLabel = useStores().me.notifications.unreadCountLabel const title = (page: string) => bskyTitle(page, unreadCountLabel) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 6d3b87dd..c74c2aa3 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -16,7 +16,7 @@ enum ScreenState { S_CreateAccount, } -export const LoggedOut = observer(() => { +export const LoggedOut = observer(function LoggedOutImpl() { const pal = usePalette('default') const store = useStores() const {screen} = useAnalytics() diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index 065d4d24..6ea8cd79 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -8,7 +8,7 @@ import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' -export const Onboarding = observer(() => { +export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') const store = useStores() diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 8cf1cfaf..1d64cc06 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -20,114 +20,116 @@ import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' -export const CreateAccount = observer( - ({onPressBack}: {onPressBack: () => void}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const model = React.useMemo(() => new CreateAccountModel(store), [store]) +export const CreateAccount = observer(function CreateAccountImpl({ + onPressBack, +}: { + onPressBack: () => void +}) { + const {track, screen} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const model = React.useMemo(() => new CreateAccountModel(store), [store]) - React.useEffect(() => { - screen('CreateAccount') - }, [screen]) + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) - React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + React.useEffect(() => { + model.fetchServiceDescription() + }, [model]) - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + const onPressRetryConnect = React.useCallback( + () => model.fetchServiceDescription(), + [model], + ) - const onPressBackInner = React.useCallback(() => { - if (model.canBack) { - model.back() - } else { - onPressBack() + const onPressBackInner = React.useCallback(() => { + if (model.canBack) { + model.back() + } else { + onPressBack() + } + }, [model, onPressBack]) + + const onPressNext = React.useCallback(async () => { + if (!model.canNext) { + return + } + if (model.step < 3) { + model.next() + } else { + try { + await model.submit() + } catch { + // dont need to handle here + } finally { + track('Try Create Account') } - }, [model, onPressBack]) + } + }, [model, track]) - const onPressNext = React.useCallback(async () => { - if (!model.canNext) { - return - } - if (model.step < 3) { - model.next() - } else { - try { - await model.submit() - } catch { - // dont need to handle here - } finally { - track('Try Create Account') - } - } - }, [model, track]) - - return ( - - - - - {model.step === 1 && } - {model.step === 2 && } - {model.step === 3 && } - - + return ( + + + + + {model.step === 1 && } + {model.step === 2 && } + {model.step === 3 && } + + + + + Back + + + + {model.canNext ? ( - - Back + {model.isProcessing ? ( + + ) : ( + + Next + + )} + + ) : model.didServiceDescriptionFetchFail ? ( + + + Retry - - {model.canNext ? ( - - {model.isProcessing ? ( - - ) : ( - - Next - - )} - - ) : model.didServiceDescriptionFetchFail ? ( - - - Retry - - - ) : model.isFetchingServiceDescription ? ( - <> - - - Connecting... - - - ) : undefined} - - - - - - ) - }, -) + ) : model.isFetchingServiceDescription ? ( + <> + + + Connecting... + + + ) : undefined} + + + + + + ) +}) const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 5d3dec43..cdd5cb21 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -20,7 +20,11 @@ import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' * @field Bluesky (default) * @field Other (staging, local dev, your own PDS, etc.) */ -export const Step1 = observer(({model}: {model: CreateAccountModel}) => { +export const Step1 = observer(function Step1Impl({ + model, +}: { + model: CreateAccountModel +}) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 5f71469f..83b0aee4 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -21,7 +21,11 @@ import {useStores} from 'state/index' * @field Birth date * @readonly Terms of service & privacy policy */ -export const Step2 = observer(({model}: {model: CreateAccountModel}) => { +export const Step2 = observer(function Step2Impl({ + model, +}: { + model: CreateAccountModel +}) { const pal = usePalette('default') const store = useStores() diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index f35777d2..beb756ac 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -13,7 +13,11 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage' /** STEP 3: Your user handle * @field User handle */ -export const Step3 = observer(({model}: {model: CreateAccountModel}) => { +export const Step3 = observer(function Step3Impl({ + model, +}: { + model: CreateAccountModel +}) { const pal = usePalette('default') return ( diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 92d12f60..99cdcafd 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -15,7 +15,9 @@ import {RECOMMENDED_FEEDS} from 'lib/constants' type Props = { next: () => void } -export const RecommendedFeeds = observer(({next}: Props) => { +export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ + next, +}: Props) { const pal = usePalette('default') const {isTabletOrMobile} = useWebMediaQueries() diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index d16b3213..e5d12273 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -13,130 +13,134 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {makeRecordUri} from 'lib/strings/url-helpers' import {sanitizeHandle} from 'lib/strings/handles' -export const RecommendedFeedsItem = observer( - ({did, rkey}: {did: string; rkey: string}) => { - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') - const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey) - const item = useCustomFeed(uri) - if (!item) return null - const onToggle = async () => { - if (item.isSaved) { - try { - await item.unsave() - } catch (e) { - Toast.show('There was an issue contacting your server') - console.error('Failed to unsave feed', {e}) - } - } else { - try { - await item.save() - await item.pin() - } catch (e) { - Toast.show('There was an issue contacting your server') - console.error('Failed to pin feed', {e}) - } +export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ + did, + rkey, +}: { + did: string + rkey: string +}) { + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey) + const item = useCustomFeed(uri) + if (!item) return null + const onToggle = async () => { + if (item.isSaved) { + try { + await item.unsave() + } catch (e) { + Toast.show('There was an issue contacting your server') + console.error('Failed to unsave feed', {e}) + } + } else { + try { + await item.save() + await item.pin() + } catch (e) { + Toast.show('There was an issue contacting your server') + console.error('Failed to pin feed', {e}) } } - return ( - - - - - - + } + return ( + + + + + + + + {item.displayName} + + + + by {sanitizeHandle(item.data.creator.handle, '@')} + + + {item.data.description ? ( - {item.displayName} + type="xl" + style={[ + pal.text, + { + flex: isMobile ? 1 : undefined, + maxWidth: 550, + marginBottom: 18, + }, + ]} + numberOfLines={6}> + {item.data.description} + ) : null} - - by {sanitizeHandle(item.data.creator.handle, '@')} - - - {item.data.description ? ( - - {item.data.description} - - ) : null} - - - - - - - - {item.data.likeCount || 0} - + + + + + + + {item.data.likeCount || 0} + - ) - }, -) + + ) +}) diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index 7b7555ac..c066e9bd 100644 --- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx +++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx @@ -14,7 +14,9 @@ type Props = { skip: () => void } -export const WelcomeDesktop = observer(({next}: Props) => { +export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ + next, +}: Props) { const pal = usePalette('default') const horizontal = useMediaQuery({minWidth: 1300}) const title = ( diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 0f627ad0..19c8d52d 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -13,7 +13,10 @@ type Props = { skip: () => void } -export const WelcomeMobile = observer(({next, skip}: Props) => { +export const WelcomeMobile = observer(function WelcomeMobileImpl({ + next, + skip, +}: Props) { const pal = usePalette('default') return ( diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx index c81c2d5d..25d12165 100644 --- a/src/view/com/auth/withAuthRequired.tsx +++ b/src/view/com/auth/withAuthRequired.tsx @@ -17,7 +17,7 @@ import {STATUS_PAGE_URL} from 'lib/constants' export const withAuthRequired =

( Component: React.ComponentType

, ): React.FC

=> - observer((props: P) => { + observer(function AuthRequired(props: P) { const store = useStores() if (store.session.isResumingSession) { return diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index d5465f79..fa3f29cf 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -16,7 +16,7 @@ interface Props { gallery: GalleryModel } -export const Gallery = observer(function ({gallery}: Props) { +export const Gallery = observer(function GalleryImpl({gallery}: Props) { const store = useStores() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index c9b8b84b..d808d896 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -8,90 +8,88 @@ import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {useGrapheme} from '../hooks/useGrapheme' -export const Autocomplete = observer( - ({ - view, - onSelect, - }: { - view: UserAutocompleteModel - onSelect: (item: string) => void - }) => { - const pal = usePalette('default') - const positionInterp = useAnimatedValue(0) - const {getGraphemeString} = useGrapheme() +export const Autocomplete = observer(function AutocompleteImpl({ + view, + onSelect, +}: { + view: UserAutocompleteModel + onSelect: (item: string) => void +}) { + const pal = usePalette('default') + const positionInterp = useAnimatedValue(0) + const {getGraphemeString} = useGrapheme() - useEffect(() => { - Animated.timing(positionInterp, { - toValue: view.isActive ? 1 : 0, - duration: 200, - useNativeDriver: true, - }).start() - }, [positionInterp, view.isActive]) + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: true, + }).start() + }, [positionInterp, view.isActive]) - const topAnimStyle = { - transform: [ - { - translateY: positionInterp.interpolate({ - inputRange: [0, 1], - outputRange: [200, 0], - }), - }, - ], - } + const topAnimStyle = { + transform: [ + { + translateY: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [200, 0], + }), + }, + ], + } - return ( - - {view.isActive ? ( - - {view.suggestions.length > 0 ? ( - view.suggestions.slice(0, 5).map(item => { - // Eventually use an average length - const MAX_CHARS = 40 - const MAX_HANDLE_CHARS = 20 + return ( + + {view.isActive ? ( + + {view.suggestions.length > 0 ? ( + view.suggestions.slice(0, 5).map(item => { + // Eventually use an average length + const MAX_CHARS = 40 + const MAX_HANDLE_CHARS = 20 - // Using this approach because styling is not respecting - // bounding box wrapping (before converting to ellipsis) - const {name: displayHandle, remainingCharacters} = - getGraphemeString(item.handle, MAX_HANDLE_CHARS) + // Using this approach because styling is not respecting + // bounding box wrapping (before converting to ellipsis) + const {name: displayHandle, remainingCharacters} = + getGraphemeString(item.handle, MAX_HANDLE_CHARS) - const {name: displayName} = getGraphemeString( - item.displayName ?? item.handle, - MAX_CHARS - - MAX_HANDLE_CHARS + - (remainingCharacters > 0 ? remainingCharacters : 0), - ) + const {name: displayName} = getGraphemeString( + item.displayName ?? item.handle, + MAX_CHARS - + MAX_HANDLE_CHARS + + (remainingCharacters > 0 ? remainingCharacters : 0), + ) - return ( - onSelect(item.handle)} - accessibilityLabel={`Select ${item.handle}`} - accessibilityHint=""> - - - - {displayName} - - - - @{displayHandle} + return ( + onSelect(item.handle)} + accessibilityLabel={`Select ${item.handle}`} + accessibilityHint=""> + + + + {displayName} - - ) - }) - ) : ( - - No result - - )} - - ) : null} - - ) - }, -) + + + @{displayHandle} + + + ) + }) + ) : ( + + No result + + )} + + ) : null} + + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx index 1635d17f..e6df15a1 100644 --- a/src/view/com/feeds/CustomFeed.tsx +++ b/src/view/com/feeds/CustomFeed.tsx @@ -15,120 +15,118 @@ import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' -export const CustomFeed = observer( - ({ - item, - style, - showSaveBtn = false, - showDescription = false, - showLikes = false, - }: { - item: CustomFeedModel - style?: StyleProp - showSaveBtn?: boolean - showDescription?: boolean - showLikes?: boolean - }) => { - const store = useStores() - const pal = usePalette('default') - const navigation = useNavigation() +export const CustomFeed = observer(function CustomFeedImpl({ + item, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, +}: { + item: CustomFeedModel + style?: StyleProp + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean +}) { + const store = useStores() + const pal = usePalette('default') + const navigation = useNavigation() - const onToggleSaved = React.useCallback(async () => { - if (item.isSaved) { - store.shell.openModal({ - name: 'confirm', - title: 'Remove from my feeds', - message: `Remove ${item.displayName} from my feeds?`, - onPressConfirm: async () => { - try { - await store.me.savedFeeds.unsave(item) - Toast.show('Removed from my feeds') - } catch (e) { - Toast.show('There was an issue contacting your server') - store.log.error('Failed to unsave feed', {e}) - } - }, - }) - } else { - try { - await store.me.savedFeeds.save(item) - Toast.show('Added to my feeds') - } catch (e) { - Toast.show('There was an issue contacting your server') - store.log.error('Failed to save feed', {e}) - } + const onToggleSaved = React.useCallback(async () => { + if (item.isSaved) { + store.shell.openModal({ + name: 'confirm', + title: 'Remove from my feeds', + message: `Remove ${item.displayName} from my feeds?`, + onPressConfirm: async () => { + try { + await store.me.savedFeeds.unsave(item) + Toast.show('Removed from my feeds') + } catch (e) { + Toast.show('There was an issue contacting your server') + store.log.error('Failed to unsave feed', {e}) + } + }, + }) + } else { + try { + await store.me.savedFeeds.save(item) + Toast.show('Added to my feeds') + } catch (e) { + Toast.show('There was an issue contacting your server') + store.log.error('Failed to save feed', {e}) } - }, [store, item]) + } + }, [store, item]) - return ( - { - navigation.push('CustomFeed', { - name: item.data.creator.did, - rkey: new AtUri(item.data.uri).rkey, - }) - }} - key={item.data.uri}> - - - - - - - {item.displayName} - - - by {sanitizeHandle(item.data.creator.handle, '@')} - - - {showSaveBtn && ( - - - {item.isSaved ? ( - - ) : ( - - )} - - - )} + return ( + { + navigation.push('CustomFeed', { + name: item.data.creator.did, + rkey: new AtUri(item.data.uri).rkey, + }) + }} + key={item.data.uri}> + + + - - {showDescription && item.data.description ? ( - - {item.data.description} + + + {item.displayName} - ) : null} - - {showLikes ? ( - - Liked by {item.data.likeCount || 0}{' '} - {pluralize(item.data.likeCount || 0, 'user')} + + by {sanitizeHandle(item.data.creator.handle, '@')} - ) : null} - - ) - }, -) + + {showSaveBtn && ( + + + {item.isSaved ? ( + + ) : ( + + )} + + + )} + + + {showDescription && item.data.description ? ( + + {item.data.description} + + ) : null} + + {showLikes ? ( + + Liked by {item.data.likeCount || 0}{' '} + {pluralize(item.data.likeCount || 0, 'user')} + + ) : null} + + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index d611bc50..b78cf83c 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -35,319 +35,314 @@ const EMPTY_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListItems = observer( - ({ - list, - style, - scrollElRef, - onPressTryAgain, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, - renderEmptyState, - testID, - headerOffset = 0, - }: { - list: ListModel - style?: StyleProp - scrollElRef?: MutableRefObject | null> - onPressTryAgain?: () => void - onToggleSubscribed: () => void - onPressEditList: () => void - onPressDeleteList: () => void - onPressShareList: () => void - onPressReportList: () => void - renderEmptyState?: () => JSX.Element - testID?: string - headerOffset?: number - }) => { - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) +export const ListItems = observer(function ListItemsImpl({ + list, + style, + scrollElRef, + onPressTryAgain, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + onPressShareList, + onPressReportList, + renderEmptyState, + testID, + headerOffset = 0, +}: { + list: ListModel + style?: StyleProp + scrollElRef?: MutableRefObject | null> + onPressTryAgain?: () => void + onToggleSubscribed: () => void + onPressEditList: () => void + onPressDeleteList: () => void + onPressShareList: () => void + onPressReportList: () => void + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number +}) { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let items: any[] = [HEADER_ITEM] - if (list.hasLoaded) { - if (list.hasError) { - items = items.concat([ERROR_ITEM]) - } - if (list.isEmpty) { - items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) - } - if (list.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) - } - } else if (list.isLoading) { - items = items.concat([LOADING_ITEM]) + const data = React.useMemo(() => { + let items: any[] = [HEADER_ITEM] + if (list.hasLoaded) { + if (list.hasError) { + items = items.concat([ERROR_ITEM]) } - return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) - - // events - // = - - const onRefresh = React.useCallback(async () => { - track('Lists:onRefresh') - setIsRefreshing(true) - try { - await list.refresh() - } catch (err) { - list.rootStore.log.error('Failed to refresh lists', err) + if (list.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + items = items.concat(list.items) } - setIsRefreshing(false) - }, [list, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await list.loadMore() - } catch (err) { - list.rootStore.log.error('Failed to load more lists', err) + if (list.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) } - }, [list, track]) + } else if (list.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) - const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) + // events + // = - const onPressEditMembership = React.useCallback( - (profile: AppBskyActorDefs.ProfileViewBasic) => { - store.shell.openModal({ - name: 'list-add-remove-user', - subject: profile.did, - displayName: profile.displayName || profile.handle, - onUpdate() { - list.refresh() - }, - }) - }, - [store, list], - ) + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await list.refresh() + } catch (err) { + list.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [list, track, setIsRefreshing]) - // rendering - // = + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await list.loadMore() + } catch (err) { + list.rootStore.log.error('Failed to load more lists', err) + } + }, [list, track]) - const renderMemberButton = React.useCallback( - (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { - return null + const onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) + + const onPressEditMembership = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + store.shell.openModal({ + name: 'list-add-remove-user', + subject: profile.did, + displayName: profile.displayName || profile.handle, + onUpdate() { + list.refresh() + }, + }) + }, + [store, list], + ) + + // rendering + // = + + const renderMemberButton = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + if (!list.isOwner) { + return null + } + return ( +