diff options
author | dan <dan.abramov@gmail.com> | 2023-09-08 01:36:08 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-07 17:36:08 -0700 |
commit | 8a93321fb1bd4991cbb3bd1c1f09ed2196182f93 (patch) | |
tree | 2cd7cbfa0eb98a808517c8485af3ec43c0a7ea2e /src | |
parent | 69209c988fc412a10a5028ca915f99b1d059f5ec (diff) | |
download | voidsky-8a93321fb1bd4991cbb3bd1c1f09ed2196182f93.tar.zst |
Give explicit names to MobX observer components (#1413)
* Consider observer(...) as components * Add display names to MobX observers * Temporarily suppress nested components * Suppress new false positives for react/prop-types
Diffstat (limited to 'src')
71 files changed, 2801 insertions, 2772 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index ad37aa099..09782a875 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<RootStoreModel | undefined>( undefined, ) diff --git a/src/App.web.tsx b/src/App.web.tsx index b0f949b8b..41a7189d3 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<RootStoreModel | undefined>( undefined, ) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2422491e2..dac70dfc7 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 6d3b87dd3..c74c2aa33 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 065d4d244..6ea8cd79e 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 8cf1cfaf5..1d64cc067 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() - } - }, [model, 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') - } + 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]) + } + }, [model, track]) - return ( - <LoggedOutLayout - leadin={`Step ${model.step}`} - title="Create Account" - description="We're so excited to have you join us!"> - <ScrollView testID="createAccount" style={pal.view}> - <KeyboardAvoidingView behavior="padding"> - <View style={styles.stepContainer}> - {model.step === 1 && <Step1 model={model} />} - {model.step === 2 && <Step2 model={model} />} - {model.step === 3 && <Step3 model={model} />} - </View> - <View style={[s.flexRow, s.pl20, s.pr20]}> + return ( + <LoggedOutLayout + leadin={`Step ${model.step}`} + title="Create Account" + description="We're so excited to have you join us!"> + <ScrollView testID="createAccount" style={pal.view}> + <KeyboardAvoidingView behavior="padding"> + <View style={styles.stepContainer}> + {model.step === 1 && <Step1 model={model} />} + {model.step === 2 && <Step2 model={model} />} + {model.step === 3 && <Step3 model={model} />} + </View> + <View style={[s.flexRow, s.pl20, s.pr20]}> + <TouchableOpacity + onPress={onPressBackInner} + testID="backBtn" + accessibilityRole="button"> + <Text type="xl" style={pal.link}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {model.canNext ? ( <TouchableOpacity - onPress={onPressBackInner} - testID="backBtn" + testID="nextBtn" + onPress={onPressNext} accessibilityRole="button"> - <Text type="xl" style={pal.link}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {model.canNext ? ( - <TouchableOpacity - testID="nextBtn" - onPress={onPressNext} - accessibilityRole="button"> - {model.isProcessing ? ( - <ActivityIndicator /> - ) : ( - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - )} - </TouchableOpacity> - ) : model.didServiceDescriptionFetchFail ? ( - <TouchableOpacity - testID="retryConnectBtn" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries account creation" - accessibilityLiveRegion="polite"> + {model.isProcessing ? ( + <ActivityIndicator /> + ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> - ) : model.isFetchingServiceDescription ? ( - <> - <ActivityIndicator color="#fff" /> - <Text type="xl" style={[pal.text, s.pr5]}> - Connecting... + Next </Text> - </> - ) : undefined} - </View> - <View style={s.footerSpacer} /> - </KeyboardAvoidingView> - </ScrollView> - </LoggedOutLayout> - ) - }, -) + )} + </TouchableOpacity> + ) : model.didServiceDescriptionFetchFail ? ( + <TouchableOpacity + testID="retryConnectBtn" + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel="Retry" + accessibilityHint="Retries account creation" + accessibilityLiveRegion="polite"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Retry + </Text> + </TouchableOpacity> + ) : model.isFetchingServiceDescription ? ( + <> + <ActivityIndicator color="#fff" /> + <Text type="xl" style={[pal.text, s.pr5]}> + Connecting... + </Text> + </> + ) : undefined} + </View> + <View style={s.footerSpacer} /> + </KeyboardAvoidingView> + </ScrollView> + </LoggedOutLayout> + ) +}) const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 5d3dec430..cdd5cb21d 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 5f71469f0..83b0aee40 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 f35777d27..beb756ac1 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 ( <View> diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 92d12f60b..99cdcafd0 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 d16b3213e..e5d12273a 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 ( - <View testID={`feed-${item.displayName}`}> - <View - style={[ - pal.border, - { - flex: isMobile ? 1 : undefined, - flexDirection: 'row', - gap: 18, - maxWidth: isMobile ? undefined : 670, - borderRightWidth: isMobile ? undefined : 1, - paddingHorizontal: 24, - paddingVertical: isMobile ? 12 : 24, - borderTopWidth: 1, - }, - ]}> - <View style={{marginTop: 2}}> - <UserAvatar type="algo" size={42} avatar={item.data.avatar} /> - </View> - <View style={{flex: isMobile ? 1 : undefined}}> + } + return ( + <View testID={`feed-${item.displayName}`}> + <View + style={[ + pal.border, + { + flex: isMobile ? 1 : undefined, + flexDirection: 'row', + gap: 18, + maxWidth: isMobile ? undefined : 670, + borderRightWidth: isMobile ? undefined : 1, + paddingHorizontal: 24, + paddingVertical: isMobile ? 12 : 24, + borderTopWidth: 1, + }, + ]}> + <View style={{marginTop: 2}}> + <UserAvatar type="algo" size={42} avatar={item.data.avatar} /> + </View> + <View style={{flex: isMobile ? 1 : undefined}}> + <Text + type="2xl-bold" + numberOfLines={1} + style={[pal.text, {fontSize: 19}]}> + {item.displayName} + </Text> + + <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> + by {sanitizeHandle(item.data.creator.handle, '@')} + </Text> + + {item.data.description ? ( <Text - type="2xl-bold" - numberOfLines={1} - style={[pal.text, {fontSize: 19}]}> - {item.displayName} + type="xl" + style={[ + pal.text, + { + flex: isMobile ? 1 : undefined, + maxWidth: 550, + marginBottom: 18, + }, + ]} + numberOfLines={6}> + {item.data.description} </Text> + ) : null} - <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.data.creator.handle, '@')} - </Text> + <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> + <Button + type="inverted" + style={{paddingVertical: 6}} + onPress={onToggle}> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + paddingRight: 2, + gap: 6, + }}> + {item.isSaved ? ( + <> + <FontAwesomeIcon + icon="check" + size={16} + color={pal.colors.textInverted} + /> + <Text type="lg-medium" style={pal.textInverted}> + Added + </Text> + </> + ) : ( + <> + <FontAwesomeIcon + icon="plus" + size={16} + color={pal.colors.textInverted} + /> + <Text type="lg-medium" style={pal.textInverted}> + Add + </Text> + </> + )} + </View> + </Button> - {item.data.description ? ( - <Text - type="xl" - style={[ - pal.text, - { - flex: isMobile ? 1 : undefined, - maxWidth: 550, - marginBottom: 18, - }, - ]} - numberOfLines={6}> - {item.data.description} + <View style={{flexDirection: 'row', gap: 4}}> + <HeartIcon + size={16} + strokeWidth={2.5} + style={[pal.textLight, {position: 'relative', top: 2}]} + /> + <Text type="lg-medium" style={[pal.text, pal.textLight]}> + {item.data.likeCount || 0} </Text> - ) : null} - - <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> - <Button - type="inverted" - style={{paddingVertical: 6}} - onPress={onToggle}> - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - paddingRight: 2, - gap: 6, - }}> - {item.isSaved ? ( - <> - <FontAwesomeIcon - icon="check" - size={16} - color={pal.colors.textInverted} - /> - <Text type="lg-medium" style={pal.textInverted}> - Added - </Text> - </> - ) : ( - <> - <FontAwesomeIcon - icon="plus" - size={16} - color={pal.colors.textInverted} - /> - <Text type="lg-medium" style={pal.textInverted}> - Add - </Text> - </> - )} - </View> - </Button> - - <View style={{flexDirection: 'row', gap: 4}}> - <HeartIcon - size={16} - strokeWidth={2.5} - style={[pal.textLight, {position: 'relative', top: 2}]} - /> - <Text type="lg-medium" style={[pal.text, pal.textLight]}> - {item.data.likeCount || 0} - </Text> - </View> </View> </View> </View> </View> - ) - }, -) + </View> + ) +}) diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index 7b7555ace..c066e9bd5 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 0f627ad0b..19c8d52d0 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 c81c2d5df..25d12165f 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 = <P extends object>( Component: React.ComponentType<P>, ): React.FC<P> => - observer((props: P) => { + observer(function AuthRequired(props: P) { const store = useStores() if (store.session.isResumingSession) { return <Loading /> diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index d5465f79a..fa3f29cf2 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 c9b8b84b1..d808d896f 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 ( - <Animated.View style={topAnimStyle}> - {view.isActive ? ( - <View style={[pal.view, styles.container, pal.border]}> - {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 ( + <Animated.View style={topAnimStyle}> + {view.isActive ? ( + <View style={[pal.view, styles.container, pal.border]}> + {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 ( - <TouchableOpacity - testID="autocompleteButton" - key={item.handle} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)} - accessibilityLabel={`Select ${item.handle}`} - accessibilityHint=""> - <View style={styles.avatarAndHandle}> - <UserAvatar avatar={item.avatar ?? null} size={24} /> - <Text type="md-medium" style={pal.text}> - {displayName} - </Text> - </View> - <Text type="sm" style={pal.textLight} numberOfLines={1}> - @{displayHandle} + return ( + <TouchableOpacity + testID="autocompleteButton" + key={item.handle} + style={[pal.border, styles.item]} + onPress={() => onSelect(item.handle)} + accessibilityLabel={`Select ${item.handle}`} + accessibilityHint=""> + <View style={styles.avatarAndHandle}> + <UserAvatar avatar={item.avatar ?? null} size={24} /> + <Text type="md-medium" style={pal.text}> + {displayName} </Text> - </TouchableOpacity> - ) - }) - ) : ( - <Text type="sm" style={[pal.text, pal.border, styles.noResults]}> - No result - </Text> - )} - </View> - ) : null} - </Animated.View> - ) - }, -) + </View> + <Text type="sm" style={pal.textLight} numberOfLines={1}> + @{displayHandle} + </Text> + </TouchableOpacity> + ) + }) + ) : ( + <Text type="sm" style={[pal.text, pal.border, styles.noResults]}> + No result + </Text> + )} + </View> + ) : null} + </Animated.View> + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx index 1635d17fc..e6df15a15 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<ViewStyle> - showSaveBtn?: boolean - showDescription?: boolean - showLikes?: boolean - }) => { - const store = useStores() - const pal = usePalette('default') - const navigation = useNavigation<NavigationProp>() +export const CustomFeed = observer(function CustomFeedImpl({ + item, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, +}: { + item: CustomFeedModel + style?: StyleProp<ViewStyle> + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean +}) { + const store = useStores() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() - 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 ( - <Pressable - testID={`feed-${item.displayName}`} - accessibilityRole="button" - style={[styles.container, pal.border, style]} - onPress={() => { - navigation.push('CustomFeed', { - name: item.data.creator.did, - rkey: new AtUri(item.data.uri).rkey, - }) - }} - key={item.data.uri}> - <View style={[styles.headerContainer]}> - <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={item.data.avatar} /> - </View> - <View style={[styles.headerTextContainer]}> - <Text style={[pal.text, s.bold]} numberOfLines={3}> - {item.displayName} - </Text> - <Text style={[pal.textLight]} numberOfLines={3}> - by {sanitizeHandle(item.data.creator.handle, '@')} - </Text> - </View> - {showSaveBtn && ( - <View> - <Pressable - accessibilityRole="button" - accessibilityLabel={ - item.isSaved ? 'Remove from my feeds' : 'Add to my feeds' - } - accessibilityHint="" - onPress={onToggleSaved} - hitSlop={15} - style={styles.btn}> - {item.isSaved ? ( - <FontAwesomeIcon - icon={['far', 'trash-can']} - size={19} - color={pal.colors.icon} - /> - ) : ( - <FontAwesomeIcon - icon="plus" - size={18} - color={pal.colors.link} - /> - )} - </Pressable> - </View> - )} + return ( + <Pressable + testID={`feed-${item.displayName}`} + accessibilityRole="button" + style={[styles.container, pal.border, style]} + onPress={() => { + navigation.push('CustomFeed', { + name: item.data.creator.did, + rkey: new AtUri(item.data.uri).rkey, + }) + }} + key={item.data.uri}> + <View style={[styles.headerContainer]}> + <View style={[s.mr10]}> + <UserAvatar type="algo" size={36} avatar={item.data.avatar} /> </View> - - {showDescription && item.data.description ? ( - <Text style={[pal.textLight, styles.description]} numberOfLines={3}> - {item.data.description} + <View style={[styles.headerTextContainer]}> + <Text style={[pal.text, s.bold]} numberOfLines={3}> + {item.displayName} </Text> - ) : null} - - {showLikes ? ( - <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {item.data.likeCount || 0}{' '} - {pluralize(item.data.likeCount || 0, 'user')} + <Text style={[pal.textLight]} numberOfLines={3}> + by {sanitizeHandle(item.data.creator.handle, '@')} </Text> - ) : null} - </Pressable> - ) - }, -) + </View> + {showSaveBtn && ( + <View> + <Pressable + accessibilityRole="button" + accessibilityLabel={ + item.isSaved ? 'Remove from my feeds' : 'Add to my feeds' + } + accessibilityHint="" + onPress={onToggleSaved} + hitSlop={15} + style={styles.btn}> + {item.isSaved ? ( + <FontAwesomeIcon + icon={['far', 'trash-can']} + size={19} + color={pal.colors.icon} + /> + ) : ( + <FontAwesomeIcon + icon="plus" + size={18} + color={pal.colors.link} + /> + )} + </Pressable> + </View> + )} + </View> + + {showDescription && item.data.description ? ( + <Text style={[pal.textLight, styles.description]} numberOfLines={3}> + {item.data.description} + </Text> + ) : null} + + {showLikes ? ( + <Text type="sm-medium" style={[pal.text, pal.textLight]}> + Liked by {item.data.likeCount || 0}{' '} + {pluralize(item.data.likeCount || 0, 'user')} + </Text> + ) : null} + </Pressable> + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index d611bc504..b78cf83cf 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<ViewStyle> - scrollElRef?: MutableRefObject<FlatList<any> | 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<ViewStyle> + scrollElRef?: MutableRefObject<FlatList<any> | 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, - ]) + 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]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) - // events - // = + // 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) - } - setIsRefreshing(false) - }, [list, track, setIsRefreshing]) + 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]) - 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 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 onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) - 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], + ) - 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 + // = - // rendering - // = + const renderMemberButton = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + if (!list.isOwner) { + return null + } + return ( + <Button + type="default" + label="Edit" + onPress={() => onPressEditMembership(profile)} + /> + ) + }, + [list, onPressEditMembership], + ) - const renderMemberButton = React.useCallback( - (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { - return null + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY_ITEM) { + if (renderEmptyState) { + return renderEmptyState() } + return <View /> + } else if (item === HEADER_ITEM) { + return list.list ? ( + <ListHeader + list={list.list} + isOwner={list.isOwner} + onToggleSubscribed={onToggleSubscribed} + onPressEditList={onPressEditList} + onPressDeleteList={onPressDeleteList} + onPressShareList={onPressShareList} + onPressReportList={onPressReportList} + /> + ) : null + } else if (item === ERROR_ITEM) { return ( - <Button - type="default" - label="Edit" - onPress={() => onPressEditMembership(profile)} + <ErrorMessage + message={list.error} + onPressTryAgain={onPressTryAgain} /> ) - }, - [list, onPressEditMembership], - ) - - const renderItem = React.useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> - } else if (item === HEADER_ITEM) { - return list.list ? ( - <ListHeader - list={list.list} - isOwner={list.isOwner} - onToggleSubscribed={onToggleSubscribed} - onPressEditList={onPressEditList} - onPressDeleteList={onPressDeleteList} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - /> - ) : null - } else if (item === ERROR_ITEM) { - return ( - <ErrorMessage - message={list.error} - onPressTryAgain={onPressTryAgain} - /> - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label="There was an issue fetching the list. Tap here to try again." - onPress={onPressRetryLoadMore} - /> - ) - } else if (item === LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } + } else if (item === LOAD_MORE_ERROR_ITEM) { return ( - <ProfileCard - testID={`user-${ - (item as AppBskyGraphDefs.ListItemView).subject.handle - }`} - profile={(item as AppBskyGraphDefs.ListItemView).subject} - renderButton={renderMemberButton} + <LoadMoreRetryBtn + label="There was an issue fetching the list. Tap here to try again." + onPress={onPressRetryLoadMore} /> ) - }, - [ - renderMemberButton, - renderEmptyState, - list.list, - list.isOwner, - list.error, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, - onPressTryAgain, - onPressRetryLoadMore, - ], - ) + } else if (item === LOADING_ITEM) { + return <ProfileCardFeedLoadingPlaceholder /> + } + return ( + <ProfileCard + testID={`user-${ + (item as AppBskyGraphDefs.ListItemView).subject.handle + }`} + profile={(item as AppBskyGraphDefs.ListItemView).subject} + renderButton={renderMemberButton} + /> + ) + }, + [ + renderMemberButton, + renderEmptyState, + list.list, + list.isOwner, + list.error, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + onPressShareList, + onPressReportList, + onPressTryAgain, + onPressRetryLoadMore, + ], + ) - const Footer = React.useCallback( - () => - list.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ), - [list], - ) + const Footer = React.useCallback( + () => + list.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [list], + ) - return ( - <View testID={testID} style={style}> - {data.length > 0 && ( - <FlatList - testID={testID ? `${testID}-flatlist` : undefined} - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } - contentContainerStyle={s.contentContainer} - style={{paddingTop: headerOffset}} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </View> - ) - }, -) + return ( + <View testID={testID} style={style}> + {data.length > 0 && ( + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={data} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + ListFooterComponent={Footer} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={s.contentContainer} + style={{paddingTop: headerOffset}} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) +}) -const ListHeader = observer( - ({ - list, - isOwner, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, - }: { - list: AppBskyGraphDefs.ListView - isOwner: boolean - onToggleSubscribed: () => void - onPressEditList: () => void - onPressDeleteList: () => void - onPressShareList: () => void - onPressReportList: () => void - }) => { - const pal = usePalette('default') - const store = useStores() - const {isDesktop} = useWebMediaQueries() - const descriptionRT = React.useMemo( - () => - list?.description && - new RichText({text: list.description, facets: list.descriptionFacets}), - [list], - ) - return ( - <> - <View style={[styles.header, pal.border]}> - <View style={s.flex1}> - <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> - {list.name} +const ListHeader = observer(function ListHeaderImpl({ + list, + isOwner, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + onPressShareList, + onPressReportList, +}: { + list: AppBskyGraphDefs.ListView + isOwner: boolean + onToggleSubscribed: () => void + onPressEditList: () => void + onPressDeleteList: () => void + onPressShareList: () => void + onPressReportList: () => void +}) { + const pal = usePalette('default') + const store = useStores() + const {isDesktop} = useWebMediaQueries() + const descriptionRT = React.useMemo( + () => + list?.description && + new RichText({text: list.description, facets: list.descriptionFacets}), + [list], + ) + return ( + <> + <View style={[styles.header, pal.border]}> + <View style={s.flex1}> + <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> + {list.name} + </Text> + {list && ( + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} + by{' '} + {list.creator.did === store.me.did ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(list.creator.handle, '@')} + href={makeProfileLink(list.creator)} + style={pal.textLight} + /> + )} </Text> - {list && ( - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} - by{' '} - {list.creator.did === store.me.did ? ( - 'you' - ) : ( - <TextLink - text={sanitizeHandle(list.creator.handle, '@')} - href={makeProfileLink(list.creator)} - style={pal.textLight} - /> - )} - </Text> - )} - {descriptionRT && ( - <RichTextCom - testID="listDescription" - style={[pal.text, styles.headerDescription]} - richText={descriptionRT} - /> - )} - {isDesktop && ( - <ListActions - isOwner={isOwner} - muted={list.viewer?.muted} - onPressDeleteList={onPressDeleteList} - onPressEditList={onPressEditList} - onToggleSubscribed={onToggleSubscribed} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - /> - )} - </View> - <View> - <UserAvatar type="list" avatar={list.avatar} size={64} /> - </View> + )} + {descriptionRT && ( + <RichTextCom + testID="listDescription" + style={[pal.text, styles.headerDescription]} + richText={descriptionRT} + /> + )} + {isDesktop && ( + <ListActions + isOwner={isOwner} + muted={list.viewer?.muted} + onPressDeleteList={onPressDeleteList} + onPressEditList={onPressEditList} + onToggleSubscribed={onToggleSubscribed} + onPressShareList={onPressShareList} + onPressReportList={onPressReportList} + /> + )} </View> - <View - style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}> - <View - style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> - <Text type="md-medium" style={[pal.text]}> - Muted users - </Text> - </View> + <View> + <UserAvatar type="list" avatar={list.avatar} size={64} /> </View> - </> - ) - }, -) + </View> + <View + style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}> + <View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> + <Text type="md-medium" style={[pal.text]}> + Muted users + </Text> + </View> + </View> + </> + ) +}) const styles = StyleSheet.create({ header: { diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index fb07ee0b8..4c8befa1f 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -30,173 +30,171 @@ const EMPTY_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListsList = observer( - ({ - listsList, - showAddBtns, - style, - scrollElRef, - onPressTryAgain, - onPressCreateNew, - renderItem, - renderEmptyState, - testID, - headerOffset = 0, - }: { - listsList: ListsListModel - showAddBtns?: boolean - style?: StyleProp<ViewStyle> - scrollElRef?: MutableRefObject<FlatList<any> | null> - onPressCreateNew: () => void - onPressTryAgain?: () => void - renderItem?: (list: GraphDefs.ListView) => JSX.Element - renderEmptyState?: () => JSX.Element - testID?: string - headerOffset?: number - }) => { - const pal = usePalette('default') - const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) +export const ListsList = observer(function ListsListImpl({ + listsList, + showAddBtns, + style, + scrollElRef, + onPressTryAgain, + onPressCreateNew, + renderItem, + renderEmptyState, + testID, + headerOffset = 0, +}: { + listsList: ListsListModel + showAddBtns?: boolean + style?: StyleProp<ViewStyle> + scrollElRef?: MutableRefObject<FlatList<any> | null> + onPressCreateNew: () => void + onPressTryAgain?: () => void + renderItem?: (list: GraphDefs.ListView) => JSX.Element + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let items: any[] = [] - if (listsList.hasLoaded) { - if (listsList.hasError) { - items = items.concat([ERROR_ITEM]) - } - if (listsList.isEmpty) { - items = items.concat([EMPTY_ITEM]) - } else { - if (showAddBtns) { - items = items.concat([CREATENEW_ITEM]) - } - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + const data = React.useMemo(() => { + let items: any[] = [] + if (listsList.hasLoaded) { + if (listsList.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (listsList.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + if (showAddBtns) { + items = items.concat([CREATENEW_ITEM]) } - } else if (listsList.isLoading) { - items = items.concat([LOADING_ITEM]) + items = items.concat(listsList.lists) + } + if (listsList.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) } - return items - }, [ - listsList.hasError, - listsList.hasLoaded, - listsList.isLoading, - listsList.isEmpty, - listsList.lists, - listsList.loadMoreError, - showAddBtns, - ]) + } else if (listsList.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + listsList.hasError, + listsList.hasLoaded, + listsList.isLoading, + listsList.isEmpty, + listsList.lists, + listsList.loadMoreError, + showAddBtns, + ]) - // events - // = + // events + // = - const onRefresh = React.useCallback(async () => { - track('Lists:onRefresh') - setIsRefreshing(true) - try { - await listsList.refresh() - } catch (err) { - listsList.rootStore.log.error('Failed to refresh lists', err) - } - setIsRefreshing(false) - }, [listsList, track, setIsRefreshing]) + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await listsList.refresh() + } catch (err) { + listsList.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [listsList, track, setIsRefreshing]) - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await listsList.loadMore() - } catch (err) { - listsList.rootStore.log.error('Failed to load more lists', err) - } - }, [listsList, track]) + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await listsList.loadMore() + } catch (err) { + listsList.rootStore.log.error('Failed to load more lists', err) + } + }, [listsList, track]) - const onPressRetryLoadMore = React.useCallback(() => { - listsList.retryLoadMore() - }, [listsList]) + const onPressRetryLoadMore = React.useCallback(() => { + listsList.retryLoadMore() + }, [listsList]) - // rendering - // = + // rendering + // = - const renderItemInner = React.useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> - } else if (item === CREATENEW_ITEM) { - return <CreateNewItem onPress={onPressCreateNew} /> - } else if (item === ERROR_ITEM) { - return ( - <ErrorMessage - message={listsList.error} - onPressTryAgain={onPressTryAgain} - /> - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." - onPress={onPressRetryLoadMore} - /> - ) - } else if (item === LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY_ITEM) { + if (renderEmptyState) { + return renderEmptyState() } - return renderItem ? ( - renderItem(item) - ) : ( - <ListCard - list={item} - testID={`list-${item.name}`} - style={styles.item} + return <View /> + } else if (item === CREATENEW_ITEM) { + return <CreateNewItem onPress={onPressCreateNew} /> + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={listsList.error} + onPressTryAgain={onPressTryAgain} /> ) - }, - [ - listsList, - onPressTryAgain, - onPressRetryLoadMore, - onPressCreateNew, - renderItem, - renderEmptyState, - ], - ) - - return ( - <View testID={testID} style={style}> - {data.length > 0 && ( - <FlatList - testID={testID ? `${testID}-flatlist` : undefined} - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItemInner} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } - contentContainerStyle={[s.contentContainer]} - style={{paddingTop: headerOffset}} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} - // @ts-ignore our .web version only -prf - desktopFixedHeight + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} /> - )} - </View> - ) - }, -) + ) + } else if (item === LOADING_ITEM) { + return <ProfileCardFeedLoadingPlaceholder /> + } + return renderItem ? ( + renderItem(item) + ) : ( + <ListCard + list={item} + testID={`list-${item.name}`} + style={styles.item} + /> + ) + }, + [ + listsList, + onPressTryAgain, + onPressRetryLoadMore, + onPressCreateNew, + renderItem, + renderEmptyState, + ], + ) + + return ( + <View testID={testID} style={style}> + {data.length > 0 && ( + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={data} + keyExtractor={item => item._reactKey} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={[s.contentContainer]} + style={{paddingTop: headerOffset}} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) +}) function CreateNewItem({onPress}: {onPress: () => void}) { const pal = usePalette('default') diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 588b21353..d2bf278f5 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -17,159 +17,161 @@ import * as Toast from '../util/Toast' export const snapPoints = ['90%'] -export const Component = observer(({}: {}) => { - const store = useStores() - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') +export const Component = observer( + function ContentFilteringSettingsImpl({}: {}) { + const store = useStores() + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') - React.useEffect(() => { - store.preferences.sync() - }, [store]) + React.useEffect(() => { + store.preferences.sync() + }, [store]) - const onToggleAdultContent = React.useCallback(async () => { - if (isIOS) { - return - } - try { - await store.preferences.setAdultContentEnabled( - !store.preferences.adultContentEnabled, - ) - } catch (e) { - Toast.show('There was an issue syncing your preferences with the server') - store.log.error('Failed to update preferences with server', {e}) - } - }, [store]) + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) { + return + } + try { + await store.preferences.setAdultContentEnabled( + !store.preferences.adultContentEnabled, + ) + } catch (e) { + Toast.show( + 'There was an issue syncing your preferences with the server', + ) + store.log.error('Failed to update preferences with server', {e}) + } + }, [store]) - const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + const onPressDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) - return ( - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}>Content Filtering</Text> - <ScrollView style={styles.scrollContainer}> - <View style={s.mb10}> - {isIOS ? ( - store.preferences.adultContentEnabled ? null : ( - <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . - </Text> - ) - ) : ( - <ToggleButton - type="default-light" - label="Enable Adult Content" - isSelected={store.preferences.adultContentEnabled} - onPress={onToggleAdultContent} - style={styles.toggleBtn} - /> - )} + return ( + <View testID="contentFilteringModal" style={[pal.view, styles.container]}> + <Text style={[pal.text, styles.title]}>Content Filtering</Text> + <ScrollView style={styles.scrollContainer}> + <View style={s.mb10}> + {isIOS ? ( + store.preferences.adultContentEnabled ? null : ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Text> + ) + ) : ( + <ToggleButton + type="default-light" + label="Enable Adult Content" + isSelected={store.preferences.adultContentEnabled} + onPress={onToggleAdultContent} + style={styles.toggleBtn} + /> + )} + </View> + <ContentLabelPref + group="nsfw" + disabled={!store.preferences.adultContentEnabled} + /> + <ContentLabelPref + group="nudity" + disabled={!store.preferences.adultContentEnabled} + /> + <ContentLabelPref + group="suggestive" + disabled={!store.preferences.adultContentEnabled} + /> + <ContentLabelPref + group="gore" + disabled={!store.preferences.adultContentEnabled} + /> + <ContentLabelPref group="hate" /> + <ContentLabelPref group="spam" /> + <ContentLabelPref group="impersonation" /> + <View style={{height: isMobile ? 60 : 0}} /> + </ScrollView> + <View + style={[ + styles.btnContainer, + isMobile && styles.btnContainerMobile, + pal.borderDark, + ]}> + <Pressable + testID="sendReportBtn" + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel="Done" + accessibilityHint=""> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </LinearGradient> + </Pressable> </View> - <ContentLabelPref - group="nsfw" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="nudity" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="suggestive" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="gore" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref group="hate" /> - <ContentLabelPref group="spam" /> - <ContentLabelPref group="impersonation" /> - <View style={{height: isMobile ? 60 : 0}} /> - </ScrollView> - <View - style={[ - styles.btnContainer, - isMobile && styles.btnContainerMobile, - pal.borderDark, - ]}> - <Pressable - testID="sendReportBtn" - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel="Done" - accessibilityHint=""> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> - </LinearGradient> - </Pressable> </View> - </View> - ) -}) + ) + }, +) // TODO: Refactor this component to pass labels down to each tab -const ContentLabelPref = observer( - ({ - group, - disabled, - }: { - group: keyof typeof CONFIGURABLE_LABEL_GROUPS - disabled?: boolean - }) => { - const store = useStores() - const pal = usePalette('default') +const ContentLabelPref = observer(function ContentLabelPrefImpl({ + group, + disabled, +}: { + group: keyof typeof CONFIGURABLE_LABEL_GROUPS + disabled?: boolean +}) { + const store = useStores() + const pal = usePalette('default') - const onChange = React.useCallback( - async (v: LabelPreference) => { - try { - await store.preferences.setContentLabelPref(group, v) - } catch (e) { - Toast.show( - 'There was an issue syncing your preferences with the server', - ) - store.log.error('Failed to update preferences with server', {e}) - } - }, - [store, group], - ) + const onChange = React.useCallback( + async (v: LabelPreference) => { + try { + await store.preferences.setContentLabelPref(group, v) + } catch (e) { + Toast.show( + 'There was an issue syncing your preferences with the server', + ) + store.log.error('Failed to update preferences with server', {e}) + } + }, + [store, group], + ) - return ( - <View style={[styles.contentLabelPref, pal.border]}> - <View style={s.flex1}> - <Text type="md-medium" style={[pal.text]}> - {CONFIGURABLE_LABEL_GROUPS[group].title} - </Text> - {typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( - <Text type="sm" style={[pal.textLight]}> - {CONFIGURABLE_LABEL_GROUPS[group].subtitle} - </Text> - )} - </View> - {disabled ? ( - <Text type="sm-bold" style={pal.textLight}> - Hide + return ( + <View style={[styles.contentLabelPref, pal.border]}> + <View style={s.flex1}> + <Text type="md-medium" style={[pal.text]}> + {CONFIGURABLE_LABEL_GROUPS[group].title} + </Text> + {typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( + <Text type="sm" style={[pal.textLight]}> + {CONFIGURABLE_LABEL_GROUPS[group].subtitle} </Text> - ) : ( - <SelectGroup - current={store.preferences.contentLabels[group]} - onChange={onChange} - group={group} - /> )} </View> - ) - }, -) + {disabled ? ( + <Text type="sm-bold" style={pal.textLight}> + Hide + </Text> + ) : ( + <SelectGroup + current={store.preferences.contentLabels[group]} + onChange={onChange} + group={group} + /> + )} + </View> + ) +}) interface SelectGroupProps { current: LabelPreference diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index e4cfbac35..dcb6668c7 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -46,7 +46,10 @@ interface Props { gallery: GalleryModel } -export const Component = observer(function ({image, gallery}: Props) { +export const Component = observer(function EditImageImpl({ + image, + gallery, +}: Props) { const pal = usePalette('default') const theme = useTheme() const store = useStores() diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index ba3cc382b..33ffc86a2 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -79,50 +79,56 @@ export function Component({}: {}) { ) } -const InviteCode = observer( - ({testID, code, used}: {testID: string; code: string; used?: boolean}) => { - const pal = usePalette('default') - const store = useStores() - const {invitesAvailable} = store.me +const InviteCode = observer(function InviteCodeImpl({ + testID, + code, + used, +}: { + testID: string + code: string + used?: boolean +}) { + const pal = usePalette('default') + const store = useStores() + const {invitesAvailable} = store.me - const onPress = React.useCallback(() => { - Clipboard.setString(code) - Toast.show('Copied to clipboard') - store.invitedUsers.setInviteCopied(code) - }, [store, code]) + const onPress = React.useCallback(() => { + Clipboard.setString(code) + Toast.show('Copied to clipboard') + store.invitedUsers.setInviteCopied(code) + }, [store, code]) - return ( - <TouchableOpacity - testID={testID} - style={[styles.inviteCode, pal.border]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <Text - testID={`${testID}-code`} - type={used ? 'md' : 'md-bold'} - style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> - {code} - </Text> - <View style={styles.flex1} /> - {!used && store.invitedUsers.isInviteCopied(code) && ( - <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> - )} - {!used && ( - <FontAwesomeIcon - icon={['far', 'clone']} - style={pal.text as FontAwesomeIconStyle} - /> - )} - </TouchableOpacity> - ) - }, -) + return ( + <TouchableOpacity + testID={testID} + style={[styles.inviteCode, pal.border]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <Text + testID={`${testID}-code`} + type={used ? 'md' : 'md-bold'} + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> + {code} + </Text> + <View style={styles.flex1} /> + {!used && store.invitedUsers.isInviteCopied(code) && ( + <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> + )} + {!used && ( + <FontAwesomeIcon + icon={['far', 'clone']} + style={pal.text as FontAwesomeIconStyle} + /> + )} + </TouchableOpacity> + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx index e00509285..58d6a529c 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -24,210 +24,207 @@ import isEqual from 'lodash.isequal' export const snapPoints = ['fullscreen'] -export const Component = observer( - ({ - subject, - displayName, - onUpdate, - }: { - subject: string - displayName: string - onUpdate?: () => void - }) => { - const store = useStores() - const pal = usePalette('default') - const palPrimary = usePalette('primary') - const palInverted = usePalette('inverted') - const [originalSelections, setOriginalSelections] = React.useState< - string[] - >([]) - const [selected, setSelected] = React.useState<string[]>([]) - const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) +export const Component = observer(function ListAddRemoveUserImpl({ + subject, + displayName, + onUpdate, +}: { + subject: string + displayName: string + onUpdate?: () => void +}) { + const store = useStores() + const pal = usePalette('default') + const palPrimary = usePalette('primary') + const palInverted = usePalette('inverted') + const [originalSelections, setOriginalSelections] = React.useState<string[]>( + [], + ) + const [selected, setSelected] = React.useState<string[]>([]) + const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) - const listsList: ListsListModel = React.useMemo( - () => new ListsListModel(store, store.me.did), - [store], - ) - const memberships: ListMembershipModel = React.useMemo( - () => new ListMembershipModel(store, subject), - [store, subject], + const listsList: ListsListModel = React.useMemo( + () => new ListsListModel(store, store.me.did), + [store], + ) + const memberships: ListMembershipModel = React.useMemo( + () => new ListMembershipModel(store, subject), + [store, subject], + ) + React.useEffect(() => { + listsList.refresh() + memberships.fetch().then( + () => { + const ids = memberships.memberships.map(m => m.value.list) + setOriginalSelections(ids) + setSelected(ids) + setMembershipsLoaded(true) + }, + err => { + store.log.error('Failed to fetch memberships', {err}) + }, ) - React.useEffect(() => { - listsList.refresh() - memberships.fetch().then( - () => { - const ids = memberships.memberships.map(m => m.value.list) - setOriginalSelections(ids) - setSelected(ids) - setMembershipsLoaded(true) - }, - err => { - store.log.error('Failed to fetch memberships', {err}) - }, - ) - }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) + }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) - const onPressCancel = useCallback(() => { - store.shell.closeModal() - }, [store]) + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) - const onPressSave = useCallback(async () => { - try { - await memberships.updateTo(selected) - } catch (err) { - store.log.error('Failed to update memberships', {err}) - return - } - Toast.show('Lists updated') - onUpdate?.() - store.shell.closeModal() - }, [store, selected, memberships, onUpdate]) - - const onPressNewMuteList = useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - onSave: (_uri: string) => { - listsList.refresh() - }, - }) - }, [store, listsList]) + const onPressSave = useCallback(async () => { + try { + await memberships.updateTo(selected) + } catch (err) { + store.log.error('Failed to update memberships', {err}) + return + } + Toast.show('Lists updated') + onUpdate?.() + store.shell.closeModal() + }, [store, selected, memberships, onUpdate]) - const onToggleSelected = useCallback( - (uri: string) => { - if (selected.includes(uri)) { - setSelected(selected.filter(uri2 => uri2 !== uri)) - } else { - setSelected([...selected, uri]) - } + const onPressNewMuteList = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (_uri: string) => { + listsList.refresh() }, - [selected, setSelected], - ) + }) + }, [store, listsList]) - const renderItem = useCallback( - (list: GraphDefs.ListView) => { - const isSelected = selected.includes(list.uri) - return ( - <Pressable - testID={`toggleBtn-${list.name}`} - style={[ - styles.listItem, - pal.border, - {opacity: membershipsLoaded ? 1 : 0.5}, - ]} - accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ - list.name - }`} - accessibilityHint="" - disabled={!membershipsLoaded} - onPress={() => onToggleSelected(list.uri)}> - <View style={styles.listItemAvi}> - <UserAvatar size={40} avatar={list.avatar} /> - </View> - <View style={styles.listItemContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName(list.name)} - </Text> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '} - by{' '} - {list.creator.did === store.me.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} - </Text> - </View> - {membershipsLoaded && ( - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> - )} - </Pressable> - ) - }, - [ - pal, - palPrimary, - palInverted, - onToggleSelected, - selected, - store.me.did, - membershipsLoaded, - ], - ) + const onToggleSelected = useCallback( + (uri: string) => { + if (selected.includes(uri)) { + setSelected(selected.filter(uri2 => uri2 !== uri)) + } else { + setSelected([...selected, uri]) + } + }, + [selected, setSelected], + ) - const renderEmptyState = React.useCallback(() => { + const renderItem = useCallback( + (list: GraphDefs.ListView) => { + const isSelected = selected.includes(list.uri) return ( - <EmptyStateWithButton - icon="users-slash" - message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." - buttonLabel="New Mute List" - onPress={onPressNewMuteList} - /> + <Pressable + testID={`toggleBtn-${list.name}`} + style={[ + styles.listItem, + pal.border, + {opacity: membershipsLoaded ? 1 : 0.5}, + ]} + accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ + list.name + }`} + accessibilityHint="" + disabled={!membershipsLoaded} + onPress={() => onToggleSelected(list.uri)}> + <View style={styles.listItemAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.listItemContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName(list.name)} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.creator.did === store.me.did + ? 'you' + : sanitizeHandle(list.creator.handle, '@')} + </Text> + </View> + {membershipsLoaded && ( + <View + style={ + isSelected + ? [styles.checkbox, palPrimary.border, palPrimary.view] + : [styles.checkbox, pal.borderDark] + }> + {isSelected && ( + <FontAwesomeIcon + icon="check" + style={palInverted.text as FontAwesomeIconStyle} + /> + )} + </View> + )} + </Pressable> ) - }, [onPressNewMuteList]) - - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection - const canSaveChanges = - !listsList.isEmpty && !isEqual(selected, originalSelections) + }, + [ + pal, + palPrimary, + palInverted, + onToggleSelected, + selected, + store.me.did, + membershipsLoaded, + ], + ) + const renderEmptyState = React.useCallback(() => { return ( - <View testID="listAddRemoveUserModal" style={s.hContentRegion}> - <Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> - <ListsList - listsList={listsList} - showAddBtns - onPressCreateNew={onPressNewMuteList} - renderItem={renderItem} - renderEmptyState={renderEmptyState} - style={[styles.list, pal.border]} + <EmptyStateWithButton + icon="users-slash" + message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." + buttonLabel="New Mute List" + onPress={onPressNewMuteList} + /> + ) + }, [onPressNewMuteList]) + + // Only show changes button if there are some items on the list to choose from AND user has made changes in selection + const canSaveChanges = + !listsList.isEmpty && !isEqual(selected, originalSelections) + + return ( + <View testID="listAddRemoveUserModal" style={s.hContentRegion}> + <Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> + <ListsList + listsList={listsList} + showAddBtns + onPressCreateNew={onPressNewMuteList} + renderItem={renderItem} + renderEmptyState={renderEmptyState} + style={[styles.list, pal.border]} + /> + <View style={[styles.btns, pal.border]}> + <Button + testID="cancelBtn" + type="default" + onPress={onPressCancel} + style={styles.footerBtn} + accessibilityLabel="Cancel" + accessibilityHint="" + onAccessibilityEscape={onPressCancel} + label="Cancel" /> - <View style={[styles.btns, pal.border]}> + {canSaveChanges && ( <Button - testID="cancelBtn" - type="default" - onPress={onPressCancel} + testID="saveBtn" + type="primary" + onPress={onPressSave} style={styles.footerBtn} - accessibilityLabel="Cancel" + accessibilityLabel="Save changes" accessibilityHint="" - onAccessibilityEscape={onPressCancel} - label="Cancel" + onAccessibilityEscape={onPressSave} + label="Save Changes" /> - {canSaveChanges && ( - <Button - testID="saveBtn" - type="primary" - onPress={onPressSave} - style={styles.footerBtn} - accessibilityLabel="Save changes" - accessibilityHint="" - onAccessibilityEscape={onPressSave} - label="Save Changes" - /> - )} + )} - {(listsList.isLoading || !membershipsLoaded) && ( - <View style={styles.loadingContainer}> - <ActivityIndicator /> - </View> - )} - </View> + {(listsList.isLoading || !membershipsLoaded) && ( + <View style={styles.loadingContainer}> + <ActivityIndicator /> + </View> + )} </View> - ) - }, -) + </View> + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index 65b584866..6f189cf1a 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -14,7 +14,11 @@ import {s} from 'lib/styles' export const snapPoints = [520, '100%'] -export const Component = observer(({did}: {did: string}) => { +export const Component = observer(function ProfilePreviewImpl({ + did, +}: { + did: string +}) { const store = useStores() const pal = usePalette('default') const [model] = useState(new ProfileModel(store, {actor: did})) diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx index df1b405ca..187b46e8c 100644 --- a/src/view/com/modals/lang-settings/LanguageToggle.tsx +++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx @@ -5,43 +5,41 @@ import {observer} from 'mobx-react-lite' import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {useStores} from 'state/index' -export const LanguageToggle = observer( - ({ - code2, - name, - onPress, - langType, - }: { - code2: string - name: string - onPress: () => void - langType: 'contentLanguages' | 'postLanguages' - }) => { - const pal = usePalette('default') - const store = useStores() +export const LanguageToggle = observer(function LanguageToggleImpl({ + code2, + name, + onPress, + langType, +}: { + code2: string + name: string + onPress: () => void + langType: 'contentLanguages' | 'postLanguages' +}) { + const pal = usePalette('default') + const store = useStores() - const isSelected = store.preferences[langType].includes(code2) + const isSelected = store.preferences[langType].includes(code2) - // enforce a max of 3 selections for post languages - let isDisabled = false - if ( - langType === 'postLanguages' && - store.preferences[langType].length >= 3 && - !isSelected - ) { - isDisabled = true - } + // enforce a max of 3 selections for post languages + let isDisabled = false + if ( + langType === 'postLanguages' && + store.preferences[langType].length >= 3 && + !isSelected + ) { + isDisabled = true + } - return ( - <ToggleButton - label={name} - isSelected={isSelected} - onPress={isDisabled ? undefined : onPress} - style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]} - /> - ) - }, -) + return ( + <ToggleButton + label={name} + isSelected={isSelected} + onPress={isDisabled ? undefined : onPress} + style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]} + /> + ) +}) const styles = StyleSheet.create({ languageToggle: { diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx index 1ee5c9d1f..d74d884cc 100644 --- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx +++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx @@ -13,7 +13,7 @@ import {ToggleButton} from 'view/com/util/forms/ToggleButton' export const snapPoints = ['100%'] -export const Component = observer(() => { +export const Component = observer(function PostLanguagesSettingsImpl() { const store = useStores() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index ef191a1d2..00e56e1cc 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -52,7 +52,7 @@ interface Author { moderation: ProfileModeration } -export const FeedItem = observer(function ({ +export const FeedItem = observer(function FeedItemImpl({ item, }: { item: NotificationsFeedItemModel diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx index 1bdb42a9c..89a0da47f 100644 --- a/src/view/com/notifications/InvitedUsers.tsx +++ b/src/view/com/notifications/InvitedUsers.tsx @@ -18,7 +18,7 @@ import {s} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {makeProfileLink} from 'lib/routes/links' -export const InvitedUsers = observer(() => { +export const InvitedUsers = observer(function InvitedUsersImpl() { const store = useStores() return ( <CenteredView> diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 48a6ed3a9..0083e953b 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -9,59 +9,55 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' -export const FeedsTabBar = observer( - ( - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, - ) => { - const {isMobile} = useWebMediaQueries() - if (isMobile) { - return <FeedsTabBarMobile {...props} /> - } else { - return <FeedsTabBarDesktop {...props} /> - } - }, -) +export const FeedsTabBar = observer(function FeedsTabBarImpl( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, +) { + const {isMobile} = useWebMediaQueries() + if (isMobile) { + return <FeedsTabBarMobile {...props} /> + } else { + return <FeedsTabBarDesktop {...props} /> + } +}) -const FeedsTabBarDesktop = observer( - ( - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, - ) => { - const store = useStores() - const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], - [store.me.savedFeeds.pinnedFeedNames], - ) - const pal = usePalette('default') - const interp = useAnimatedValue(0) +const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, +) { + const store = useStores() + const items = useMemo( + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], + [store.me.savedFeeds.pinnedFeedNames], + ) + const pal = usePalette('default') + const interp = useAnimatedValue(0) - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [ - {translateX: '-50%'}, - {translateY: Animated.multiply(interp, -100)}, - ], - } + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [ + {translateX: '-50%'}, + {translateY: Animated.multiply(interp, -100)}, + ], + } - return ( - // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf - <Animated.View style={[pal.view, styles.tabBar, transform]}> - <TabBar - key={items.join(',')} - {...props} - items={items} - indicatorColor={pal.colors.link} - /> - </Animated.View> - ) - }, -) + return ( + // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf + <Animated.View style={[pal.view, styles.tabBar, transform]}> + <TabBar + key={items.join(',')} + {...props} + items={items} + indicatorColor={pal.colors.link} + /> + </Animated.View> + ) +}) const styles = StyleSheet.create({ tabBar: { diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 55a38803f..5ce2906b3 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -14,79 +14,77 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' -export const FeedsTabBar = observer( - ( - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, - ) => { - const store = useStores() - const pal = usePalette('default') - const interp = useAnimatedValue(0) +export const FeedsTabBar = observer(function FeedsTabBarImpl( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, +) { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } - const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) + const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) - const onPressAvi = React.useCallback(() => { - store.shell.openDrawer() - }, [store]) + const onPressAvi = React.useCallback(() => { + store.shell.openDrawer() + }, [store]) - const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], - [store.me.savedFeeds.pinnedFeedNames], - ) + const items = useMemo( + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], + [store.me.savedFeeds.pinnedFeedNames], + ) - return ( - <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}> - <View style={[pal.view, styles.topBar]}> - <View style={[pal.view]}> - <TouchableOpacity - testID="viewHeaderDrawerBtn" - onPress={onPressAvi} - accessibilityRole="button" - accessibilityLabel="Open navigation" - accessibilityHint="Access profile and other navigation links" - hitSlop={HITSLOP_10}> - <FontAwesomeIcon - icon="bars" - size={18} - color={pal.colors.textLight} - /> - </TouchableOpacity> - </View> - <Text style={[brandBlue, s.bold, styles.title]}> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} - </Text> - <View style={[pal.view]}> - <Link - href="/settings/saved-feeds" - hitSlop={HITSLOP_10} - accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={21} strokeWidth={2} style={pal.textLight} /> - </Link> - </View> + return ( + <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}> + <View style={[pal.view, styles.topBar]}> + <View style={[pal.view]}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + onPress={onPressAvi} + accessibilityRole="button" + accessibilityLabel="Open navigation" + accessibilityHint="Access profile and other navigation links" + hitSlop={HITSLOP_10}> + <FontAwesomeIcon + icon="bars" + size={18} + color={pal.colors.textLight} + /> + </TouchableOpacity> </View> - <TabBar - key={items.join(',')} - {...props} - items={items} - indicatorColor={pal.colors.link} - /> - </Animated.View> - ) - }, -) + <Text style={[brandBlue, s.bold, styles.title]}> + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} + </Text> + <View style={[pal.view]}> + <Link + href="/settings/saved-feeds" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel="Edit Saved Feeds" + accessibilityHint="Opens screen to edit Saved Feeds"> + <CogIcon size={21} strokeWidth={2} style={pal.textLight} /> + </Link> + </View> + </View> + <TabBar + key={items.join(',')} + {...props} + items={items} + indicatorColor={pal.colors.link} + /> + </Animated.View> + ) +}) const styles = StyleSheet.create({ tabBar: { diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 80dd59072..574fe1e8e 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -8,7 +8,11 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -export const PostLikedBy = observer(function ({uri}: {uri: string}) { +export const PostLikedBy = observer(function PostLikedByImpl({ + uri, +}: { + uri: string +}) { const pal = usePalette('default') const store = useStores() const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri]) @@ -64,6 +68,8 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) { onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {view.isLoading && <ActivityIndicator />} diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 31fa0cf7f..e4b592779 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -8,7 +8,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -export const PostRepostedBy = observer(function PostRepostedBy({ +export const PostRepostedBy = observer(function PostRepostedByImpl({ uri, }: { uri: string @@ -75,6 +75,8 @@ export const PostRepostedBy = observer(function PostRepostedBy({ onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {view.isLoading && <ActivityIndicator />} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 661b3a899..0855f25bf 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -31,7 +31,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' -export const Post = observer(function Post({ +export const Post = observer(function PostImpl({ view, showReplyLine, hideError, @@ -88,214 +88,212 @@ export const Post = observer(function Post({ ) }) -const PostLoaded = observer( - ({ - item, - record, - setDeleted, - showReplyLine, - style, - }: { - item: PostThreadItemModel - record: FeedPost.Record - setDeleted: (v: boolean) => void - showReplyLine?: boolean - style?: StyleProp<ViewStyle> - }) => { - const pal = usePalette('default') - const store = useStores() +const PostLoaded = observer(function PostLoadedImpl({ + item, + record, + setDeleted, + showReplyLine, + style, +}: { + item: PostThreadItemModel + record: FeedPost.Record + setDeleted: (v: boolean) => void + showReplyLine?: boolean + style?: StyleProp<ViewStyle> +}) { + const pal = usePalette('default') + const store = useStores() - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) - const itemTitle = `Post by ${item.post.author.handle}` - let replyAuthorDid = '' - if (record.reply) { - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) - replyAuthorDid = urip.hostname - } + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemUrip = new AtUri(item.post.uri) + const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${item.post.author.handle}` + let replyAuthorDid = '' + if (record.reply) { + const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) + replyAuthorDid = urip.hostname + } - const translatorUrl = getTranslatorLink(record?.text || '') - const needsTranslation = useMemo( - () => - store.preferences.contentLanguages.length > 0 && - !isPostInLanguage(item.post, store.preferences.contentLanguages), - [item.post, store.preferences.contentLanguages], - ) + const translatorUrl = getTranslatorLink(record?.text || '') + const needsTranslation = useMemo( + () => + store.preferences.contentLanguages.length > 0 && + !isPostInLanguage(item.post, store.preferences.contentLanguages), + [item.post, store.preferences.contentLanguages], + ) - const onPressReply = React.useCallback(() => { - store.shell.openComposer({ - replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, - author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, - }, + const onPressReply = React.useCallback(() => { + store.shell.openComposer({ + replyTo: { + uri: item.post.uri, + cid: item.post.cid, + text: record.text as string, + author: { + handle: item.post.author.handle, + displayName: item.post.author.displayName, + avatar: item.post.author.avatar, }, - }) - }, [store, item, record]) + }, + }) + }, [store, item, record]) - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => store.log.error('Failed to toggle repost', e)) - }, [item, store]) + const onPressToggleRepost = React.useCallback(() => { + return item + .toggleRepost() + .catch(e => store.log.error('Failed to toggle repost', e)) + }, [item, store]) - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => store.log.error('Failed to toggle like', e)) - }, [item, store]) + const onPressToggleLike = React.useCallback(() => { + return item + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) + }, [item, store]) - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - }, [record]) + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record.text) + Toast.show('Copied to clipboard') + }, [record]) - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) + const onOpenTranslate = React.useCallback(() => { + Linking.openURL(translatorUrl) + }, [translatorUrl]) - const onToggleThreadMute = React.useCallback(async () => { - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - store.log.error('Failed to toggle thread mute', e) + const onToggleThreadMute = React.useCallback(async () => { + try { + await item.toggleThreadMute() + if (item.isThreadMuted) { + Toast.show('You will no longer receive notifications for this thread') + } else { + Toast.show('You will now receive notifications for this thread') } - }, [item, store]) + } catch (e) { + store.log.error('Failed to toggle thread mute', e) + } + }, [item, store]) - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - store.log.error('Failed to delete post', e) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item, setDeleted, store]) + const onDeletePost = React.useCallback(() => { + item.delete().then( + () => { + setDeleted(true) + Toast.show('Post deleted') + }, + e => { + store.log.error('Failed to delete post', e) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [item, setDeleted, store]) - return ( - <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> - {showReplyLine && <View style={styles.replyLine} />} - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <PreviewableUserAvatar - size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} - /> - </View> - <View style={styles.layoutContent}> - <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + return ( + <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> + {showReplyLine && <View style={styles.replyLine} />} + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <PreviewableUserAvatar + size={52} + did={item.post.author.did} + handle={item.post.author.handle} + avatar={item.post.author.avatar} + moderation={item.moderation.avatar} + /> + </View> + <View style={styles.layoutContent}> + <PostMeta + author={item.post.author} + authorHasWarning={!!item.post.author.labels?.length} + timestamp={item.post.indexedAt} + postHref={itemHref} + /> + {replyAuthorDid !== '' && ( + <View style={[s.flexRow, s.mb2, s.alignCenter]}> + <FontAwesomeIcon + icon="reply" + size={9} + style={[pal.textLight, s.mr5]} + /> + <Text + type="sm" + style={[pal.textLight, s.mr2]} + lineHeight={1.2} + numberOfLines={1}> + Reply to{' '} + <UserInfoText + type="sm" + did={replyAuthorDid} + attr="displayName" + style={[pal.textLight]} + /> + </Text> + </View> + )} + <ContentHider + moderation={item.moderation.content} + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} /> - {replyAuthorDid !== '' && ( - <View style={[s.flexRow, s.mb2, s.alignCenter]}> - <FontAwesomeIcon - icon="reply" - size={9} - style={[pal.textLight, s.mr5]} + {item.richText?.text ? ( + <View style={styles.postTextContainer}> + <RichText + testID="postText" + type="post-text" + richText={item.richText} + lineHeight={1.3} + style={s.flex1} /> - <Text - type="sm" - style={[pal.textLight, s.mr2]} - lineHeight={1.2} - numberOfLines={1}> - Reply to{' '} - <UserInfoText - type="sm" - did={replyAuthorDid} - attr="displayName" - style={[pal.textLight]} - /> - </Text> </View> - )} - <ContentHider - moderation={item.moderation.content} - style={styles.contentHider} - childContainerStyle={styles.contentHiderChild}> - <PostAlerts - moderation={item.moderation.content} - style={styles.alert} - /> - {item.richText?.text ? ( - <View style={styles.postTextContainer}> - <RichText - testID="postText" - type="post-text" - richText={item.richText} - lineHeight={1.3} - style={s.flex1} - /> - </View> - ) : undefined} - {item.post.embed ? ( - <ContentHider + ) : undefined} + {item.post.embed ? ( + <ContentHider + moderation={item.moderation.embed} + style={styles.contentHider}> + <PostEmbeds + embed={item.post.embed} moderation={item.moderation.embed} - style={styles.contentHider}> - <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} - /> - </ContentHider> - ) : null} - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} - </ContentHider> - <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - indexedAt={item.post.indexedAt} - text={item.richText?.text || record.text} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> - </View> + /> + </ContentHider> + ) : null} + {needsTranslation && ( + <View style={[pal.borderDark, styles.translateLink]}> + <Link href={translatorUrl} title="Translate"> + <Text type="sm" style={pal.link}> + Translate this post + </Text> + </Link> + </View> + )} + </ContentHider> + <PostCtrls + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={item.post.author} + indexedAt={item.post.indexedAt} + text={item.richText?.text || record.text} + isAuthor={item.post.author.did === store.me.did} + replyCount={item.post.replyCount} + repostCount={item.post.repostCount} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + isThreadMuted={item.isThreadMuted} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onToggleThreadMute={onToggleThreadMute} + onDeletePost={onDeletePost} + /> </View> - </Link> - ) - }, -) + </View> + </Link> + ) +}) const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index e11f258a5..de08af746 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -30,7 +30,7 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' -export const FeedItem = observer(function ({ +export const FeedItem = observer(function FeedItemImpl({ item, isThreadChild, isThreadLastChild, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 6fc169db9..47313ee27 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -10,63 +10,61 @@ import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' -export const FeedSlice = observer( - ({ - slice, - ignoreFilterFor, - }: { - slice: PostsFeedSliceModel - ignoreFilterFor?: string - }) => { - if (slice.shouldFilter(ignoreFilterFor)) { - return null - } - - if (slice.isThread && slice.items.length > 3) { - const last = slice.items.length - 1 - return ( - <> - <FeedItem - key={slice.items[0]._reactKey} - item={slice.items[0]} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} - /> - <FeedItem - key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} - /> - <ViewFullThread slice={slice} /> - <FeedItem - key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} - isThreadLastChild - /> - </> - ) - } +export const FeedSlice = observer(function FeedSliceImpl({ + slice, + ignoreFilterFor, +}: { + slice: PostsFeedSliceModel + ignoreFilterFor?: string +}) { + if (slice.shouldFilter(ignoreFilterFor)) { + return null + } + if (slice.isThread && slice.items.length > 3) { + const last = slice.items.length - 1 return ( <> - {slice.items.map((item, i) => ( - <FeedItem - key={item._reactKey} - item={item} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} - isThreadLastChild={ - slice.isThreadChildAt(i) && slice.items.length === i + 1 - } - /> - ))} + <FeedItem + key={slice.items[0]._reactKey} + item={slice.items[0]} + isThreadParent={slice.isThreadParentAt(0)} + isThreadChild={slice.isThreadChildAt(0)} + /> + <FeedItem + key={slice.items[1]._reactKey} + item={slice.items[1]} + isThreadParent={slice.isThreadParentAt(1)} + isThreadChild={slice.isThreadChildAt(1)} + /> + <ViewFullThread slice={slice} /> + <FeedItem + key={slice.items[last]._reactKey} + item={slice.items[last]} + isThreadParent={slice.isThreadParentAt(last)} + isThreadChild={slice.isThreadChildAt(last)} + isThreadLastChild + /> </> ) - }, -) + } + + return ( + <> + {slice.items.map((item, i) => ( + <FeedItem + key={item._reactKey} + item={item} + isThreadParent={slice.isThreadParentAt(i)} + isThreadChild={slice.isThreadChildAt(i)} + isThreadLastChild={ + slice.isThreadChildAt(i) && slice.items.length === i + 1 + } + /> + ))} + </> + ) +}) function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const pal = usePalette('default') diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index fcb2225da..6f6286e69 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -6,56 +6,54 @@ import {useStores} from 'state/index' import * as Toast from '../util/Toast' import {FollowState} from 'state/models/cache/my-follows' -export const FollowButton = observer( - ({ - unfollowedType = 'inverted', - followedType = 'default', - did, - onToggleFollow, - }: { - unfollowedType?: ButtonType - followedType?: ButtonType - did: string - onToggleFollow?: (v: boolean) => void - }) => { - const store = useStores() - const followState = store.me.follows.getFollowState(did) +export const FollowButton = observer(function FollowButtonImpl({ + unfollowedType = 'inverted', + followedType = 'default', + did, + onToggleFollow, +}: { + unfollowedType?: ButtonType + followedType?: ButtonType + did: string + onToggleFollow?: (v: boolean) => void +}) { + const store = useStores() + const followState = store.me.follows.getFollowState(did) - if (followState === FollowState.Unknown) { - return <View /> - } + if (followState === FollowState.Unknown) { + return <View /> + } - const onToggleFollowInner = async () => { - const updatedFollowState = await store.me.follows.fetchFollowState(did) - if (updatedFollowState === FollowState.Following) { - try { - await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) - store.me.follows.removeFollow(did) - onToggleFollow?.(false) - } catch (e: any) { - store.log.error('Failed to delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } else if (updatedFollowState === FollowState.NotFollowing) { - try { - const res = await store.agent.follow(did) - store.me.follows.addFollow(did, res.uri) - onToggleFollow?.(true) - } catch (e: any) { - store.log.error('Failed to create follow', e) - Toast.show('An issue occurred, please try again.') - } + const onToggleFollowInner = async () => { + const updatedFollowState = await store.me.follows.fetchFollowState(did) + if (updatedFollowState === FollowState.Following) { + try { + await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + onToggleFollow?.(false) + } catch (e: any) { + store.log.error('Failed to delete follow', e) + Toast.show('An issue occurred, please try again.') + } + } else if (updatedFollowState === FollowState.NotFollowing) { + try { + const res = await store.agent.follow(did) + store.me.follows.addFollow(did, res.uri) + onToggleFollow?.(true) + } catch (e: any) { + store.log.error('Failed to create follow', e) + Toast.show('An issue occurred, please try again.') } } + } - return ( - <Button - type={ - followState === FollowState.Following ? followedType : unfollowedType - } - onPress={onToggleFollowInner} - label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} - /> - ) - }, -) + return ( + <Button + type={ + followState === FollowState.Following ? followedType : unfollowedType + } + onPress={onToggleFollowInner} + label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} + /> + ) +}) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 771785ee9..e0c8ad21a 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -22,89 +22,82 @@ import { getModerationCauseKey, } from 'lib/moderation' -export const ProfileCard = observer( - ({ - testID, - profile, - noBg, - noBorder, - followers, - renderButton, - }: { - testID?: string - profile: AppBskyActorDefs.ProfileViewBasic - noBg?: boolean - noBorder?: boolean - followers?: AppBskyActorDefs.ProfileView[] | undefined - renderButton?: ( - profile: AppBskyActorDefs.ProfileViewBasic, - ) => React.ReactNode - }) => { - const store = useStores() - const pal = usePalette('default') +export const ProfileCard = observer(function ProfileCardImpl({ + testID, + profile, + noBg, + noBorder, + followers, + renderButton, +}: { + testID?: string + profile: AppBskyActorDefs.ProfileViewBasic + noBg?: boolean + noBorder?: boolean + followers?: AppBskyActorDefs.ProfileView[] | undefined + renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode +}) { + const store = useStores() + const pal = usePalette('default') - const moderation = moderateProfile( - profile, - store.preferences.moderationOpts, - ) + const moderation = moderateProfile(profile, store.preferences.moderationOpts) - return ( - <Link - testID={testID} - style={[ - styles.outer, - pal.border, - noBorder && styles.outerNoBorder, - !noBg && pal.view, - ]} - href={makeProfileLink(profile)} - title={profile.handle} - asAnchor - anchorNoUnderline> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - avatar={profile.avatar} - moderation={moderation.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.profile, - )} - </Text> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {sanitizeHandle(profile.handle, '@')} - </Text> - <ProfileCardPills - followedBy={!!profile.viewer?.followedBy} - moderation={moderation} - /> - {!!profile.viewer?.followedBy && <View style={s.flexRow} />} - </View> - {renderButton ? ( - <View style={styles.layoutButton}>{renderButton(profile)}</View> - ) : undefined} + return ( + <Link + testID={testID} + style={[ + styles.outer, + pal.border, + noBorder && styles.outerNoBorder, + !noBg && pal.view, + ]} + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.avatar} + /> </View> - {profile.description ? ( - <View style={styles.details}> - <Text style={pal.text} numberOfLines={4}> - {profile.description as string} - </Text> - </View> + <View style={styles.layoutContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + <ProfileCardPills + followedBy={!!profile.viewer?.followedBy} + moderation={moderation} + /> + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} + </View> + {renderButton ? ( + <View style={styles.layoutButton}>{renderButton(profile)}</View> ) : undefined} - <FollowersList followers={followers} /> - </Link> - ) - }, -) + </View> + {profile.description ? ( + <View style={styles.details}> + <Text style={pal.text} numberOfLines={4}> + {profile.description as string} + </Text> + </View> + ) : undefined} + <FollowersList followers={followers} /> + </Link> + ) +}) function ProfileCardPills({ followedBy, @@ -146,45 +139,47 @@ function ProfileCardPills({ ) } -const FollowersList = observer( - ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { - const store = useStores() - const pal = usePalette('default') - if (!followers?.length) { - return null - } +const FollowersList = observer(function FollowersListImpl({ + followers, +}: { + followers?: AppBskyActorDefs.ProfileView[] | undefined +}) { + const store = useStores() + const pal = usePalette('default') + if (!followers?.length) { + return null + } - const followersWithMods = followers - .map(f => ({ - f, - mod: moderateProfile(f, store.preferences.moderationOpts), - })) - .filter(({mod}) => !mod.account.filter) + const followersWithMods = followers + .map(f => ({ + f, + mod: moderateProfile(f, store.preferences.moderationOpts), + })) + .filter(({mod}) => !mod.account.filter) - return ( - <View style={styles.followedBy}> - <Text - type="sm" - style={[styles.followsByDesc, pal.textLight]} - numberOfLines={2} - lineHeight={1.2}> - Followed by{' '} - {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} - </Text> - {followersWithMods.slice(0, 3).map(({f, mod}) => ( - <View key={f.did} style={styles.followedByAviContainer}> - <View style={[styles.followedByAvi, pal.view]}> - <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> - </View> + return ( + <View style={styles.followedBy}> + <Text + type="sm" + style={[styles.followsByDesc, pal.textLight]} + numberOfLines={2} + lineHeight={1.2}> + Followed by{' '} + {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} + </Text> + {followersWithMods.slice(0, 3).map(({f, mod}) => ( + <View key={f.did} style={styles.followedByAviContainer}> + <View style={[styles.followedByAvi, pal.view]}> + <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> </View> - ))} - </View> - ) - }, -) + </View> + ))} + </View> + ) +}) export const ProfileCardWithFollowBtn = observer( - ({ + function ProfileCardWithFollowBtnImpl({ profile, noBg, noBorder, @@ -194,7 +189,7 @@ export const ProfileCardWithFollowBtn = observer( noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - }) => { + }) { const store = useStores() const isMe = store.me.did === profile.did diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index aeb2fcba9..beb9609b6 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -78,6 +78,8 @@ export const ProfileFollowers = observer(function ProfileFollowers({ onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {view.isLoading && <ActivityIndicator />} diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 0632fac02..22722ee63 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -75,6 +75,8 @@ export const ProfileFollows = observer(function ProfileFollows({ onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {view.isLoading && <ActivityIndicator />} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 1c683ab9a..b52d338aa 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -45,510 +45,502 @@ interface Props { hideBackButton?: boolean } -export const ProfileHeader = observer( - ({view, onRefreshAll, hideBackButton = false}: Props) => { - const pal = usePalette('default') +export const ProfileHeader = observer(function ProfileHeaderImpl({ + view, + onRefreshAll, + hideBackButton = false, +}: Props) { + const pal = usePalette('default') - // loading - // = - if (!view || !view.hasLoaded) { - return ( - <View style={pal.view}> - <LoadingPlaceholder width="100%" height={120} /> - <View - style={[ - pal.view, - {borderColor: pal.colors.background}, - styles.avi, - ]}> - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + // loading + // = + if (!view || !view.hasLoaded) { + return ( + <View style={pal.view}> + <LoadingPlaceholder width="100%" height={120} /> + <View + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + </View> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + <LoadingPlaceholder width={100} height={31} style={styles.br50} /> </View> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={100} height={31} style={styles.br50} /> - </View> - <View> - <Text type="title-2xl" style={[pal.text, styles.title]}> - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - )} - </Text> - </View> + <View> + <Text type="title-2xl" style={[pal.text, styles.title]}> + {sanitizeDisplayName( + view.displayName || sanitizeHandle(view.handle), + )} + </Text> </View> </View> - ) - } - - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } + </View> + ) + } - // loaded - // = + // error + // = + if (view.hasError) { return ( - <ProfileHeaderLoaded - view={view} - onRefreshAll={onRefreshAll} - hideBackButton={hideBackButton} - /> + <View testID="profileHeaderHasError"> + <Text>{view.error}</Text> + </View> ) - }, -) + } -const ProfileHeaderLoaded = observer( - ({view, onRefreshAll, hideBackButton = false}: Props) => { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - const navigation = useNavigation<NavigationProp>() - const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(view.handle) - const {isDesktop} = useWebMediaQueries() + // loaded + // = + return ( + <ProfileHeaderLoaded + view={view} + onRefreshAll={onRefreshAll} + hideBackButton={hideBackButton} + /> + ) +}) - const onPressBack = React.useCallback(() => { - navigation.goBack() - }, [navigation]) +const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ + view, + onRefreshAll, + hideBackButton = false, +}: Props) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + const {track} = useAnalytics() + const invalidHandle = isInvalidHandle(view.handle) + const {isDesktop} = useWebMediaQueries() - const onPressAvi = React.useCallback(() => { - if ( - view.avatar && - !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { - store.shell.openLightbox(new ProfileImageLightbox(view)) - } - }, [store, view]) + const onPressBack = React.useCallback(() => { + navigation.goBack() + }, [navigation]) - const onPressToggleFollow = React.useCallback(() => { - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', - ) - view?.toggleFollowing().then( - () => { - Toast.show( - `${ - view.viewer.following ? 'Following' : 'No longer following' - } ${sanitizeDisplayName(view.displayName || view.handle)}`, - ) - }, - err => store.log.error('Failed to toggle follow', err), - ) - }, [track, view, store.log]) + const onPressAvi = React.useCallback(() => { + if ( + view.avatar && + !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { + store.shell.openLightbox(new ProfileImageLightbox(view)) + } + }, [store, view]) - const onPressEditProfile = React.useCallback(() => { - track('ProfileHeader:EditProfileButtonClicked') - store.shell.openModal({ - name: 'edit-profile', - profileView: view, - onUpdate: onRefreshAll, - }) - }, [track, store, view, onRefreshAll]) + const onPressToggleFollow = React.useCallback(() => { + track( + view.viewer.following + ? 'ProfileHeader:FollowButtonClicked' + : 'ProfileHeader:UnfollowButtonClicked', + ) + view?.toggleFollowing().then( + () => { + Toast.show( + `${ + view.viewer.following ? 'Following' : 'No longer following' + } ${sanitizeDisplayName(view.displayName || view.handle)}`, + ) + }, + err => store.log.error('Failed to toggle follow', err), + ) + }, [track, view, store.log]) - const onPressFollowers = React.useCallback(() => { - track('ProfileHeader:FollowersButtonClicked') - navigate('ProfileFollowers', { - name: isInvalidHandle(view.handle) ? view.did : view.handle, - }) - store.shell.closeAllActiveElements() // for when used in the profile preview modal - }, [track, view, store.shell]) + const onPressEditProfile = React.useCallback(() => { + track('ProfileHeader:EditProfileButtonClicked') + store.shell.openModal({ + name: 'edit-profile', + profileView: view, + onUpdate: onRefreshAll, + }) + }, [track, store, view, onRefreshAll]) - const onPressFollows = React.useCallback(() => { - track('ProfileHeader:FollowsButtonClicked') - navigate('ProfileFollows', { - name: isInvalidHandle(view.handle) ? view.did : view.handle, - }) - store.shell.closeAllActiveElements() // for when used in the profile preview modal - }, [track, view, store.shell]) + const onPressFollowers = React.useCallback(() => { + track('ProfileHeader:FollowersButtonClicked') + navigate('ProfileFollowers', { + name: isInvalidHandle(view.handle) ? view.did : view.handle, + }) + store.shell.closeAllActiveElements() // for when used in the profile preview modal + }, [track, view, store.shell]) - const onPressShare = React.useCallback(() => { - track('ProfileHeader:ShareButtonClicked') - const url = toShareUrl(makeProfileLink(view)) - shareUrl(url) - }, [track, view]) + const onPressFollows = React.useCallback(() => { + track('ProfileHeader:FollowsButtonClicked') + navigate('ProfileFollows', { + name: isInvalidHandle(view.handle) ? view.did : view.handle, + }) + store.shell.closeAllActiveElements() // for when used in the profile preview modal + }, [track, view, store.shell]) - const onPressAddRemoveLists = React.useCallback(() => { - track('ProfileHeader:AddToListsButtonClicked') - store.shell.openModal({ - name: 'list-add-remove-user', - subject: view.did, - displayName: view.displayName || view.handle, - }) - }, [track, view, store]) + const onPressShare = React.useCallback(() => { + track('ProfileHeader:ShareButtonClicked') + const url = toShareUrl(makeProfileLink(view)) + shareUrl(url) + }, [track, view]) - const onPressMuteAccount = React.useCallback(async () => { - track('ProfileHeader:MuteAccountButtonClicked') - try { - await view.muteAccount() - Toast.show('Account muted') - } catch (e: any) { - store.log.error('Failed to mute account', e) - Toast.show(`There was an issue! ${e.toString()}`) - } - }, [track, view, store]) + const onPressAddRemoveLists = React.useCallback(() => { + track('ProfileHeader:AddToListsButtonClicked') + store.shell.openModal({ + name: 'list-add-remove-user', + subject: view.did, + displayName: view.displayName || view.handle, + }) + }, [track, view, store]) - const onPressUnmuteAccount = React.useCallback(async () => { - track('ProfileHeader:UnmuteAccountButtonClicked') - try { - await view.unmuteAccount() - Toast.show('Account unmuted') - } catch (e: any) { - store.log.error('Failed to unmute account', e) - Toast.show(`There was an issue! ${e.toString()}`) - } - }, [track, view, store]) + const onPressMuteAccount = React.useCallback(async () => { + track('ProfileHeader:MuteAccountButtonClicked') + try { + await view.muteAccount() + Toast.show('Account muted') + } catch (e: any) { + store.log.error('Failed to mute account', e) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, [track, view, store]) - const onPressBlockAccount = React.useCallback(async () => { - track('ProfileHeader:BlockAccountButtonClicked') - store.shell.openModal({ - name: 'confirm', - title: 'Block Account', - message: - 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', - onPressConfirm: async () => { - try { - await view.blockAccount() - onRefreshAll() - Toast.show('Account blocked') - } catch (e: any) { - store.log.error('Failed to block account', e) - Toast.show(`There was an issue! ${e.toString()}`) - } - }, - }) - }, [track, view, store, onRefreshAll]) + const onPressUnmuteAccount = React.useCallback(async () => { + track('ProfileHeader:UnmuteAccountButtonClicked') + try { + await view.unmuteAccount() + Toast.show('Account unmuted') + } catch (e: any) { + store.log.error('Failed to unmute account', e) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, [track, view, store]) - const onPressUnblockAccount = React.useCallback(async () => { - track('ProfileHeader:UnblockAccountButtonClicked') - store.shell.openModal({ - name: 'confirm', - title: 'Unblock Account', - message: - 'The account will be able to interact with you after unblocking.', - onPressConfirm: async () => { - try { - await view.unblockAccount() - onRefreshAll() - Toast.show('Account unblocked') - } catch (e: any) { - store.log.error('Failed to unblock account', e) - Toast.show(`There was an issue! ${e.toString()}`) - } - }, - }) - }, [track, view, store, onRefreshAll]) + const onPressBlockAccount = React.useCallback(async () => { + track('ProfileHeader:BlockAccountButtonClicked') + store.shell.openModal({ + name: 'confirm', + title: 'Block Account', + message: + 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + onPressConfirm: async () => { + try { + await view.blockAccount() + onRefreshAll() + Toast.show('Account blocked') + } catch (e: any) { + store.log.error('Failed to block account', e) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, + }) + }, [track, view, store, onRefreshAll]) - const onPressReportAccount = React.useCallback(() => { - track('ProfileHeader:ReportAccountButtonClicked') - store.shell.openModal({ - name: 'report', - did: view.did, - }) - }, [track, store, view]) + const onPressUnblockAccount = React.useCallback(async () => { + track('ProfileHeader:UnblockAccountButtonClicked') + store.shell.openModal({ + name: 'confirm', + title: 'Unblock Account', + message: + 'The account will be able to interact with you after unblocking.', + onPressConfirm: async () => { + try { + await view.unblockAccount() + onRefreshAll() + Toast.show('Account unblocked') + } catch (e: any) { + store.log.error('Failed to unblock account', e) + Toast.show(`There was an issue! ${e.toString()}`) + } + }, + }) + }, [track, view, store, onRefreshAll]) - const isMe = React.useMemo( - () => store.me.did === view.did, - [store.me.did, view.did], - ) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - let items: DropdownItem[] = [ - { - testID: 'profileHeaderDropdownShareBtn', - label: 'Share', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', + const onPressReportAccount = React.useCallback(() => { + track('ProfileHeader:ReportAccountButtonClicked') + store.shell.openModal({ + name: 'report', + did: view.did, + }) + }, [track, store, view]) + + const isMe = React.useMemo( + () => store.me.did === view.did, + [store.me.did, view.did], + ) + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'profileHeaderDropdownShareBtn', + label: 'Share', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', }, + android: 'ic_menu_share', + web: 'share', }, - ] - if (!isMe) { - items.push({label: 'separator'}) - // Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self! - items.push({ - testID: 'profileHeaderDropdownListAddRemoveBtn', - label: 'Add to Lists', - onPress: onPressAddRemoveLists, - icon: { - ios: { - name: 'list.bullet', - }, - android: 'ic_menu_add', - web: 'list', - }, - }) - if (!view.viewer.blocking) { - items.push({ - testID: 'profileHeaderDropdownMuteBtn', - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted - ? onPressUnmuteAccount - : onPressMuteAccount, - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }) - } - items.push({ - testID: 'profileHeaderDropdownBlockBtn', - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', - onPress: view.viewer.blocking - ? onPressUnblockAccount - : onPressBlockAccount, - icon: { - ios: { - name: 'person.fill.xmark', - }, - android: 'ic_menu_close_clear_cancel', - web: 'user-slash', + }, + ] + if (!isMe) { + items.push({label: 'separator'}) + // Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self! + items.push({ + testID: 'profileHeaderDropdownListAddRemoveBtn', + label: 'Add to Lists', + onPress: onPressAddRemoveLists, + icon: { + ios: { + name: 'list.bullet', }, - }) + android: 'ic_menu_add', + web: 'list', + }, + }) + if (!view.viewer.blocking) { items.push({ - testID: 'profileHeaderDropdownReportBtn', - label: 'Report Account', - onPress: onPressReportAccount, + testID: 'profileHeaderDropdownMuteBtn', + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted + ? onPressUnmuteAccount + : onPressMuteAccount, icon: { ios: { - name: 'exclamationmark.triangle', + name: 'speaker.slash', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', + android: 'ic_lock_silent_mode', + web: 'comment-slash', }, }) } - return items - }, [ - isMe, - view.viewer.muted, - view.viewer.blocking, - onPressShare, - onPressUnmuteAccount, - onPressMuteAccount, - onPressUnblockAccount, - onPressBlockAccount, - onPressReportAccount, - onPressAddRemoveLists, - ]) + items.push({ + testID: 'profileHeaderDropdownBlockBtn', + label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', + onPress: view.viewer.blocking + ? onPressUnblockAccount + : onPressBlockAccount, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: 'ic_menu_close_clear_cancel', + web: 'user-slash', + }, + }) + items.push({ + testID: 'profileHeaderDropdownReportBtn', + label: 'Report Account', + onPress: onPressReportAccount, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', + }, + }) + } + return items + }, [ + isMe, + view.viewer.muted, + view.viewer.blocking, + onPressShare, + onPressUnmuteAccount, + onPressMuteAccount, + onPressUnblockAccount, + onPressBlockAccount, + onPressReportAccount, + onPressAddRemoveLists, + ]) - const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) - const following = formatCount(view.followsCount) - const followers = formatCount(view.followersCount) - const pluralizedFollowers = pluralize(view.followersCount, 'follower') + const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) + const following = formatCount(view.followsCount) + const followers = formatCount(view.followersCount) + const pluralizedFollowers = pluralize(view.followersCount, 'follower') - return ( - <View style={pal.view}> - <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - {isMe ? ( - <TouchableOpacity - testID="profileHeaderEditProfileButton" - onPress={onPressEditProfile} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel="Edit profile" - accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> - <Text type="button" style={pal.text}> - Edit Profile - </Text> - </TouchableOpacity> - ) : view.viewer.blocking ? ( - <TouchableOpacity - testID="unblockBtn" - onPress={onPressUnblockAccount} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel="Unblock" - accessibilityHint=""> - <Text type="button" style={[pal.text, s.bold]}> - Unblock - </Text> - </TouchableOpacity> - ) : !view.viewer.blockedBy ? ( - <> - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( - <TouchableOpacity - testID="unfollowBtn" - onPress={onPressToggleFollow} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel={`Unfollow ${view.handle}`} - accessibilityHint={`Hides posts from ${view.handle} in your feed`}> - <FontAwesomeIcon - icon="check" - style={[pal.text, s.mr5]} - size={14} - /> - <Text type="button" style={pal.text}> - Following - </Text> - </TouchableOpacity> - ) : ( - <TouchableOpacity - testID="followBtn" - onPress={onPressToggleFollow} - style={[styles.btn, styles.mainBtn, palInverted.view]} - accessibilityRole="button" - accessibilityLabel={`Follow ${view.handle}`} - accessibilityHint={`Shows posts from ${view.handle} in your feed`}> - <FontAwesomeIcon - icon="plus" - style={[palInverted.text, s.mr5]} - /> - <Text type="button" style={[palInverted.text, s.bold]}> - Follow - </Text> - </TouchableOpacity> - )} - </> - ) : null} - {dropdownItems?.length ? ( - <NativeDropdown - testID="profileHeaderDropdownBtn" - items={dropdownItems}> - <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - style={[pal.text]} - /> - </View> - </NativeDropdown> - ) : undefined} - </View> - <View> - <Text - testID="profileHeaderDisplayName" - type="title-2xl" - style={[pal.text, styles.title]}> - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - view.moderation.profile, - )} - </Text> - </View> - <View style={styles.handleLine}> - {view.viewer.followedBy && !blockHide ? ( - <View style={[styles.pill, pal.btn, s.mr5]}> - <Text type="xs" style={[pal.text]}> - Follows you - </Text> - </View> - ) : undefined} - <ThemedText - type={invalidHandle ? 'xs' : 'md'} - fg={invalidHandle ? 'error' : 'light'} - border={invalidHandle ? 'error' : undefined} - style={[ - invalidHandle ? styles.invalidHandle : undefined, - styles.handle, - ]}> - {invalidHandle ? 'âš Invalid Handle' : `@${view.handle}`} - </ThemedText> - </View> - {!blockHide && ( + return ( + <View style={pal.view}> + <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + {isMe ? ( + <TouchableOpacity + testID="profileHeaderEditProfileButton" + onPress={onPressEditProfile} + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Edit profile" + accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> + <Text type="button" style={pal.text}> + Edit Profile + </Text> + </TouchableOpacity> + ) : view.viewer.blocking ? ( + <TouchableOpacity + testID="unblockBtn" + onPress={onPressUnblockAccount} + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Unblock" + accessibilityHint=""> + <Text type="button" style={[pal.text, s.bold]}> + Unblock + </Text> + </TouchableOpacity> + ) : !view.viewer.blockedBy ? ( <> - <View style={styles.metricsLine}> + {store.me.follows.getFollowState(view.did) === + FollowState.Following ? ( <TouchableOpacity - testID="profileHeaderFollowersButton" - style={[s.flexRow, s.mr10]} - onPress={onPressFollowers} + testID="unfollowBtn" + onPress={onPressToggleFollow} + style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel={`${followers} ${pluralizedFollowers}`} - accessibilityHint={'Opens followers list'}> - <Text type="md" style={[s.bold, pal.text]}> - {followers}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - {pluralizedFollowers} + accessibilityLabel={`Unfollow ${view.handle}`} + accessibilityHint={`Hides posts from ${view.handle} in your feed`}> + <FontAwesomeIcon + icon="check" + style={[pal.text, s.mr5]} + size={14} + /> + <Text type="button" style={pal.text}> + Following </Text> </TouchableOpacity> + ) : ( <TouchableOpacity - testID="profileHeaderFollowsButton" - style={[s.flexRow, s.mr10]} - onPress={onPressFollows} + testID="followBtn" + onPress={onPressToggleFollow} + style={[styles.btn, styles.mainBtn, palInverted.view]} accessibilityRole="button" - accessibilityLabel={`${following} following`} - accessibilityHint={'Opens following list'}> - <Text type="md" style={[s.bold, pal.text]}> - {following}{' '} - </Text> - <Text type="md" style={[pal.textLight]}> - following + accessibilityLabel={`Follow ${view.handle}`} + accessibilityHint={`Shows posts from ${view.handle} in your feed`}> + <FontAwesomeIcon + icon="plus" + style={[palInverted.text, s.mr5]} + /> + <Text type="button" style={[palInverted.text, s.bold]}> + Follow </Text> </TouchableOpacity> - <Text type="md" style={[s.bold, pal.text]}> - {formatCount(view.postsCount)}{' '} - <Text type="md" style={[pal.textLight]}> - {pluralize(view.postsCount, 'post')} - </Text> - </Text> - </View> - {view.description && - view.descriptionRichText && - !view.moderation.profile.blur ? ( - <RichText - testID="profileHeaderDescription" - style={[styles.description, pal.text]} - numberOfLines={15} - richText={view.descriptionRichText} - /> - ) : undefined} + )} </> - )} - <ProfileHeaderAlerts moderation={view.moderation} /> + ) : null} + {dropdownItems?.length ? ( + <NativeDropdown + testID="profileHeaderDropdownBtn" + items={dropdownItems}> + <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> + <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} /> + </View> + </NativeDropdown> + ) : undefined} + </View> + <View> + <Text + testID="profileHeaderDisplayName" + type="title-2xl" + style={[pal.text, styles.title]}> + {sanitizeDisplayName( + view.displayName || sanitizeHandle(view.handle), + view.moderation.profile, + )} + </Text> + </View> + <View style={styles.handleLine}> + {view.viewer.followedBy && !blockHide ? ( + <View style={[styles.pill, pal.btn, s.mr5]}> + <Text type="xs" style={[pal.text]}> + Follows you + </Text> + </View> + ) : undefined} + <ThemedText + type={invalidHandle ? 'xs' : 'md'} + fg={invalidHandle ? 'error' : 'light'} + border={invalidHandle ? 'error' : undefined} + style={[ + invalidHandle ? styles.invalidHandle : undefined, + styles.handle, + ]}> + {invalidHandle ? 'âš Invalid Handle' : `@${view.handle}`} + </ThemedText> </View> - {!isDesktop && !hideBackButton && ( - <TouchableWithoutFeedback - onPress={onPressBack} - hitSlop={BACK_HITSLOP} - accessibilityRole="button" - accessibilityLabel="Back" - accessibilityHint=""> - <View style={styles.backBtnWrapper}> - <BlurView style={styles.backBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> - </BlurView> + {!blockHide && ( + <> + <View style={styles.metricsLine}> + <TouchableOpacity + testID="profileHeaderFollowersButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollowers} + accessibilityRole="button" + accessibilityLabel={`${followers} ${pluralizedFollowers}`} + accessibilityHint={'Opens followers list'}> + <Text type="md" style={[s.bold, pal.text]}> + {followers}{' '} + </Text> + <Text type="md" style={[pal.textLight]}> + {pluralizedFollowers} + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="profileHeaderFollowsButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollows} + accessibilityRole="button" + accessibilityLabel={`${following} following`} + accessibilityHint={'Opens following list'}> + <Text type="md" style={[s.bold, pal.text]}> + {following}{' '} + </Text> + <Text type="md" style={[pal.textLight]}> + following + </Text> + </TouchableOpacity> + <Text type="md" style={[s.bold, pal.text]}> + {formatCount(view.postsCount)}{' '} + <Text type="md" style={[pal.textLight]}> + {pluralize(view.postsCount, 'post')} + </Text> + </Text> </View> - </TouchableWithoutFeedback> + {view.description && + view.descriptionRichText && + !view.moderation.profile.blur ? ( + <RichText + testID="profileHeaderDescription" + style={[styles.description, pal.text]} + numberOfLines={15} + richText={view.descriptionRichText} + /> + ) : undefined} + </> )} + <ProfileHeaderAlerts moderation={view.moderation} /> + </View> + {!isDesktop && !hideBackButton && ( <TouchableWithoutFeedback - testID="profileHeaderAviButton" - onPress={onPressAvi} - accessibilityRole="image" - accessibilityLabel={`View ${view.handle}'s avatar`} + onPress={onPressBack} + hitSlop={BACK_HITSLOP} + accessibilityRole="button" + accessibilityLabel="Back" accessibilityHint=""> - <View - style={[ - pal.view, - {borderColor: pal.colors.background}, - styles.avi, - ]}> - <UserAvatar - size={80} - avatar={view.avatar} - moderation={view.moderation.avatar} - /> + <View style={styles.backBtnWrapper}> + <BlurView style={styles.backBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="angle-left" style={s.white} /> + </BlurView> </View> </TouchableWithoutFeedback> - </View> - ) - }, -) + )} + <TouchableWithoutFeedback + testID="profileHeaderAviButton" + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityHint=""> + <View + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> + <UserAvatar + size={80} + avatar={view.avatar} + moderation={view.moderation.avatar} + /> + </View> + </TouchableWithoutFeedback> + </View> + ) +}) const styles = StyleSheet.create({ banner: { diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index e74a8cfe4..87378bba7 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -18,7 +18,11 @@ import {s} from 'lib/styles' const SECTIONS = ['Posts', 'Users'] -export const SearchResults = observer(({model}: {model: SearchUIModel}) => { +export const SearchResults = observer(function SearchResultsImpl({ + model, +}: { + model: SearchUIModel +}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() @@ -56,7 +60,11 @@ export const SearchResults = observer(({model}: {model: SearchUIModel}) => { ) }) -const PostResults = observer(({model}: {model: SearchUIModel}) => { +const PostResults = observer(function PostResultsImpl({ + model, +}: { + model: SearchUIModel +}) { const pal = usePalette('default') if (model.isPostsLoading) { return ( @@ -88,7 +96,11 @@ const PostResults = observer(({model}: {model: SearchUIModel}) => { ) }) -const Profiles = observer(({model}: {model: SearchUIModel}) => { +const Profiles = observer(function ProfilesImpl({ + model, +}: { + model: SearchUIModel +}) { const pal = usePalette('default') if (model.isProfilesLoading) { return ( diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx index 6f9fff52f..02a38a0eb 100644 --- a/src/view/com/search/Suggestions.tsx +++ b/src/view/com/search/Suggestions.tsx @@ -38,6 +38,9 @@ interface ProfileView { } type Item = Heading | RefWrapper | SuggestWrapper | ProfileView +// FIXME(dan): Figure out why the false positives +/* eslint-disable react/prop-types */ + export const Suggestions = observer( forwardRef(function SuggestionsImpl( { diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index b0ad01754..21cbbc547 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -25,7 +25,7 @@ interface PostMetaOpts { timestamp: string } -export const PostMeta = observer(function (opts: PostMetaOpts) { +export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) { const pal = usePalette('default') const displayName = opts.author.displayName || opts.author.handle const handle = opts.author.handle diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 7b2dd61f3..0765f65b2 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -3,6 +3,9 @@ import {observer} from 'mobx-react-lite' import {ago} from 'lib/strings/time' import {useStores} from 'state/index' +// FIXME(dan): Figure out why the false positives +/* eslint-disable react/prop-types */ + export const TimeElapsed = observer(function TimeElapsed({ timestamp, children, diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 91cdb08c7..164028708 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -14,7 +14,7 @@ import {NavigationProp} from 'lib/routes/types' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const ViewHeader = observer(function ({ +export const ViewHeader = observer(function ViewHeaderImpl({ title, canGoBack, showBackButton = true, @@ -140,70 +140,68 @@ function DesktopWebHeader({ ) } -const Container = observer( - ({ - children, - hideOnScroll, - showBorder, - }: { - children: React.ReactNode - hideOnScroll: boolean - showBorder?: boolean - }) => { - const store = useStores() - const pal = usePalette('default') - const interp = useAnimatedValue(0) +const Container = observer(function ContainerImpl({ + children, + hideOnScroll, + showBorder, +}: { + children: React.ReactNode + hideOnScroll: boolean + showBorder?: boolean +}) { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) - React.useEffect(() => { - if (store.shell.minimalShellMode) { - Animated.timing(interp, { - toValue: 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } else { - Animated.timing(interp, { - toValue: 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], + React.useEffect(() => { + if (store.shell.minimalShellMode) { + Animated.timing(interp, { + toValue: 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } else { + Animated.timing(interp, { + toValue: 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() } + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } - if (!hideOnScroll) { - return ( - <View - style={[ - styles.header, - styles.headerFixed, - pal.view, - pal.border, - showBorder && styles.border, - ]}> - {children} - </View> - ) - } + if (!hideOnScroll) { return ( - <Animated.View + <View style={[ styles.header, - styles.headerFloating, + styles.headerFixed, pal.view, pal.border, - transform, showBorder && styles.border, ]}> {children} - </Animated.View> + </View> ) - }, -) + } + return ( + <Animated.View + style={[ + styles.header, + styles.headerFloating, + pal.view, + pal.border, + transform, + showBorder && styles.border, + ]}> + {children} + </Animated.View> + ) +}) const styles = StyleSheet.create({ header: { diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index afd172c82..f5a3e6b50 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -14,7 +14,11 @@ export interface FABProps icon: JSX.Element } -export const FABInner = observer(({testID, icon, ...props}: FABProps) => { +export const FABInner = observer(function FABInnerImpl({ + testID, + icon, + ...props +}: FABProps) { const {isTablet} = useWebMediaQueries() const store = useStores() const interp = useAnimatedValue(0) diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index eb7eaaa49..3e8add5e9 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -9,41 +9,39 @@ import {usePalette} from 'lib/hooks/usePalette' import {colors} from 'lib/styles' import {HITSLOP_20} from 'lib/constants' -export const LoadLatestBtn = observer( - ({ - onPress, - label, - showIndicator, - }: { - onPress: () => void - label: string - showIndicator: boolean - minimalShellMode?: boolean // NOTE not used on mobile -prf - }) => { - const store = useStores() - const pal = usePalette('default') - const safeAreaInsets = useSafeAreaInsets() - return ( - <TouchableOpacity - style={[ - styles.loadLatest, - pal.borderDark, - pal.view, - !store.shell.minimalShellMode && { - bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]} - onPress={onPress} - hitSlop={HITSLOP_20} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityHint=""> - <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> - {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} - </TouchableOpacity> - ) - }, -) +export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ + onPress, + label, + showIndicator, +}: { + onPress: () => void + label: string + showIndicator: boolean + minimalShellMode?: boolean // NOTE not used on mobile -prf +}) { + const store = useStores() + const pal = usePalette('default') + const safeAreaInsets = useSafeAreaInsets() + return ( + <TouchableOpacity + style={[ + styles.loadLatest, + pal.borderDark, + pal.view, + !store.shell.minimalShellMode && { + bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), + }, + ]} + onPress={onPress} + hitSlop={HITSLOP_20} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityHint=""> + <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> + {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} + </TouchableOpacity> + ) +}) const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx index 6f40b3e1a..dbf350039 100644 --- a/src/view/com/util/post-embeds/ListEmbed.tsx +++ b/src/view/com/util/post-embeds/ListEmbed.tsx @@ -6,23 +6,21 @@ import {ListCard} from 'view/com/lists/ListCard' import {AppBskyGraphDefs} from '@atproto/api' import {s} from 'lib/styles' -export const ListEmbed = observer( - ({ - item, - style, - }: { - item: AppBskyGraphDefs.ListView - style?: StyleProp<ViewStyle> - }) => { - const pal = usePalette('default') +export const ListEmbed = observer(function ListEmbedImpl({ + item, + style, +}: { + item: AppBskyGraphDefs.ListView + style?: StyleProp<ViewStyle> +}) { + const pal = usePalette('default') - return ( - <View style={[pal.view, pal.border, s.border1, styles.container]}> - <ListCard list={item} style={[style, styles.card]} /> - </View> - ) - }, -) + return ( + <View style={[pal.view, pal.border, s.border1, styles.container]}> + <ListCard list={item} style={[style, styles.card]} /> + </View> + ) +}) const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 8fac86d34..32f9e13e1 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -19,7 +19,7 @@ import {CenteredView} from 'view/com/util/Views' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> export const AppPasswords = withAuthRequired( - observer(({}: Props) => { + observer(function AppPasswordsImpl({}: Props) { const pal = usePalette('default') const store = useStores() const {screen} = useAnalytics() diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index f4e1b0eb7..af4d01843 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -42,7 +42,7 @@ import {NavigationProp} from 'lib/routes/types' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> export const CustomFeedScreen = withAuthRequired( - observer((props: Props) => { + observer(function CustomFeedScreenImpl(props: Props) { const pal = usePalette('default') const store = useStores() const navigation = useNavigation<NavigationProp>() @@ -119,7 +119,10 @@ export const CustomFeedScreen = withAuthRequired( ) export const CustomFeedScreenInner = observer( - ({route, feedOwnerDid}: Props & {feedOwnerDid: string}) => { + function CustomFeedScreenInnerImpl({ + route, + feedOwnerDid, + }: Props & {feedOwnerDid: string}) { const store = useStores() const pal = usePalette('default') const {isTabletOrDesktop} = useWebMediaQueries() diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx index 11f38c26a..6aa7a9e31 100644 --- a/src/view/screens/DiscoverFeeds.tsx +++ b/src/view/screens/DiscoverFeeds.tsx @@ -19,7 +19,7 @@ import debounce from 'lodash.debounce' type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'> export const DiscoverFeedsScreen = withAuthRequired( - observer(({}: Props) => { + observer(function DiscoverFeedsScreenImpl({}: Props) { const store = useStores() const pal = usePalette('default') const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 6e0706737..97c6e8672 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -25,7 +25,7 @@ const MOBILE_HEADER_OFFSET = 40 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> export const FeedsScreen = withAuthRequired( - observer<Props>(({}: Props) => { + observer<Props>(function FeedsScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() const {isMobile} = useWebMediaQueries() diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 795d813d1..33cc2e110 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -28,7 +28,7 @@ const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired( - observer(({}: Props) => { + observer(function HomeScreenImpl({}: Props) { const store = useStores() const pagerRef = React.useRef<PagerRef>(null) const [selectedPage, setSelectedPage] = React.useState(0) @@ -142,152 +142,141 @@ export const HomeScreen = withAuthRequired( }), ) -const FeedPage = observer( - ({ - testID, - isPageFocused, - feed, - renderEmptyState, - }: { - testID?: string - feed: PostsFeedModel - isPageFocused: boolean - renderEmptyState?: () => JSX.Element - }) => { - const store = useStores() - const {isMobile} = useWebMediaQueries() - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) - const {screen, track} = useAnalytics() - const [headerOffset, setHeaderOffset] = React.useState( - isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, - ) - const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) - const isScreenFocused = useIsFocused() +const FeedPage = observer(function FeedPageImpl({ + testID, + isPageFocused, + feed, + renderEmptyState, +}: { + testID?: string + feed: PostsFeedModel + isPageFocused: boolean + renderEmptyState?: () => JSX.Element +}) { + const store = useStores() + const {isMobile} = useWebMediaQueries() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) + const {screen, track} = useAnalytics() + const [headerOffset, setHeaderOffset] = React.useState( + isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, + ) + const scrollElRef = React.useRef<FlatList>(null) + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) + const isScreenFocused = useIsFocused() - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) + React.useEffect(() => { + // called on first load + if (!feed.hasLoaded && isPageFocused) { + feed.setup() + } + }, [isPageFocused, feed]) - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - store.log.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, store, feed], - ) + const doPoll = React.useCallback( + (knownActive = false) => { + if ( + (!knownActive && appState !== 'active') || + !isScreenFocused || + !isPageFocused + ) { + return + } + if (feed.isLoading) { + return + } + store.log.debug('HomeScreen: Polling for new posts') + feed.checkForLatest() + }, + [appState, isScreenFocused, isPageFocused, store, feed], + ) - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: -headerOffset}) - resetMainScroll() - }, [headerOffset, resetMainScroll]) + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + resetMainScroll() + }, [headerOffset, resetMainScroll]) - const onSoftReset = React.useCallback(() => { - if (isPageFocused) { - scrollToTop() - feed.refresh() - } - }, [isPageFocused, scrollToTop, feed]) + const onSoftReset = React.useCallback(() => { + if (isPageFocused) { + scrollToTop() + feed.refresh() + } + }, [isPageFocused, scrollToTop, feed]) - // listens for resize events - React.useEffect(() => { - setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP) - }, [isMobile]) + // listens for resize events + React.useEffect(() => { + setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP) + }, [isMobile]) - // fires when page within screen is activated/deactivated - // - check for latest - React.useEffect(() => { - if (!isPageFocused || !isScreenFocused) { - return - } + // fires when page within screen is activated/deactivated + // - check for latest + React.useEffect(() => { + if (!isPageFocused || !isScreenFocused) { + return + } - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) + const softResetSub = store.onScreenSoftReset(onSoftReset) + const feedCleanup = feed.registerListeners() + const pollInterval = setInterval(doPoll, POLL_FREQ) - screen('Feed') - store.log.debug('HomeScreen: Updating feed') - feed.checkForLatest() - if (feed.hasContent) { - feed.update() - } + screen('Feed') + store.log.debug('HomeScreen: Updating feed') + feed.checkForLatest() + if (feed.hasContent) { + feed.update() + } - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [ - store, - doPoll, - onSoftReset, - screen, - feed, - isPageFocused, - isScreenFocused, - ]) + return () => { + clearInterval(pollInterval) + softResetSub.remove() + feedCleanup() + } + }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) - const onPressCompose = React.useCallback(() => { - track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) + const onPressCompose = React.useCallback(() => { + track('HomeScreen:PressCompose') + store.shell.openComposer({}) + }, [store, track]) - const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + const onPressTryAgain = React.useCallback(() => { + feed.refresh() + }, [feed]) - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + feed.refresh() + }, [feed, scrollToTop]) - const hasNew = feed.hasNewLatest && !feed.isRefreshing - return ( - <View testID={testID} style={s.h100pct}> - <Feed - testID={testID ? `${testID}-feed` : undefined} - key="default" - feed={feed} - scrollElRef={scrollElRef} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollEventThrottle={100} - renderEmptyState={renderEmptyState} - headerOffset={headerOffset} + const hasNew = feed.hasNewLatest && !feed.isRefreshing + return ( + <View testID={testID} style={s.h100pct}> + <Feed + testID={testID ? `${testID}-feed` : undefined} + key="default" + feed={feed} + scrollElRef={scrollElRef} + onPressTryAgain={onPressTryAgain} + onScroll={onMainScroll} + scrollEventThrottle={100} + renderEmptyState={renderEmptyState} + headerOffset={headerOffset} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + minimalShellMode={store.shell.minimalShellMode} /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new posts" - showIndicator={hasNew} - minimalShellMode={store.shell.minimalShellMode} - /> - )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - }, -) + )} + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) +}) diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 10fa87080..7bbb6beee 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -27,7 +27,7 @@ type Props = NativeStackScreenProps< 'ModerationBlockedAccounts' > export const ModerationBlockedAccounts = withAuthRequired( - observer(({}: Props) => { + observer(function ModerationBlockedAccountsImpl({}: Props) { const pal = usePalette('default') const store = useStores() const {isTabletOrDesktop} = useWebMediaQueries() @@ -116,6 +116,8 @@ export const ModerationBlockedAccounts = withAuthRequired( onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {blockedAccounts.isLoading && <ActivityIndicator />} diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index eb822270a..31c46e640 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -27,7 +27,7 @@ type Props = NativeStackScreenProps< 'ModerationMutedAccounts' > export const ModerationMutedAccounts = withAuthRequired( - observer(({}: Props) => { + observer(function ModerationMutedAccountsImpl({}: Props) { const pal = usePalette('default') const store = useStores() const {isTabletOrDesktop} = useWebMediaQueries() @@ -112,6 +112,8 @@ export const ModerationMutedAccounts = withAuthRequired( onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> {mutedAccounts.isLoading && <ActivityIndicator />} diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 5dda965db..058f09034 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -24,7 +24,7 @@ type Props = NativeStackScreenProps< 'Notifications' > export const NotificationsScreen = withAuthRequired( - observer(({}: Props) => { + observer(function NotificationsScreenImpl({}: Props) { const store = useStores() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index bd6dd8b39..49c13bfa3 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -48,7 +48,9 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesHomeFeed' > -export const PreferencesHomeFeed = observer(({navigation}: Props) => { +export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ + navigation, +}: Props) { const pal = usePalette('default') const store = useStores() const {isTabletOrDesktop} = useWebMediaQueries() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index a78650b3f..69b5ceee6 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -32,7 +32,7 @@ import {combinedDisplayName} from 'lib/strings/display-names' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> export const ProfileScreen = withAuthRequired( - observer(({route}: Props) => { + observer(function ProfileScreenImpl({route}: Props) { const store = useStores() const {screen, track} = useAnalytics() const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index e86a457b6..322f99486 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -23,7 +23,7 @@ import {s} from 'lib/styles' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> export const ProfileListScreen = withAuthRequired( - observer(({route}: Props) => { + observer(function ProfileListScreenImpl({route}: Props) { const store = useStores() const navigation = useNavigation<NavigationProp>() const {isTabletOrDesktop} = useWebMediaQueries() diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index dc9c253cb..d5c02ba63 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -35,7 +35,7 @@ import {Link, TextLink} from 'view/com/util/Link' type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> export const SavedFeeds = withAuthRequired( - observer(({}: Props) => { + observer(function SavedFeedsImpl({}: Props) { const pal = usePalette('default') const store = useStores() const {isMobile, isTabletOrDesktop} = useWebMediaQueries() @@ -151,96 +151,98 @@ export const SavedFeeds = withAuthRequired( }), ) -const ListItem = observer( - ({item, drag}: {item: CustomFeedModel; drag: () => void}) => { - const pal = usePalette('default') - const store = useStores() - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) - const isPinned = savedFeeds.isPinned(item) +const ListItem = observer(function ListItemImpl({ + item, + drag, +}: { + item: CustomFeedModel + drag: () => void +}) { + const pal = usePalette('default') + const store = useStores() + const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + const isPinned = savedFeeds.isPinned(item) - const onTogglePinned = useCallback(() => { - Haptics.default() - savedFeeds.togglePinnedFeed(item).catch(e => { + const onTogglePinned = useCallback(() => { + Haptics.default() + savedFeeds.togglePinnedFeed(item).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [savedFeeds, item, store]) + const onPressUp = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'up').catch(e => { Toast.show('There was an issue contacting the server') - store.log.error('Failed to toggle pinned feed', {e}) - }) - }, [savedFeeds, item, store]) - const onPressUp = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'up').catch(e => { - Toast.show('There was an issue contacting the server') - store.log.error('Failed to set pinned feed order', {e}) - }), - [store, savedFeeds, item], - ) - const onPressDown = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'down').catch(e => { - Toast.show('There was an issue contacting the server') - store.log.error('Failed to set pinned feed order', {e}) - }), - [store, savedFeeds, item], - ) + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) + const onPressDown = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'down').catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) - return ( - <ScaleDecorator> - <ShadowDecorator> - <Pressable + return ( + <ScaleDecorator> + <ShadowDecorator> + <Pressable + accessibilityRole="button" + onLongPress={isPinned ? drag : undefined} + delayLongPress={200} + style={[styles.itemContainer, pal.border]}> + {isPinned && isWeb ? ( + <View style={styles.webArrowButtonsContainer}> + <TouchableOpacity accessibilityRole="button" onPress={onPressUp}> + <FontAwesomeIcon + icon="arrow-up" + size={12} + style={[pal.text, styles.webArrowUpButton]} + /> + </TouchableOpacity> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressDown}> + <FontAwesomeIcon + icon="arrow-down" + size={12} + style={[pal.text]} + /> + </TouchableOpacity> + </View> + ) : isPinned ? ( + <FontAwesomeIcon + icon="bars" + size={20} + color={pal.colors.text} + style={s.ml20} + /> + ) : null} + <CustomFeed + key={item.data.uri} + item={item} + showSaveBtn + style={styles.noBorder} + /> + <TouchableOpacity accessibilityRole="button" - onLongPress={isPinned ? drag : undefined} - delayLongPress={200} - style={[styles.itemContainer, pal.border]}> - {isPinned && isWeb ? ( - <View style={styles.webArrowButtonsContainer}> - <TouchableOpacity - accessibilityRole="button" - onPress={onPressUp}> - <FontAwesomeIcon - icon="arrow-up" - size={12} - style={[pal.text, styles.webArrowUpButton]} - /> - </TouchableOpacity> - <TouchableOpacity - accessibilityRole="button" - onPress={onPressDown}> - <FontAwesomeIcon - icon="arrow-down" - size={12} - style={[pal.text]} - /> - </TouchableOpacity> - </View> - ) : isPinned ? ( - <FontAwesomeIcon - icon="bars" - size={20} - color={pal.colors.text} - style={s.ml20} - /> - ) : null} - <CustomFeed - key={item.data.uri} - item={item} - showSaveBtn - style={styles.noBorder} + hitSlop={10} + onPress={onTogglePinned}> + <FontAwesomeIcon + icon="thumb-tack" + size={20} + color={isPinned ? colors.blue3 : pal.colors.icon} /> - <TouchableOpacity - accessibilityRole="button" - hitSlop={10} - onPress={onTogglePinned}> - <FontAwesomeIcon - icon="thumb-tack" - size={20} - color={isPinned ? colors.blue3 : pal.colors.icon} - /> - </TouchableOpacity> - </Pressable> - </ShadowDecorator> - </ScaleDecorator> - ) - }, -) + </TouchableOpacity> + </Pressable> + </ShadowDecorator> + </ScaleDecorator> + ) +}) const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index f325b1233..2d0c0288a 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -18,7 +18,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> export const SearchScreen = withAuthRequired( - observer(({navigation, route}: Props) => { + observer(function SearchScreenImpl({navigation, route}: Props) { const store = useStores() const params = route.params || {} const foafs = React.useMemo<FoafsModel>( diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index 6ad468fa9..b545a643d 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -30,7 +30,7 @@ import {isAndroid, isIOS} from 'platform/detection' type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> export const SearchScreen = withAuthRequired( - observer<Props>(({}: Props) => { + observer<Props>(function SearchScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() const scrollViewRef = React.useRef<ScrollView>(null) diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index ac155887c..d7c6a80b7 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -6,73 +6,71 @@ import {ComposerOpts} from 'state/models/ui/shell' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' -export const Composer = observer( - ({ - active, - winHeight, - replyTo, - onPost, - onClose, - quote, - mention, - }: { - active: boolean - winHeight: number - replyTo?: ComposerOpts['replyTo'] - onPost?: ComposerOpts['onPost'] - onClose: () => void - quote?: ComposerOpts['quote'] - mention?: ComposerOpts['mention'] - }) => { - const pal = usePalette('default') - const initInterp = useAnimatedValue(0) +export const Composer = observer(function ComposerImpl({ + active, + winHeight, + replyTo, + onPost, + onClose, + quote, + mention, +}: { + active: boolean + winHeight: number + replyTo?: ComposerOpts['replyTo'] + onPost?: ComposerOpts['onPost'] + onClose: () => void + quote?: ComposerOpts['quote'] + mention?: ComposerOpts['mention'] +}) { + const pal = usePalette('default') + const initInterp = useAnimatedValue(0) - useEffect(() => { - if (active) { - Animated.timing(initInterp, { - toValue: 1, - duration: 300, - easing: Easing.out(Easing.exp), - useNativeDriver: true, - }).start() - } else { - initInterp.setValue(0) - } - }, [initInterp, active]) - const wrapperAnimStyle = { - transform: [ - { - translateY: initInterp.interpolate({ - inputRange: [0, 1], - outputRange: [winHeight, 0], - }), - }, - ], + useEffect(() => { + if (active) { + Animated.timing(initInterp, { + toValue: 1, + duration: 300, + easing: Easing.out(Easing.exp), + useNativeDriver: true, + }).start() + } else { + initInterp.setValue(0) } + }, [initInterp, active]) + const wrapperAnimStyle = { + transform: [ + { + translateY: initInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winHeight, 0], + }), + }, + ], + } - // rendering - // = + // rendering + // = - if (!active) { - return <View /> - } + if (!active) { + return <View /> + } - return ( - <Animated.View - style={[styles.wrapper, pal.view, wrapperAnimStyle]} - aria-modal - accessibilityViewIsModal> - <ComposePost - replyTo={replyTo} - onPost={onPost} - onClose={onClose} - quote={quote} - mention={mention} - /> - </Animated.View> - ) - }, -) + return ( + <Animated.View + style={[styles.wrapper, pal.view, wrapperAnimStyle]} + aria-modal + accessibilityViewIsModal> + <ComposePost + replyTo={replyTo} + onPost={onPost} + onClose={onClose} + quote={quote} + mention={mention} + /> + </Animated.View> + ) +}) const styles = StyleSheet.create({ wrapper: { diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index b32ba90c4..f4b2d9a4c 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -8,54 +8,52 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' const BOTTOM_BAR_HEIGHT = 61 -export const Composer = observer( - ({ - active, - replyTo, - quote, - onPost, - onClose, - mention, - }: { - active: boolean - winHeight: number - replyTo?: ComposerOpts['replyTo'] - quote: ComposerOpts['quote'] - onPost?: ComposerOpts['onPost'] - onClose: () => void - mention?: ComposerOpts['mention'] - }) => { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() +export const Composer = observer(function ComposerImpl({ + active, + replyTo, + quote, + onPost, + onClose, + mention, +}: { + active: boolean + winHeight: number + replyTo?: ComposerOpts['replyTo'] + quote: ComposerOpts['quote'] + onPost?: ComposerOpts['onPost'] + onClose: () => void + mention?: ComposerOpts['mention'] +}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() - // rendering - // = + // rendering + // = - if (!active) { - return <View /> - } + if (!active) { + return <View /> + } - return ( - <View style={styles.mask} aria-modal accessibilityViewIsModal> - <View - style={[ - styles.container, - isMobile && styles.containerMobile, - pal.view, - pal.border, - ]}> - <ComposePost - replyTo={replyTo} - quote={quote} - onPost={onPost} - onClose={onClose} - mention={mention} - /> - </View> + return ( + <View style={styles.mask} aria-modal accessibilityViewIsModal> + <View + style={[ + styles.container, + isMobile && styles.containerMobile, + pal.view, + pal.border, + ]}> + <ComposePost + replyTo={replyTo} + quote={quote} + onPost={onPost} + onClose={onClose} + mention={mention} + /> </View> - ) - }, -) + </View> + ) +}) const styles = StyleSheet.create({ mask: { diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 0428e54c3..3379d0501 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -44,7 +44,7 @@ import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {isWeb} from 'platform/detection' import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' -export const DrawerContent = observer(() => { +export const DrawerContent = observer(function DrawerContentImpl() { const theme = useTheme() const pal = usePalette('default') const store = useStores() @@ -400,7 +400,7 @@ function MenuItem({ ) } -const InviteCodes = observer(() => { +const InviteCodes = observer(function InviteCodesImpl() { const {track} = useAnalytics() const store = useStores() const pal = usePalette('default') diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 60a6c8e67..4a34371ea 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -32,7 +32,9 @@ import {UserAvatar} from 'view/com/util/UserAvatar' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' -export const BottomBar = observer(({navigation}: BottomTabBarProps) => { +export const BottomBar = observer(function BottomBarImpl({ + navigation, +}: BottomTabBarProps) { const store = useStores() const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 50cfa0570..ee575c217 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -23,7 +23,7 @@ import {Link} from 'view/com/util/Link' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {makeProfileLink} from 'lib/routes/links' -export const BottomBarWeb = observer(() => { +export const BottomBarWeb = observer(function BottomBarWebImpl() { const store = useStores() const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 6df121fae..852731950 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -40,7 +40,7 @@ import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' import {router} from '../../../routes' import {makeProfileLink} from 'lib/routes/links' -const ProfileCard = observer(() => { +const ProfileCard = observer(function ProfileCardImpl() { const store = useStores() const {isDesktop} = useWebMediaQueries() const size = isDesktop ? 64 : 48 @@ -103,78 +103,82 @@ interface NavItemProps { iconFilled: JSX.Element label: string } -const NavItem = observer( - ({count, href, icon, iconFilled, label}: NavItemProps) => { - const pal = usePalette('default') - const store = useStores() - const {isDesktop, isTablet} = useWebMediaQueries() - const [pathName] = React.useMemo(() => router.matchPath(href), [href]) - const currentRouteInfo = useNavigationState(state => { - if (!state) { - return {name: 'Home'} +const NavItem = observer(function NavItemImpl({ + count, + href, + icon, + iconFilled, + label, +}: NavItemProps) { + const pal = usePalette('default') + const store = useStores() + const {isDesktop, isTablet} = useWebMediaQueries() + const [pathName] = React.useMemo(() => router.matchPath(href), [href]) + const currentRouteInfo = useNavigationState(state => { + if (!state) { + return {name: 'Home'} + } + return getCurrentRoute(state) + }) + let isCurrent = + currentRouteInfo.name === 'Profile' + ? isTab(currentRouteInfo.name, pathName) && + (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === + store.me.handle + : isTab(currentRouteInfo.name, pathName) + const {onPress} = useLinkProps({to: href}) + const onPressWrapped = React.useCallback( + (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { + if (e.ctrlKey || e.metaKey || e.altKey) { + return } - return getCurrentRoute(state) - }) - let isCurrent = - currentRouteInfo.name === 'Profile' - ? isTab(currentRouteInfo.name, pathName) && - (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === - store.me.handle - : isTab(currentRouteInfo.name, pathName) - const {onPress} = useLinkProps({to: href}) - const onPressWrapped = React.useCallback( - (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => { - if (e.ctrlKey || e.metaKey || e.altKey) { - return - } - e.preventDefault() - if (isCurrent) { - store.emitScreenSoftReset() - } else { - onPress() - } - }, - [onPress, isCurrent, store], - ) + e.preventDefault() + if (isCurrent) { + store.emitScreenSoftReset() + } else { + onPress() + } + }, + [onPress, isCurrent, store], + ) - return ( - <PressableWithHover - style={styles.navItemWrapper} - hoverStyle={pal.viewLight} - // @ts-ignore the function signature differs on web -prf - onPress={onPressWrapped} - // @ts-ignore web only -prf - href={href} - dataSet={{noUnderline: 1}} - accessibilityRole="tab" - accessibilityLabel={label} - accessibilityHint=""> - <View - style={[ - styles.navItemIconWrapper, - isTablet && styles.navItemIconWrapperTablet, - ]}> - {isCurrent ? iconFilled : icon} - {typeof count === 'string' && count ? ( - <Text - type="button" - style={[ - styles.navItemCount, - isTablet && styles.navItemCountTablet, - ]}> - {count} - </Text> - ) : null} - </View> - {isDesktop && ( - <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> - {label} + return ( + <PressableWithHover + style={styles.navItemWrapper} + hoverStyle={pal.viewLight} + // @ts-ignore the function signature differs on web -prf + onPress={onPressWrapped} + // @ts-ignore web only -prf + href={href} + dataSet={{noUnderline: 1}} + accessibilityRole="tab" + accessibilityLabel={label} + accessibilityHint=""> + <View + style={[ + styles.navItemIconWrapper, + isTablet && styles.navItemIconWrapperTablet, + ]}> + {isCurrent ? iconFilled : icon} + {typeof count === 'string' && count ? ( + <Text + type="button" + style={[ + styles.navItemCount, + isTablet && styles.navItemCountTablet, + ]}> + {count} </Text> - )} - </PressableWithHover> - ) - }, -) + ) : null} + </View> + {isDesktop && ( + <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}> + {label} + </Text> + )} + </PressableWithHover> + ) +}) function ComposeBtn() { const store = useStores() diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 797058d6c..e17fa6a84 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -13,7 +13,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {pluralize} from 'lib/strings/helpers' import {formatCount} from 'view/com/util/numeric/format' -export const DesktopRightNav = observer(function DesktopRightNav() { +export const DesktopRightNav = observer(function DesktopRightNavImpl() { const store = useStores() const pal = usePalette('default') const palError = usePalette('error') @@ -78,7 +78,7 @@ export const DesktopRightNav = observer(function DesktopRightNav() { ) }) -const InviteCodes = observer(() => { +const InviteCodes = observer(function InviteCodesImpl() { const store = useStores() const pal = usePalette('default') diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index c5080e866..e313450f1 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -24,7 +24,7 @@ import {isStateAtTabRoot} from 'lib/routes/helpers' import {SafeAreaProvider} from 'react-native-safe-area-context' import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' -const ShellInner = observer(() => { +const ShellInner = observer(function ShellInnerImpl() { const store = useStores() useOTAUpdate() // this hook polls for OTA updates every few seconds const winDim = useWindowDimensions() @@ -81,7 +81,7 @@ const ShellInner = observer(() => { ) }) -export const Shell: React.FC = observer(() => { +export const Shell: React.FC = observer(function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 6182f1ba4..124341917 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -17,7 +17,7 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -const ShellInner = observer(() => { +const ShellInner = observer(function ShellInnerImpl() { const store = useStores() const {isDesktop, isMobile} = useWebMediaQueries() const navigator = useNavigation<NavigationProp>() @@ -71,7 +71,7 @@ const ShellInner = observer(() => { ) }) -export const Shell: React.FC = observer(() => { +export const Shell: React.FC = observer(function ShellImpl() { const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( <View style={[s.hContentRegion, pageBg]}> |