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
zio/stable
dan 2023-09-08 01:36:08 +01:00 committed by GitHub
parent 69209c988f
commit 8a93321fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 2868 additions and 2836 deletions

View File

@ -26,4 +26,7 @@ module.exports = {
'*.html', '*.html',
'bskyweb', 'bskyweb',
], ],
settings: {
componentWrapperFunctions: ['observer'],
},
} }

View File

@ -19,7 +19,7 @@ import {handleLink} from './Navigation'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
const App = observer(() => { const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined, undefined,
) )

View File

@ -10,7 +10,7 @@ import {ToastContainer} from './view/com/util/Toast.web'
import {ThemeProvider} from 'lib/ThemeContext' import {ThemeProvider} from 'lib/ThemeContext'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
const App = observer(() => { const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined, undefined,
) )

View File

@ -330,7 +330,7 @@ function NotificationsTabNavigator() {
) )
} }
const MyProfileTabNavigator = observer(() => { const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
const store = useStores() const store = useStores()
return ( return (
@ -360,7 +360,7 @@ const MyProfileTabNavigator = observer(() => {
* The FlatNavigator is used by Web to represent the routes * The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack. * in a single ("flat") stack.
*/ */
const FlatNavigator = observer(() => { const FlatNavigator = observer(function FlatNavigatorImpl() {
const pal = usePalette('default') const pal = usePalette('default')
const unreadCountLabel = useStores().me.notifications.unreadCountLabel const unreadCountLabel = useStores().me.notifications.unreadCountLabel
const title = (page: string) => bskyTitle(page, unreadCountLabel) const title = (page: string) => bskyTitle(page, unreadCountLabel)

View File

@ -16,7 +16,7 @@ enum ScreenState {
S_CreateAccount, S_CreateAccount,
} }
export const LoggedOut = observer(() => { export const LoggedOut = observer(function LoggedOutImpl() {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {screen} = useAnalytics() const {screen} = useAnalytics()

View File

@ -8,7 +8,7 @@ import {useStores} from 'state/index'
import {Welcome} from './onboarding/Welcome' import {Welcome} from './onboarding/Welcome'
import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
export const Onboarding = observer(() => { export const Onboarding = observer(function OnboardingImpl() {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()

View File

@ -20,114 +20,116 @@ import {Step1} from './Step1'
import {Step2} from './Step2' import {Step2} from './Step2'
import {Step3} from './Step3' import {Step3} from './Step3'
export const CreateAccount = observer( export const CreateAccount = observer(function CreateAccountImpl({
({onPressBack}: {onPressBack: () => void}) => { onPressBack,
const {track, screen} = useAnalytics() }: {
const pal = usePalette('default') onPressBack: () => void
const store = useStores() }) {
const model = React.useMemo(() => new CreateAccountModel(store), [store]) const {track, screen} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const model = React.useMemo(() => new CreateAccountModel(store), [store])
React.useEffect(() => { React.useEffect(() => {
screen('CreateAccount') screen('CreateAccount')
}, [screen]) }, [screen])
React.useEffect(() => { React.useEffect(() => {
model.fetchServiceDescription() model.fetchServiceDescription()
}, [model]) }, [model])
const onPressRetryConnect = React.useCallback( const onPressRetryConnect = React.useCallback(
() => model.fetchServiceDescription(), () => model.fetchServiceDescription(),
[model], [model],
) )
const onPressBackInner = React.useCallback(() => { const onPressBackInner = React.useCallback(() => {
if (model.canBack) { if (model.canBack) {
model.back() model.back()
} else { } else {
onPressBack() onPressBack()
}
}, [model, onPressBack])
const onPressNext = React.useCallback(async () => {
if (!model.canNext) {
return
}
if (model.step < 3) {
model.next()
} else {
try {
await model.submit()
} catch {
// dont need to handle here
} finally {
track('Try Create Account')
} }
}, [model, onPressBack]) }
}, [model, track])
const onPressNext = React.useCallback(async () => { return (
if (!model.canNext) { <LoggedOutLayout
return leadin={`Step ${model.step}`}
} title="Create Account"
if (model.step < 3) { description="We're so excited to have you join us!">
model.next() <ScrollView testID="createAccount" style={pal.view}>
} else { <KeyboardAvoidingView behavior="padding">
try { <View style={styles.stepContainer}>
await model.submit() {model.step === 1 && <Step1 model={model} />}
} catch { {model.step === 2 && <Step2 model={model} />}
// dont need to handle here {model.step === 3 && <Step3 model={model} />}
} finally { </View>
track('Try Create Account') <View style={[s.flexRow, s.pl20, s.pr20]}>
} <TouchableOpacity
} onPress={onPressBackInner}
}, [model, track]) testID="backBtn"
accessibilityRole="button">
return ( <Text type="xl" style={pal.link}>
<LoggedOutLayout Back
leadin={`Step ${model.step}`} </Text>
title="Create Account" </TouchableOpacity>
description="We're so excited to have you join us!"> <View style={s.flex1} />
<ScrollView testID="createAccount" style={pal.view}> {model.canNext ? (
<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 <TouchableOpacity
onPress={onPressBackInner} testID="nextBtn"
testID="backBtn" onPress={onPressNext}
accessibilityRole="button"> accessibilityRole="button">
<Text type="xl" style={pal.link}> {model.isProcessing ? (
Back <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">
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> ) : model.isFetchingServiceDescription ? (
{model.canNext ? ( <>
<TouchableOpacity <ActivityIndicator color="#fff" />
testID="nextBtn" <Text type="xl" style={[pal.text, s.pr5]}>
onPress={onPressNext} Connecting...
accessibilityRole="button"> </Text>
{model.isProcessing ? ( </>
<ActivityIndicator /> ) : undefined}
) : ( </View>
<Text type="xl-bold" style={[pal.link, s.pr5]}> <View style={s.footerSpacer} />
Next </KeyboardAvoidingView>
</Text> </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({ const styles = StyleSheet.create({
stepContainer: { stepContainer: {

View File

@ -20,7 +20,11 @@ import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
* @field Bluesky (default) * @field Bluesky (default)
* @field Other (staging, local dev, your own PDS, etc.) * @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 pal = usePalette('default')
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)

View File

@ -21,7 +21,11 @@ import {useStores} from 'state/index'
* @field Birth date * @field Birth date
* @readonly Terms of service & privacy policy * @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 pal = usePalette('default')
const store = useStores() const store = useStores()

View File

@ -13,7 +13,11 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
/** STEP 3: Your user handle /** STEP 3: Your user handle
* @field 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') const pal = usePalette('default')
return ( return (
<View> <View>

View File

@ -15,7 +15,9 @@ import {RECOMMENDED_FEEDS} from 'lib/constants'
type Props = { type Props = {
next: () => void next: () => void
} }
export const RecommendedFeeds = observer(({next}: Props) => { export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
next,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries() const {isTabletOrMobile} = useWebMediaQueries()

View File

@ -13,130 +13,134 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {makeRecordUri} from 'lib/strings/url-helpers' import {makeRecordUri} from 'lib/strings/url-helpers'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
export const RecommendedFeedsItem = observer( export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
({did, rkey}: {did: string; rkey: string}) => { did,
const {isMobile} = useWebMediaQueries() rkey,
const pal = usePalette('default') }: {
const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey) did: string
const item = useCustomFeed(uri) rkey: string
if (!item) return null }) {
const onToggle = async () => { const {isMobile} = useWebMediaQueries()
if (item.isSaved) { const pal = usePalette('default')
try { const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
await item.unsave() const item = useCustomFeed(uri)
} catch (e) { if (!item) return null
Toast.show('There was an issue contacting your server') const onToggle = async () => {
console.error('Failed to unsave feed', {e}) if (item.isSaved) {
} try {
} else { await item.unsave()
try { } catch (e) {
await item.save() Toast.show('There was an issue contacting your server')
await item.pin() console.error('Failed to unsave feed', {e})
} catch (e) { }
Toast.show('There was an issue contacting your server') } else {
console.error('Failed to pin feed', {e}) 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}`}> return (
<View <View testID={`feed-${item.displayName}`}>
style={[ <View
pal.border, style={[
{ pal.border,
flex: isMobile ? 1 : undefined, {
flexDirection: 'row', flex: isMobile ? 1 : undefined,
gap: 18, flexDirection: 'row',
maxWidth: isMobile ? undefined : 670, gap: 18,
borderRightWidth: isMobile ? undefined : 1, maxWidth: isMobile ? undefined : 670,
paddingHorizontal: 24, borderRightWidth: isMobile ? undefined : 1,
paddingVertical: isMobile ? 12 : 24, paddingHorizontal: 24,
borderTopWidth: 1, paddingVertical: isMobile ? 12 : 24,
}, borderTopWidth: 1,
]}> },
<View style={{marginTop: 2}}> ]}>
<UserAvatar type="algo" size={42} avatar={item.data.avatar} /> <View style={{marginTop: 2}}>
</View> <UserAvatar type="algo" size={42} avatar={item.data.avatar} />
<View style={{flex: isMobile ? 1 : undefined}}> </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 <Text
type="2xl-bold" type="xl"
numberOfLines={1} style={[
style={[pal.text, {fontSize: 19}]}> pal.text,
{item.displayName} {
flex: isMobile ? 1 : undefined,
maxWidth: 550,
marginBottom: 18,
},
]}
numberOfLines={6}>
{item.data.description}
</Text> </Text>
) : null}
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
by {sanitizeHandle(item.data.creator.handle, '@')} <Button
</Text> type="inverted"
style={{paddingVertical: 6}}
{item.data.description ? ( onPress={onToggle}>
<Text <View
type="xl" style={{
style={[ flexDirection: 'row',
pal.text, alignItems: 'center',
{ paddingRight: 2,
flex: isMobile ? 1 : undefined, gap: 6,
maxWidth: 550, }}>
marginBottom: 18, {item.isSaved ? (
}, <>
]} <FontAwesomeIcon
numberOfLines={6}> icon="check"
{item.data.description} size={16}
</Text> color={pal.colors.textInverted}
) : null} />
<Text type="lg-medium" style={pal.textInverted}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> Added
<Button </Text>
type="inverted" </>
style={{paddingVertical: 6}} ) : (
onPress={onToggle}> <>
<View <FontAwesomeIcon
style={{ icon="plus"
flexDirection: 'row', size={16}
alignItems: 'center', color={pal.colors.textInverted}
paddingRight: 2, />
gap: 6, <Text type="lg-medium" style={pal.textInverted}>
}}> Add
{item.isSaved ? ( </Text>
<> </>
<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>
</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>
</View> </View>
) </View>
}, )
) })

View File

@ -14,7 +14,9 @@ type Props = {
skip: () => void skip: () => void
} }
export const WelcomeDesktop = observer(({next}: Props) => { export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
next,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const horizontal = useMediaQuery({minWidth: 1300}) const horizontal = useMediaQuery({minWidth: 1300})
const title = ( const title = (

View File

@ -13,7 +13,10 @@ type Props = {
skip: () => void skip: () => void
} }
export const WelcomeMobile = observer(({next, skip}: Props) => { export const WelcomeMobile = observer(function WelcomeMobileImpl({
next,
skip,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (

View File

@ -17,7 +17,7 @@ import {STATUS_PAGE_URL} from 'lib/constants'
export const withAuthRequired = <P extends object>( export const withAuthRequired = <P extends object>(
Component: React.ComponentType<P>, Component: React.ComponentType<P>,
): React.FC<P> => ): React.FC<P> =>
observer((props: P) => { observer(function AuthRequired(props: P) {
const store = useStores() const store = useStores()
if (store.session.isResumingSession) { if (store.session.isResumingSession) {
return <Loading /> return <Loading />

View File

@ -16,7 +16,7 @@ interface Props {
gallery: GalleryModel gallery: GalleryModel
} }
export const Gallery = observer(function ({gallery}: Props) { export const Gallery = observer(function GalleryImpl({gallery}: Props) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()

View File

@ -8,90 +8,88 @@ import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme' import {useGrapheme} from '../hooks/useGrapheme'
export const Autocomplete = observer( export const Autocomplete = observer(function AutocompleteImpl({
({ view,
view, onSelect,
onSelect, }: {
}: { view: UserAutocompleteModel
view: UserAutocompleteModel onSelect: (item: string) => void
onSelect: (item: string) => void }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const positionInterp = useAnimatedValue(0)
const positionInterp = useAnimatedValue(0) const {getGraphemeString} = useGrapheme()
const {getGraphemeString} = useGrapheme()
useEffect(() => { useEffect(() => {
Animated.timing(positionInterp, { Animated.timing(positionInterp, {
toValue: view.isActive ? 1 : 0, toValue: view.isActive ? 1 : 0,
duration: 200, duration: 200,
useNativeDriver: true, useNativeDriver: true,
}).start() }).start()
}, [positionInterp, view.isActive]) }, [positionInterp, view.isActive])
const topAnimStyle = { const topAnimStyle = {
transform: [ transform: [
{ {
translateY: positionInterp.interpolate({ translateY: positionInterp.interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: [200, 0], outputRange: [200, 0],
}), }),
}, },
], ],
} }
return ( return (
<Animated.View style={topAnimStyle}> <Animated.View style={topAnimStyle}>
{view.isActive ? ( {view.isActive ? (
<View style={[pal.view, styles.container, pal.border]}> <View style={[pal.view, styles.container, pal.border]}>
{view.suggestions.length > 0 ? ( {view.suggestions.length > 0 ? (
view.suggestions.slice(0, 5).map(item => { view.suggestions.slice(0, 5).map(item => {
// Eventually use an average length // Eventually use an average length
const MAX_CHARS = 40 const MAX_CHARS = 40
const MAX_HANDLE_CHARS = 20 const MAX_HANDLE_CHARS = 20
// Using this approach because styling is not respecting // Using this approach because styling is not respecting
// bounding box wrapping (before converting to ellipsis) // bounding box wrapping (before converting to ellipsis)
const {name: displayHandle, remainingCharacters} = const {name: displayHandle, remainingCharacters} =
getGraphemeString(item.handle, MAX_HANDLE_CHARS) getGraphemeString(item.handle, MAX_HANDLE_CHARS)
const {name: displayName} = getGraphemeString( const {name: displayName} = getGraphemeString(
item.displayName ?? item.handle, item.displayName ?? item.handle,
MAX_CHARS - MAX_CHARS -
MAX_HANDLE_CHARS + MAX_HANDLE_CHARS +
(remainingCharacters > 0 ? remainingCharacters : 0), (remainingCharacters > 0 ? remainingCharacters : 0),
) )
return ( return (
<TouchableOpacity <TouchableOpacity
testID="autocompleteButton" testID="autocompleteButton"
key={item.handle} key={item.handle}
style={[pal.border, styles.item]} style={[pal.border, styles.item]}
onPress={() => onSelect(item.handle)} onPress={() => onSelect(item.handle)}
accessibilityLabel={`Select ${item.handle}`} accessibilityLabel={`Select ${item.handle}`}
accessibilityHint=""> accessibilityHint="">
<View style={styles.avatarAndHandle}> <View style={styles.avatarAndHandle}>
<UserAvatar avatar={item.avatar ?? null} size={24} /> <UserAvatar avatar={item.avatar ?? null} size={24} />
<Text type="md-medium" style={pal.text}> <Text type="md-medium" style={pal.text}>
{displayName} {displayName}
</Text>
</View>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
@{displayHandle}
</Text> </Text>
</TouchableOpacity> </View>
) <Text type="sm" style={pal.textLight} numberOfLines={1}>
}) @{displayHandle}
) : ( </Text>
<Text type="sm" style={[pal.text, pal.border, styles.noResults]}> </TouchableOpacity>
No result )
</Text> })
)} ) : (
</View> <Text type="sm" style={[pal.text, pal.border, styles.noResults]}>
) : null} No result
</Animated.View> </Text>
) )}
}, </View>
) ) : null}
</Animated.View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

@ -15,120 +15,118 @@ import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
export const CustomFeed = observer( export const CustomFeed = observer(function CustomFeedImpl({
({ item,
item, style,
style, showSaveBtn = false,
showSaveBtn = false, showDescription = false,
showDescription = false, showLikes = false,
showLikes = false, }: {
}: { item: CustomFeedModel
item: CustomFeedModel style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> showSaveBtn?: boolean
showSaveBtn?: boolean showDescription?: boolean
showDescription?: boolean showLikes?: boolean
showLikes?: boolean }) {
}) => { const store = useStores()
const store = useStores() const pal = usePalette('default')
const pal = usePalette('default') const navigation = useNavigation<NavigationProp>()
const navigation = useNavigation<NavigationProp>()
const onToggleSaved = React.useCallback(async () => { const onToggleSaved = React.useCallback(async () => {
if (item.isSaved) { if (item.isSaved) {
store.shell.openModal({ store.shell.openModal({
name: 'confirm', name: 'confirm',
title: 'Remove from my feeds', title: 'Remove from my feeds',
message: `Remove ${item.displayName} from my feeds?`, message: `Remove ${item.displayName} from my feeds?`,
onPressConfirm: async () => { onPressConfirm: async () => {
try { try {
await store.me.savedFeeds.unsave(item) await store.me.savedFeeds.unsave(item)
Toast.show('Removed from my feeds') Toast.show('Removed from my feeds')
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') Toast.show('There was an issue contacting your server')
store.log.error('Failed to unsave feed', {e}) store.log.error('Failed to unsave feed', {e})
} }
}, },
}) })
} else { } else {
try { try {
await store.me.savedFeeds.save(item) await store.me.savedFeeds.save(item)
Toast.show('Added to my feeds') Toast.show('Added to my feeds')
} catch (e) { } catch (e) {
Toast.show('There was an issue contacting your server') Toast.show('There was an issue contacting your server')
store.log.error('Failed to save feed', {e}) store.log.error('Failed to save feed', {e})
}
} }
}, [store, item]) }
}, [store, item])
return ( return (
<Pressable <Pressable
testID={`feed-${item.displayName}`} testID={`feed-${item.displayName}`}
accessibilityRole="button" accessibilityRole="button"
style={[styles.container, pal.border, style]} style={[styles.container, pal.border, style]}
onPress={() => { onPress={() => {
navigation.push('CustomFeed', { navigation.push('CustomFeed', {
name: item.data.creator.did, name: item.data.creator.did,
rkey: new AtUri(item.data.uri).rkey, rkey: new AtUri(item.data.uri).rkey,
}) })
}} }}
key={item.data.uri}> key={item.data.uri}>
<View style={[styles.headerContainer]}> <View style={[styles.headerContainer]}>
<View style={[s.mr10]}> <View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={item.data.avatar} /> <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>
)}
</View> </View>
<View style={[styles.headerTextContainer]}>
{showDescription && item.data.description ? ( <Text style={[pal.text, s.bold]} numberOfLines={3}>
<Text style={[pal.textLight, styles.description]} numberOfLines={3}> {item.displayName}
{item.data.description}
</Text> </Text>
) : null} <Text style={[pal.textLight]} numberOfLines={3}>
by {sanitizeHandle(item.data.creator.handle, '@')}
{showLikes ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {item.data.likeCount || 0}{' '}
{pluralize(item.data.likeCount || 0, 'user')}
</Text> </Text>
) : null} </View>
</Pressable> {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({ const styles = StyleSheet.create({
container: { container: {

View File

@ -35,319 +35,314 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const ListItems = observer( export const ListItems = observer(function ListItemsImpl({
({ list,
list, style,
style, scrollElRef,
scrollElRef, onPressTryAgain,
onPressTryAgain, onToggleSubscribed,
onToggleSubscribed, onPressEditList,
onPressEditList, onPressDeleteList,
onPressDeleteList, onPressShareList,
onPressShareList, onPressReportList,
onPressReportList, renderEmptyState,
renderEmptyState, testID,
testID, headerOffset = 0,
headerOffset = 0, }: {
}: { list: ListModel
list: ListModel style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null>
scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void
onPressTryAgain?: () => void onToggleSubscribed: () => void
onToggleSubscribed: () => void onPressEditList: () => void
onPressEditList: () => void onPressDeleteList: () => void
onPressDeleteList: () => void onPressShareList: () => void
onPressShareList: () => void onPressReportList: () => void
onPressReportList: () => void renderEmptyState?: () => JSX.Element
renderEmptyState?: () => JSX.Element testID?: string
testID?: string headerOffset?: number
headerOffset?: number }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const store = useStores()
const store = useStores() const {track} = useAnalytics()
const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false)
const [isRefreshing, setIsRefreshing] = React.useState(false)
const data = React.useMemo(() => { const data = React.useMemo(() => {
let items: any[] = [HEADER_ITEM] let items: any[] = [HEADER_ITEM]
if (list.hasLoaded) { if (list.hasLoaded) {
if (list.hasError) { if (list.hasError) {
items = items.concat([ERROR_ITEM]) 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])
} }
return items if (list.isEmpty) {
}, [ items = items.concat([EMPTY_ITEM])
list.hasError, } else {
list.hasLoaded, items = items.concat(list.items)
list.isLoading,
list.isEmpty,
list.items,
list.loadMoreError,
])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsRefreshing(true)
try {
await list.refresh()
} catch (err) {
list.rootStore.log.error('Failed to refresh lists', err)
} }
setIsRefreshing(false) if (list.loadMoreError) {
}, [list, track, setIsRefreshing]) items = items.concat([LOAD_MORE_ERROR_ITEM])
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]) } else if (list.isLoading) {
items = items.concat([LOADING_ITEM])
}
return items
}, [
list.hasError,
list.hasLoaded,
list.isLoading,
list.isEmpty,
list.items,
list.loadMoreError,
])
const onPressRetryLoadMore = React.useCallback(() => { // events
list.retryLoadMore() // =
}, [list])
const onPressEditMembership = React.useCallback( const onRefresh = React.useCallback(async () => {
(profile: AppBskyActorDefs.ProfileViewBasic) => { track('Lists:onRefresh')
store.shell.openModal({ setIsRefreshing(true)
name: 'list-add-remove-user', try {
subject: profile.did, await list.refresh()
displayName: profile.displayName || profile.handle, } catch (err) {
onUpdate() { list.rootStore.log.error('Failed to refresh lists', err)
list.refresh() }
}, setIsRefreshing(false)
}) }, [list, track, setIsRefreshing])
},
[store, list],
)
// rendering const onEndReached = React.useCallback(async () => {
// = track('Lists:onEndReached')
try {
await list.loadMore()
} catch (err) {
list.rootStore.log.error('Failed to load more lists', err)
}
}, [list, track])
const renderMemberButton = React.useCallback( const onPressRetryLoadMore = React.useCallback(() => {
(profile: AppBskyActorDefs.ProfileViewBasic) => { list.retryLoadMore()
if (!list.isOwner) { }, [list])
return null
const onPressEditMembership = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
store.shell.openModal({
name: 'list-add-remove-user',
subject: profile.did,
displayName: profile.displayName || profile.handle,
onUpdate() {
list.refresh()
},
})
},
[store, list],
)
// rendering
// =
const renderMemberButton = React.useCallback(
(profile: AppBskyActorDefs.ProfileViewBasic) => {
if (!list.isOwner) {
return null
}
return (
<Button
type="default"
label="Edit"
onPress={() => onPressEditMembership(profile)}
/>
)
},
[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 ( return (
<Button <ErrorMessage
type="default" message={list.error}
label="Edit" onPressTryAgain={onPressTryAgain}
onPress={() => onPressEditMembership(profile)}
/> />
) )
}, } else if (item === LOAD_MORE_ERROR_ITEM) {
[list, onPressEditMembership], return (
) <LoadMoreRetryBtn
label="There was an issue fetching the list. Tap here to try again."
onPress={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 renderItem = React.useCallback( const Footer = React.useCallback(
({item}: {item: any}) => { () =>
if (item === EMPTY_ITEM) { list.isLoading ? (
if (renderEmptyState) { <View style={styles.feedFooter}>
return renderEmptyState() <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}
/>
} }
return <View /> contentContainerStyle={s.contentContainer}
} else if (item === HEADER_ITEM) { style={{paddingTop: headerOffset}}
return list.list ? ( onEndReached={onEndReached}
<ListHeader onEndReachedThreshold={0.6}
list={list.list} removeClippedSubviews={true}
isOwner={list.isOwner} contentOffset={{x: 0, y: headerOffset * -1}}
onToggleSubscribed={onToggleSubscribed} // @ts-ignore our .web version only -prf
onPressEditList={onPressEditList} desktopFixedHeight
/>
)}
</View>
)
})
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>
)}
{descriptionRT && (
<RichTextCom
testID="listDescription"
style={[pal.text, styles.headerDescription]}
richText={descriptionRT}
/>
)}
{isDesktop && (
<ListActions
isOwner={isOwner}
muted={list.viewer?.muted}
onPressDeleteList={onPressDeleteList} onPressDeleteList={onPressDeleteList}
onPressEditList={onPressEditList}
onToggleSubscribed={onToggleSubscribed}
onPressShareList={onPressShareList} onPressShareList={onPressShareList}
onPressReportList={onPressReportList} onPressReportList={onPressReportList}
/> />
) : null )}
} else if (item === ERROR_ITEM) { </View>
return ( <View>
<ErrorMessage <UserAvatar type="list" avatar={list.avatar} size={64} />
message={list.error} </View>
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 />
}
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],
)
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> </View>
) <View
}, style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}>
) <View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
const ListHeader = observer( Muted users
({ </Text>
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>
)}
{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>
</View> </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({ const styles = StyleSheet.create({
header: { header: {

View File

@ -30,173 +30,171 @@ const EMPTY_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export const ListsList = observer( export const ListsList = observer(function ListsListImpl({
({ listsList,
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])
}
} else if (listsList.isLoading) {
items = items.concat([LOADING_ITEM])
}
return items
}, [
listsList.hasError,
listsList.hasLoaded,
listsList.isLoading,
listsList.isEmpty,
listsList.lists,
listsList.loadMoreError,
showAddBtns, 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(() => { // events
let items: any[] = [] // =
if (listsList.hasLoaded) {
if (listsList.hasError) { const onRefresh = React.useCallback(async () => {
items = items.concat([ERROR_ITEM]) 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 onPressRetryLoadMore = React.useCallback(() => {
listsList.retryLoadMore()
}, [listsList])
// rendering
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
if (renderEmptyState) {
return renderEmptyState()
} }
if (listsList.isEmpty) { return <View />
items = items.concat([EMPTY_ITEM]) } else if (item === CREATENEW_ITEM) {
} else { return <CreateNewItem onPress={onPressCreateNew} />
if (showAddBtns) { } else if (item === ERROR_ITEM) {
items = items.concat([CREATENEW_ITEM]) return (
} <ErrorMessage
items = items.concat(listsList.lists) message={listsList.error}
} onPressTryAgain={onPressTryAgain}
if (listsList.loadMoreError) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
} 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
// =
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 onPressRetryLoadMore = React.useCallback(() => {
listsList.retryLoadMore()
}, [listsList])
// 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 />
}
return renderItem ? (
renderItem(item)
) : (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/> />
) )
}, } else if (item === LOAD_MORE_ERROR_ITEM) {
[ return (
listsList, <LoadMoreRetryBtn
onPressTryAgain, label="There was an issue fetching your lists. Tap here to try again."
onPressRetryLoadMore, onPress={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> } 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}) { function CreateNewItem({onPress}: {onPress: () => void}) {
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -17,160 +17,162 @@ import * as Toast from '../util/Toast'
export const snapPoints = ['90%'] export const snapPoints = ['90%']
export const Component = observer(({}: {}) => { export const Component = observer(
const store = useStores() function ContentFilteringSettingsImpl({}: {}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
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 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}
/>
)}
</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 store = useStores()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const onChange = React.useCallback( React.useEffect(() => {
async (v: LabelPreference) => { store.preferences.sync()
try { }, [store])
await store.preferences.setContentLabelPref(group, v)
} catch (e) { const onToggleAdultContent = React.useCallback(async () => {
Toast.show( if (isIOS) {
'There was an issue syncing your preferences with the server', return
) }
store.log.error('Failed to update preferences with server', {e}) try {
} await store.preferences.setAdultContentEnabled(
}, !store.preferences.adultContentEnabled,
[store, group], )
) } 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])
return ( return (
<View style={[styles.contentLabelPref, pal.border]}> <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
<View style={s.flex1}> <Text style={[pal.text, styles.title]}>Content Filtering</Text>
<Text type="md-medium" style={[pal.text]}> <ScrollView style={styles.scrollContainer}>
{CONFIGURABLE_LABEL_GROUPS[group].title} <View style={s.mb10}>
</Text> {isIOS ? (
{typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( store.preferences.adultContentEnabled ? null : (
<Text type="sm" style={[pal.textLight]}> <Text type="md" style={pal.textLight}>
{CONFIGURABLE_LABEL_GROUPS[group].subtitle} Adult content can only be enabled via the Web at{' '}
</Text> <TextLink
)} style={pal.link}
</View> href="https://bsky.app"
{disabled ? ( text="bsky.app"
<Text type="sm-bold" style={pal.textLight}> />
Hide .
</Text> </Text>
) : ( )
<SelectGroup ) : (
current={store.preferences.contentLabels[group]} <ToggleButton
onChange={onChange} type="default-light"
group={group} 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>
</View> </View>
) )
}, },
) )
// TODO: Refactor this component to pass labels down to each tab
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],
)
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
</Text>
) : (
<SelectGroup
current={store.preferences.contentLabels[group]}
onChange={onChange}
group={group}
/>
)}
</View>
)
})
interface SelectGroupProps { interface SelectGroupProps {
current: LabelPreference current: LabelPreference
onChange: (v: LabelPreference) => void onChange: (v: LabelPreference) => void

View File

@ -46,7 +46,10 @@ interface Props {
gallery: GalleryModel gallery: GalleryModel
} }
export const Component = observer(function ({image, gallery}: Props) { export const Component = observer(function EditImageImpl({
image,
gallery,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
const store = useStores() const store = useStores()

View File

@ -79,50 +79,56 @@ export function Component({}: {}) {
) )
} }
const InviteCode = observer( const InviteCode = observer(function InviteCodeImpl({
({testID, code, used}: {testID: string; code: string; used?: boolean}) => { testID,
const pal = usePalette('default') code,
const store = useStores() used,
const {invitesAvailable} = store.me }: {
testID: string
code: string
used?: boolean
}) {
const pal = usePalette('default')
const store = useStores()
const {invitesAvailable} = store.me
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
Clipboard.setString(code) Clipboard.setString(code)
Toast.show('Copied to clipboard') Toast.show('Copied to clipboard')
store.invitedUsers.setInviteCopied(code) store.invitedUsers.setInviteCopied(code)
}, [store, code]) }, [store, code])
return ( return (
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
style={[styles.inviteCode, pal.border]} style={[styles.inviteCode, pal.border]}
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
invitesAvailable === 1 invitesAvailable === 1
? 'Invite codes: 1 available' ? 'Invite codes: 1 available'
: `Invite codes: ${invitesAvailable} available` : `Invite codes: ${invitesAvailable} available`
} }
accessibilityHint="Opens list of invite codes"> accessibilityHint="Opens list of invite codes">
<Text <Text
testID={`${testID}-code`} testID={`${testID}-code`}
type={used ? 'md' : 'md-bold'} type={used ? 'md' : 'md-bold'}
style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
{code} {code}
</Text> </Text>
<View style={styles.flex1} /> <View style={styles.flex1} />
{!used && store.invitedUsers.isInviteCopied(code) && ( {!used && store.invitedUsers.isInviteCopied(code) && (
<Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text>
)} )}
{!used && ( {!used && (
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'clone']} icon={['far', 'clone']}
style={pal.text as FontAwesomeIconStyle} style={pal.text as FontAwesomeIconStyle}
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
) )
}, })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

@ -24,210 +24,207 @@ import isEqual from 'lodash.isequal'
export const snapPoints = ['fullscreen'] export const snapPoints = ['fullscreen']
export const Component = observer( export const Component = observer(function ListAddRemoveUserImpl({
({ subject,
subject, displayName,
displayName, onUpdate,
onUpdate, }: {
}: { subject: string
subject: string displayName: string
displayName: string onUpdate?: () => void
onUpdate?: () => void }) {
}) => { const store = useStores()
const store = useStores() const pal = usePalette('default')
const pal = usePalette('default') const palPrimary = usePalette('primary')
const palPrimary = usePalette('primary') const palInverted = usePalette('inverted')
const palInverted = usePalette('inverted') const [originalSelections, setOriginalSelections] = React.useState<string[]>(
const [originalSelections, setOriginalSelections] = React.useState< [],
string[] )
>([]) const [selected, setSelected] = React.useState<string[]>([])
const [selected, setSelected] = React.useState<string[]>([]) const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
const listsList: ListsListModel = React.useMemo( const listsList: ListsListModel = React.useMemo(
() => new ListsListModel(store, store.me.did), () => new ListsListModel(store, store.me.did),
[store], [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})
},
) )
const memberships: ListMembershipModel = React.useMemo( }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
() => 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})
},
)
}, [memberships, listsList, store, setSelected, setMembershipsLoaded])
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
store.shell.closeModal() store.shell.closeModal()
}, [store]) }, [store])
const onPressSave = useCallback(async () => { const onPressSave = useCallback(async () => {
try { try {
await memberships.updateTo(selected) await memberships.updateTo(selected)
} catch (err) { } catch (err) {
store.log.error('Failed to update memberships', {err}) store.log.error('Failed to update memberships', {err})
return 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 onToggleSelected = useCallback(
(uri: string) => {
if (selected.includes(uri)) {
setSelected(selected.filter(uri2 => uri2 !== uri))
} else {
setSelected([...selected, uri])
} }
Toast.show('Lists updated') },
onUpdate?.() [selected, setSelected],
store.shell.closeModal() )
}, [store, selected, memberships, onUpdate])
const onPressNewMuteList = useCallback(() => { const renderItem = useCallback(
store.shell.openModal({ (list: GraphDefs.ListView) => {
name: 'create-or-edit-mute-list', const isSelected = selected.includes(list.uri)
onSave: (_uri: string) => {
listsList.refresh()
},
})
}, [store, listsList])
const onToggleSelected = useCallback(
(uri: string) => {
if (selected.includes(uri)) {
setSelected(selected.filter(uri2 => uri2 !== uri))
} else {
setSelected([...selected, uri])
}
},
[selected, setSelected],
)
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 renderEmptyState = React.useCallback(() => {
return ( return (
<EmptyStateWithButton <Pressable
icon="users-slash" testID={`toggleBtn-${list.name}`}
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." style={[
buttonLabel="New Mute List" styles.listItem,
onPress={onPressNewMuteList} pal.border,
/> {opacity: membershipsLoaded ? 1 : 0.5},
) ]}
}, [onPressNewMuteList]) accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
list.name
// Only show changes button if there are some items on the list to choose from AND user has made changes in selection }`}
const canSaveChanges = accessibilityHint=""
!listsList.isEmpty && !isEqual(selected, originalSelections) disabled={!membershipsLoaded}
onPress={() => onToggleSelected(list.uri)}>
return ( <View style={styles.listItemAvi}>
<View testID="listAddRemoveUserModal" style={s.hContentRegion}> <UserAvatar size={40} avatar={list.avatar} />
<Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> </View>
<ListsList <View style={styles.listItemContent}>
listsList={listsList} <Text
showAddBtns type="lg"
onPressCreateNew={onPressNewMuteList} style={[s.bold, pal.text]}
renderItem={renderItem} numberOfLines={1}
renderEmptyState={renderEmptyState} lineHeight={1.2}>
style={[styles.list, pal.border]} {sanitizeDisplayName(list.name)}
/> </Text>
<View style={[styles.btns, pal.border]}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Button {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
testID="cancelBtn" {list.creator.did === store.me.did
type="default" ? 'you'
onPress={onPressCancel} : sanitizeHandle(list.creator.handle, '@')}
style={styles.footerBtn} </Text>
accessibilityLabel="Cancel" </View>
accessibilityHint="" {membershipsLoaded && (
onAccessibilityEscape={onPressCancel} <View
label="Cancel" style={
/> isSelected
{canSaveChanges && ( ? [styles.checkbox, palPrimary.border, palPrimary.view]
<Button : [styles.checkbox, pal.borderDark]
testID="saveBtn" }>
type="primary" {isSelected && (
onPress={onPressSave} <FontAwesomeIcon
style={styles.footerBtn} icon="check"
accessibilityLabel="Save changes" style={palInverted.text as FontAwesomeIconStyle}
accessibilityHint="" />
onAccessibilityEscape={onPressSave} )}
label="Save Changes"
/>
)}
{(listsList.isLoading || !membershipsLoaded) && (
<View style={styles.loadingContainer}>
<ActivityIndicator />
</View> </View>
)} )}
</View> </Pressable>
</View> )
},
[
pal,
palPrimary,
palInverted,
onToggleSelected,
selected,
store.me.did,
membershipsLoaded,
],
)
const renderEmptyState = React.useCallback(() => {
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}
/>
) )
}, }, [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"
/>
{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>
</View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

@ -14,7 +14,11 @@ import {s} from 'lib/styles'
export const snapPoints = [520, '100%'] 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 store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [model] = useState(new ProfileModel(store, {actor: did})) const [model] = useState(new ProfileModel(store, {actor: did}))

View File

@ -5,43 +5,41 @@ import {observer} from 'mobx-react-lite'
import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {useStores} from 'state/index' import {useStores} from 'state/index'
export const LanguageToggle = observer( export const LanguageToggle = observer(function LanguageToggleImpl({
({ code2,
code2, name,
name, onPress,
onPress, langType,
langType, }: {
}: { code2: string
code2: string name: string
name: string onPress: () => void
onPress: () => void langType: 'contentLanguages' | 'postLanguages'
langType: 'contentLanguages' | 'postLanguages' }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const store = useStores()
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 // enforce a max of 3 selections for post languages
let isDisabled = false let isDisabled = false
if ( if (
langType === 'postLanguages' && langType === 'postLanguages' &&
store.preferences[langType].length >= 3 && store.preferences[langType].length >= 3 &&
!isSelected !isSelected
) { ) {
isDisabled = true isDisabled = true
} }
return ( return (
<ToggleButton <ToggleButton
label={name} label={name}
isSelected={isSelected} isSelected={isSelected}
onPress={isDisabled ? undefined : onPress} onPress={isDisabled ? undefined : onPress}
style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]} style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
/> />
) )
}, })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
languageToggle: { languageToggle: {

View File

@ -13,7 +13,7 @@ import {ToggleButton} from 'view/com/util/forms/ToggleButton'
export const snapPoints = ['100%'] export const snapPoints = ['100%']
export const Component = observer(() => { export const Component = observer(function PostLanguagesSettingsImpl() {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()

View File

@ -52,7 +52,7 @@ interface Author {
moderation: ProfileModeration moderation: ProfileModeration
} }
export const FeedItem = observer(function ({ export const FeedItem = observer(function FeedItemImpl({
item, item,
}: { }: {
item: NotificationsFeedItemModel item: NotificationsFeedItemModel

View File

@ -18,7 +18,7 @@ import {s} from 'lib/styles'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
export const InvitedUsers = observer(() => { export const InvitedUsers = observer(function InvitedUsersImpl() {
const store = useStores() const store = useStores()
return ( return (
<CenteredView> <CenteredView>

View File

@ -9,59 +9,55 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
export const FeedsTabBar = observer( export const FeedsTabBar = observer(function FeedsTabBarImpl(
( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) {
) => { const {isMobile} = useWebMediaQueries()
const {isMobile} = useWebMediaQueries() if (isMobile) {
if (isMobile) { return <FeedsTabBarMobile {...props} />
return <FeedsTabBarMobile {...props} /> } else {
} else { return <FeedsTabBarDesktop {...props} />
return <FeedsTabBarDesktop {...props} /> }
} })
},
)
const FeedsTabBarDesktop = observer( const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) {
) => { const store = useStores()
const store = useStores() const items = useMemo(
const items = useMemo( () => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames], [store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames], )
) const pal = usePalette('default')
const pal = usePalette('default') const interp = useAnimatedValue(0)
const interp = useAnimatedValue(0)
React.useEffect(() => { React.useEffect(() => {
Animated.timing(interp, { Animated.timing(interp, {
toValue: store.shell.minimalShellMode ? 1 : 0, toValue: store.shell.minimalShellMode ? 1 : 0,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false, isInteraction: false,
}).start() }).start()
}, [interp, store.shell.minimalShellMode]) }, [interp, store.shell.minimalShellMode])
const transform = { const transform = {
transform: [ transform: [
{translateX: '-50%'}, {translateX: '-50%'},
{translateY: Animated.multiply(interp, -100)}, {translateY: Animated.multiply(interp, -100)},
], ],
} }
return ( return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf // @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]}> <Animated.View style={[pal.view, styles.tabBar, transform]}>
<TabBar <TabBar
key={items.join(',')} key={items.join(',')}
{...props} {...props}
items={items} items={items}
indicatorColor={pal.colors.link} indicatorColor={pal.colors.link}
/> />
</Animated.View> </Animated.View>
) )
}, })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {

View File

@ -14,79 +14,77 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {HITSLOP_10} from 'lib/constants' import {HITSLOP_10} from 'lib/constants'
export const FeedsTabBar = observer( export const FeedsTabBar = observer(function FeedsTabBarImpl(
( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) {
) => { const store = useStores()
const store = useStores() const pal = usePalette('default')
const pal = usePalette('default') const interp = useAnimatedValue(0)
const interp = useAnimatedValue(0)
React.useEffect(() => { React.useEffect(() => {
Animated.timing(interp, { Animated.timing(interp, {
toValue: store.shell.minimalShellMode ? 1 : 0, toValue: store.shell.minimalShellMode ? 1 : 0,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false, isInteraction: false,
}).start() }).start()
}, [interp, store.shell.minimalShellMode]) }, [interp, store.shell.minimalShellMode])
const transform = { const transform = {
transform: [{translateY: Animated.multiply(interp, -100)}], transform: [{translateY: Animated.multiply(interp, -100)}],
} }
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const onPressAvi = React.useCallback(() => { const onPressAvi = React.useCallback(() => {
store.shell.openDrawer() store.shell.openDrawer()
}, [store]) }, [store])
const items = useMemo( const items = useMemo(
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames], () => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames], [store.me.savedFeeds.pinnedFeedNames],
) )
return ( return (
<Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}> <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
<View style={[pal.view, styles.topBar]}> <View style={[pal.view, styles.topBar]}>
<View style={[pal.view]}> <View style={[pal.view]}>
<TouchableOpacity <TouchableOpacity
testID="viewHeaderDrawerBtn" testID="viewHeaderDrawerBtn"
onPress={onPressAvi} onPress={onPressAvi}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Open navigation" accessibilityLabel="Open navigation"
accessibilityHint="Access profile and other navigation links" accessibilityHint="Access profile and other navigation links"
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
<FontAwesomeIcon <FontAwesomeIcon
icon="bars" icon="bars"
size={18} size={18}
color={pal.colors.textLight} color={pal.colors.textLight}
/> />
</TouchableOpacity> </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>
</View> </View>
<TabBar <Text style={[brandBlue, s.bold, styles.title]}>
key={items.join(',')} {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}
{...props} </Text>
items={items} <View style={[pal.view]}>
indicatorColor={pal.colors.link} <Link
/> href="/settings/saved-feeds"
</Animated.View> 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({ const styles = StyleSheet.create({
tabBar: { tabBar: {

View File

@ -8,7 +8,11 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' 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 pal = usePalette('default')
const store = useStores() const store = useStores()
const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri]) const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
@ -64,6 +68,8 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {view.isLoading && <ActivityIndicator />}

View File

@ -8,7 +8,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
export const PostRepostedBy = observer(function PostRepostedBy({ export const PostRepostedBy = observer(function PostRepostedByImpl({
uri, uri,
}: { }: {
uri: string uri: string
@ -75,6 +75,8 @@ export const PostRepostedBy = observer(function PostRepostedBy({
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {view.isLoading && <ActivityIndicator />}

View File

@ -31,7 +31,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
export const Post = observer(function Post({ export const Post = observer(function PostImpl({
view, view,
showReplyLine, showReplyLine,
hideError, hideError,
@ -88,214 +88,212 @@ export const Post = observer(function Post({
) )
}) })
const PostLoaded = observer( const PostLoaded = observer(function PostLoadedImpl({
({ item,
item, record,
record, setDeleted,
setDeleted, showReplyLine,
showReplyLine, style,
style, }: {
}: { item: PostThreadItemModel
item: PostThreadItemModel record: FeedPost.Record
record: FeedPost.Record setDeleted: (v: boolean) => void
setDeleted: (v: boolean) => void showReplyLine?: boolean
showReplyLine?: boolean style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const store = useStores()
const store = useStores()
const itemUri = item.post.uri const itemUri = item.post.uri
const itemCid = item.post.cid const itemCid = item.post.cid
const itemUrip = new AtUri(item.post.uri) const itemUrip = new AtUri(item.post.uri)
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${item.post.author.handle}` const itemTitle = `Post by ${item.post.author.handle}`
let replyAuthorDid = '' let replyAuthorDid = ''
if (record.reply) { if (record.reply) {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
replyAuthorDid = urip.hostname replyAuthorDid = urip.hostname
} }
const translatorUrl = getTranslatorLink(record?.text || '') const translatorUrl = getTranslatorLink(record?.text || '')
const needsTranslation = useMemo( const needsTranslation = useMemo(
() => () =>
store.preferences.contentLanguages.length > 0 && store.preferences.contentLanguages.length > 0 &&
!isPostInLanguage(item.post, store.preferences.contentLanguages), !isPostInLanguage(item.post, store.preferences.contentLanguages),
[item.post, store.preferences.contentLanguages], [item.post, store.preferences.contentLanguages],
) )
const onPressReply = React.useCallback(() => { const onPressReply = React.useCallback(() => {
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: item.post.uri, uri: item.post.uri,
cid: item.post.cid, cid: item.post.cid,
text: record.text as string, text: record.text as string,
author: { author: {
handle: item.post.author.handle, handle: item.post.author.handle,
displayName: item.post.author.displayName, displayName: item.post.author.displayName,
avatar: item.post.author.avatar, avatar: item.post.author.avatar,
},
}, },
}) },
}, [store, item, record]) })
}, [store, item, record])
const onPressToggleRepost = React.useCallback(() => { const onPressToggleRepost = React.useCallback(() => {
return item return item
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
}, [item, store]) }, [item, store])
const onPressToggleLike = React.useCallback(() => { const onPressToggleLike = React.useCallback(() => {
return item return item
.toggleLike() .toggleLike()
.catch(e => store.log.error('Failed to toggle like', e)) .catch(e => store.log.error('Failed to toggle like', e))
}, [item, store]) }, [item, store])
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record.text) Clipboard.setString(record.text)
Toast.show('Copied to clipboard') Toast.show('Copied to clipboard')
}, [record]) }, [record])
const onOpenTranslate = React.useCallback(() => { const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl) Linking.openURL(translatorUrl)
}, [translatorUrl]) }, [translatorUrl])
const onToggleThreadMute = React.useCallback(async () => { const onToggleThreadMute = React.useCallback(async () => {
try { try {
await item.toggleThreadMute() await item.toggleThreadMute()
if (item.isThreadMuted) { if (item.isThreadMuted) {
Toast.show('You will no longer receive notifications for this thread') Toast.show('You will no longer receive notifications for this thread')
} else { } else {
Toast.show('You will now receive notifications for this thread') Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
store.log.error('Failed to toggle thread mute', e)
} }
}, [item, store]) } catch (e) {
store.log.error('Failed to toggle thread mute', e)
}
}, [item, store])
const onDeletePost = React.useCallback(() => { const onDeletePost = React.useCallback(() => {
item.delete().then( item.delete().then(
() => { () => {
setDeleted(true) setDeleted(true)
Toast.show('Post deleted') Toast.show('Post deleted')
}, },
e => { e => {
store.log.error('Failed to delete post', e) store.log.error('Failed to delete post', e)
Toast.show('Failed to delete post, please try again') Toast.show('Failed to delete post, please try again')
}, },
) )
}, [item, setDeleted, store]) }, [item, setDeleted, store])
return ( return (
<Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
{showReplyLine && <View style={styles.replyLine} />} {showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={52} size={52}
did={item.post.author.did} did={item.post.author.did}
handle={item.post.author.handle} handle={item.post.author.handle}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
moderation={item.moderation.avatar} moderation={item.moderation.avatar}
/> />
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<PostMeta <PostMeta
author={item.post.author} author={item.post.author}
authorHasWarning={!!item.post.author.labels?.length} authorHasWarning={!!item.post.author.labels?.length}
timestamp={item.post.indexedAt} timestamp={item.post.indexedAt}
postHref={itemHref} postHref={itemHref}
/> />
{replyAuthorDid !== '' && ( {replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="reply" icon="reply"
size={9} size={9}
style={[pal.textLight, s.mr5]} style={[pal.textLight, s.mr5]}
/> />
<Text <Text
type="sm"
style={[pal.textLight, s.mr2]}
lineHeight={1.2}
numberOfLines={1}>
Reply to{' '}
<UserInfoText
type="sm" type="sm"
style={[pal.textLight, s.mr2]} did={replyAuthorDid}
lineHeight={1.2} attr="displayName"
numberOfLines={1}> style={[pal.textLight]}
Reply to{' '} />
<UserInfoText </Text>
type="sm" </View>
did={replyAuthorDid} )}
attr="displayName" <ContentHider
style={[pal.textLight]} moderation={item.moderation.content}
/> style={styles.contentHider}
</Text> 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
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> </View>
)} )}
<ContentHider </ContentHider>
moderation={item.moderation.content} <PostCtrls
style={styles.contentHider} itemUri={itemUri}
childContainerStyle={styles.contentHiderChild}> itemCid={itemCid}
<PostAlerts itemHref={itemHref}
moderation={item.moderation.content} itemTitle={itemTitle}
style={styles.alert} author={item.post.author}
/> indexedAt={item.post.indexedAt}
{item.richText?.text ? ( text={item.richText?.text || record.text}
<View style={styles.postTextContainer}> isAuthor={item.post.author.did === store.me.did}
<RichText replyCount={item.post.replyCount}
testID="postText" repostCount={item.post.repostCount}
type="post-text" likeCount={item.post.likeCount}
richText={item.richText} isReposted={!!item.post.viewer?.repost}
lineHeight={1.3} isLiked={!!item.post.viewer?.like}
style={s.flex1} isThreadMuted={item.isThreadMuted}
/> onPressReply={onPressReply}
</View> onPressToggleRepost={onPressToggleRepost}
) : undefined} onPressToggleLike={onPressToggleLike}
{item.post.embed ? ( onCopyPostText={onCopyPostText}
<ContentHider onOpenTranslate={onOpenTranslate}
moderation={item.moderation.embed} onToggleThreadMute={onToggleThreadMute}
style={styles.contentHider}> onDeletePost={onDeletePost}
<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>
</View> </View>
</Link> </View>
) </Link>
}, )
) })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {

View File

@ -30,7 +30,7 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {isEmbedByEmbedder} from 'lib/embeds' import {isEmbedByEmbedder} from 'lib/embeds'
export const FeedItem = observer(function ({ export const FeedItem = observer(function FeedItemImpl({
item, item,
isThreadChild, isThreadChild,
isThreadLastChild, isThreadLastChild,

View File

@ -10,63 +10,61 @@ import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
export const FeedSlice = observer( export const FeedSlice = observer(function FeedSliceImpl({
({ slice,
slice, ignoreFilterFor,
ignoreFilterFor, }: {
}: { slice: PostsFeedSliceModel
slice: PostsFeedSliceModel ignoreFilterFor?: string
ignoreFilterFor?: string }) {
}) => { if (slice.shouldFilter(ignoreFilterFor)) {
if (slice.shouldFilter(ignoreFilterFor)) { return null
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
/>
</>
)
}
if (slice.isThread && slice.items.length > 3) {
const last = slice.items.length - 1
return ( return (
<> <>
{slice.items.map((item, i) => ( <FeedItem
<FeedItem key={slice.items[0]._reactKey}
key={item._reactKey} item={slice.items[0]}
item={item} isThreadParent={slice.isThreadParentAt(0)}
isThreadParent={slice.isThreadParentAt(i)} isThreadChild={slice.isThreadChildAt(0)}
isThreadChild={slice.isThreadChildAt(i)} />
isThreadLastChild={ <FeedItem
slice.isThreadChildAt(i) && slice.items.length === i + 1 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}) { function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -6,56 +6,54 @@ import {useStores} from 'state/index'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
export const FollowButton = observer( export const FollowButton = observer(function FollowButtonImpl({
({ unfollowedType = 'inverted',
unfollowedType = 'inverted', followedType = 'default',
followedType = 'default', did,
did, onToggleFollow,
onToggleFollow, }: {
}: { unfollowedType?: ButtonType
unfollowedType?: ButtonType followedType?: ButtonType
followedType?: ButtonType did: string
did: string onToggleFollow?: (v: boolean) => void
onToggleFollow?: (v: boolean) => void }) {
}) => { const store = useStores()
const store = useStores() const followState = store.me.follows.getFollowState(did)
const followState = store.me.follows.getFollowState(did)
if (followState === FollowState.Unknown) { if (followState === FollowState.Unknown) {
return <View /> return <View />
} }
const onToggleFollowInner = async () => { const onToggleFollowInner = async () => {
const updatedFollowState = await store.me.follows.fetchFollowState(did) const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) { if (updatedFollowState === FollowState.Following) {
try { try {
await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did) store.me.follows.removeFollow(did)
onToggleFollow?.(false) onToggleFollow?.(false)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to delete follow', e) store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
} }
} else if (updatedFollowState === FollowState.NotFollowing) { } else if (updatedFollowState === FollowState.NotFollowing) {
try { try {
const res = await store.agent.follow(did) const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri) store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true) onToggleFollow?.(true)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to create follow', e) store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
}
} }
} }
}
return ( return (
<Button <Button
type={ type={
followState === FollowState.Following ? followedType : unfollowedType followState === FollowState.Following ? followedType : unfollowedType
} }
onPress={onToggleFollowInner} onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
/> />
) )
}, })
)

View File

@ -22,89 +22,82 @@ import {
getModerationCauseKey, getModerationCauseKey,
} from 'lib/moderation' } from 'lib/moderation'
export const ProfileCard = observer( export const ProfileCard = observer(function ProfileCardImpl({
({ testID,
testID, profile,
profile, noBg,
noBg, noBorder,
noBorder, followers,
followers, renderButton,
renderButton, }: {
}: { testID?: string
testID?: string profile: AppBskyActorDefs.ProfileViewBasic
profile: AppBskyActorDefs.ProfileViewBasic noBg?: boolean
noBg?: boolean noBorder?: boolean
noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined
followers?: AppBskyActorDefs.ProfileView[] | undefined renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
renderButton?: ( }) {
profile: AppBskyActorDefs.ProfileViewBasic, const store = useStores()
) => React.ReactNode const pal = usePalette('default')
}) => {
const store = useStores()
const pal = usePalette('default')
const moderation = moderateProfile( const moderation = moderateProfile(profile, store.preferences.moderationOpts)
profile,
store.preferences.moderationOpts,
)
return ( return (
<Link <Link
testID={testID} testID={testID}
style={[ style={[
styles.outer, styles.outer,
pal.border, pal.border,
noBorder && styles.outerNoBorder, noBorder && styles.outerNoBorder,
!noBg && pal.view, !noBg && pal.view,
]} ]}
href={makeProfileLink(profile)} href={makeProfileLink(profile)}
title={profile.handle} title={profile.handle}
asAnchor asAnchor
anchorNoUnderline> anchorNoUnderline>
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<UserAvatar <UserAvatar
size={40} size={40}
avatar={profile.avatar} avatar={profile.avatar}
moderation={moderation.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}
</View> </View>
{profile.description ? ( <View style={styles.layoutContent}>
<View style={styles.details}> <Text
<Text style={pal.text} numberOfLines={4}> type="lg"
{profile.description as string} style={[s.bold, pal.text]}
</Text> numberOfLines={1}
</View> 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} ) : undefined}
<FollowersList followers={followers} /> </View>
</Link> {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({ function ProfileCardPills({
followedBy, followedBy,
@ -146,45 +139,47 @@ function ProfileCardPills({
) )
} }
const FollowersList = observer( const FollowersList = observer(function FollowersListImpl({
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { followers,
const store = useStores() }: {
const pal = usePalette('default') followers?: AppBskyActorDefs.ProfileView[] | undefined
if (!followers?.length) { }) {
return null const store = useStores()
} const pal = usePalette('default')
if (!followers?.length) {
return null
}
const followersWithMods = followers const followersWithMods = followers
.map(f => ({ .map(f => ({
f, f,
mod: moderateProfile(f, store.preferences.moderationOpts), mod: moderateProfile(f, store.preferences.moderationOpts),
})) }))
.filter(({mod}) => !mod.account.filter) .filter(({mod}) => !mod.account.filter)
return ( return (
<View style={styles.followedBy}> <View style={styles.followedBy}>
<Text <Text
type="sm" type="sm"
style={[styles.followsByDesc, pal.textLight]} style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2} numberOfLines={2}
lineHeight={1.2}> lineHeight={1.2}>
Followed by{' '} Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text> </Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => ( {followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}> <View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}> <View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View>
</View> </View>
))} </View>
</View> ))}
) </View>
}, )
) })
export const ProfileCardWithFollowBtn = observer( export const ProfileCardWithFollowBtn = observer(
({ function ProfileCardWithFollowBtnImpl({
profile, profile,
noBg, noBg,
noBorder, noBorder,
@ -194,7 +189,7 @@ export const ProfileCardWithFollowBtn = observer(
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => { }) {
const store = useStores() const store = useStores()
const isMe = store.me.did === profile.did const isMe = store.me.did === profile.did

View File

@ -78,6 +78,8 @@ export const ProfileFollowers = observer(function ProfileFollowers({
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {view.isLoading && <ActivityIndicator />}

View File

@ -75,6 +75,8 @@ export const ProfileFollows = observer(function ProfileFollows({
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{view.isLoading && <ActivityIndicator />} {view.isLoading && <ActivityIndicator />}

View File

@ -45,510 +45,502 @@ interface Props {
hideBackButton?: boolean hideBackButton?: boolean
} }
export const ProfileHeader = observer( export const ProfileHeader = observer(function ProfileHeaderImpl({
({view, onRefreshAll, hideBackButton = false}: Props) => { view,
const pal = usePalette('default') onRefreshAll,
hideBackButton = false,
// loading }: Props) {
// = const pal = usePalette('default')
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>
<Text type="title-2xl" style={[pal.text, styles.title]}>
{sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle),
)}
</Text>
</View>
</View>
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View testID="profileHeaderHasError">
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
return (
<ProfileHeaderLoaded
view={view}
onRefreshAll={onRefreshAll}
hideBackButton={hideBackButton}
/>
)
},
)
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()
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressAvi = React.useCallback(() => {
if (
view.avatar &&
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}, [store, view])
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 onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
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 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 onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(makeProfileLink(view))
shareUrl(url)
}, [track, view])
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 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 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 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 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 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',
},
})
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')
// loading
// =
if (!view || !view.hasLoaded) {
return ( return (
<View style={pal.view}> <View style={pal.view}>
<UserBanner banner={view.banner} moderation={view.moderation.avatar} /> <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.content}>
<View style={[styles.buttonsLine]}> <View style={[styles.buttonsLine]}>
{isMe ? ( <LoadingPlaceholder width={100} height={31} style={styles.br50} />
<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>
<View> <View>
<Text <Text type="title-2xl" style={[pal.text, styles.title]}>
testID="profileHeaderDisplayName"
type="title-2xl"
style={[pal.text, styles.title]}>
{sanitizeDisplayName( {sanitizeDisplayName(
view.displayName || sanitizeHandle(view.handle), view.displayName || sanitizeHandle(view.handle),
view.moderation.profile,
)} )}
</Text> </Text>
</View> </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 && (
<>
<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>
{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> </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>
</View>
</TouchableWithoutFeedback>
)}
<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> </View>
) )
}, }
)
// error
// =
if (view.hasError) {
return (
<View testID="profileHeaderHasError">
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
return (
<ProfileHeaderLoaded
view={view}
onRefreshAll={onRefreshAll}
hideBackButton={hideBackButton}
/>
)
})
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 onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressAvi = React.useCallback(() => {
if (
view.avatar &&
!(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}, [store, view])
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 onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}, [track, store, view, onRefreshAll])
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 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 onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
const url = toShareUrl(makeProfileLink(view))
shareUrl(url)
}, [track, view])
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 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 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 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 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 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',
},
})
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')
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 && (
<>
<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>
{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
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>
</View>
</TouchableWithoutFeedback>
)}
<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({ const styles = StyleSheet.create({
banner: { banner: {

View File

@ -18,7 +18,11 @@ import {s} from 'lib/styles'
const SECTIONS = ['Posts', 'Users'] 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 pal = usePalette('default')
const {isMobile} = useWebMediaQueries() 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') const pal = usePalette('default')
if (model.isPostsLoading) { if (model.isPostsLoading) {
return ( 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') const pal = usePalette('default')
if (model.isProfilesLoading) { if (model.isProfilesLoading) {
return ( return (

View File

@ -38,6 +38,9 @@ interface ProfileView {
} }
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView type Item = Heading | RefWrapper | SuggestWrapper | ProfileView
// FIXME(dan): Figure out why the false positives
/* eslint-disable react/prop-types */
export const Suggestions = observer( export const Suggestions = observer(
forwardRef(function SuggestionsImpl( forwardRef(function SuggestionsImpl(
{ {

View File

@ -25,7 +25,7 @@ interface PostMetaOpts {
timestamp: string timestamp: string
} }
export const PostMeta = observer(function (opts: PostMetaOpts) { export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) {
const pal = usePalette('default') const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle const handle = opts.author.handle

View File

@ -3,6 +3,9 @@ import {observer} from 'mobx-react-lite'
import {ago} from 'lib/strings/time' import {ago} from 'lib/strings/time'
import {useStores} from 'state/index' import {useStores} from 'state/index'
// FIXME(dan): Figure out why the false positives
/* eslint-disable react/prop-types */
export const TimeElapsed = observer(function TimeElapsed({ export const TimeElapsed = observer(function TimeElapsed({
timestamp, timestamp,
children, children,

View File

@ -14,7 +14,7 @@ import {NavigationProp} from 'lib/routes/types'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
export const ViewHeader = observer(function ({ export const ViewHeader = observer(function ViewHeaderImpl({
title, title,
canGoBack, canGoBack,
showBackButton = true, showBackButton = true,
@ -140,70 +140,68 @@ function DesktopWebHeader({
) )
} }
const Container = observer( const Container = observer(function ContainerImpl({
({ children,
children, hideOnScroll,
hideOnScroll, showBorder,
showBorder, }: {
}: { children: React.ReactNode
children: React.ReactNode hideOnScroll: boolean
hideOnScroll: boolean showBorder?: boolean
showBorder?: boolean }) {
}) => { const store = useStores()
const store = useStores() const pal = usePalette('default')
const pal = usePalette('default') const interp = useAnimatedValue(0)
const interp = useAnimatedValue(0)
React.useEffect(() => { React.useEffect(() => {
if (store.shell.minimalShellMode) { if (store.shell.minimalShellMode) {
Animated.timing(interp, { Animated.timing(interp, {
toValue: 1, toValue: 1,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false, isInteraction: false,
}).start() }).start()
} else { } else {
Animated.timing(interp, { Animated.timing(interp, {
toValue: 0, toValue: 0,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false, isInteraction: false,
}).start() }).start()
}
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [{translateY: Animated.multiply(interp, -100)}],
} }
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [{translateY: Animated.multiply(interp, -100)}],
}
if (!hideOnScroll) { if (!hideOnScroll) {
return (
<View
style={[
styles.header,
styles.headerFixed,
pal.view,
pal.border,
showBorder && styles.border,
]}>
{children}
</View>
)
}
return ( return (
<Animated.View <View
style={[ style={[
styles.header, styles.header,
styles.headerFloating, styles.headerFixed,
pal.view, pal.view,
pal.border, pal.border,
transform,
showBorder && styles.border, showBorder && styles.border,
]}> ]}>
{children} {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({ const styles = StyleSheet.create({
header: { header: {

View File

@ -14,7 +14,11 @@ export interface FABProps
icon: JSX.Element 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 {isTablet} = useWebMediaQueries()
const store = useStores() const store = useStores()
const interp = useAnimatedValue(0) const interp = useAnimatedValue(0)

View File

@ -9,41 +9,39 @@ import {usePalette} from 'lib/hooks/usePalette'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants' import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = observer( export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
({ onPress,
onPress, label,
label, showIndicator,
showIndicator, }: {
}: { onPress: () => void
onPress: () => void label: string
label: string showIndicator: boolean
showIndicator: boolean minimalShellMode?: boolean // NOTE not used on mobile -prf
minimalShellMode?: boolean // NOTE not used on mobile -prf }) {
}) => { const store = useStores()
const store = useStores() const pal = usePalette('default')
const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets()
const safeAreaInsets = useSafeAreaInsets() return (
return ( <TouchableOpacity
<TouchableOpacity style={[
style={[ styles.loadLatest,
styles.loadLatest, pal.borderDark,
pal.borderDark, pal.view,
pal.view, !store.shell.minimalShellMode && {
!store.shell.minimalShellMode && { bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), },
}, ]}
]} onPress={onPress}
onPress={onPress} hitSlop={HITSLOP_20}
hitSlop={HITSLOP_20} accessibilityRole="button"
accessibilityRole="button" accessibilityLabel={label}
accessibilityLabel={label} accessibilityHint="">
accessibilityHint=""> <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />} </TouchableOpacity>
</TouchableOpacity> )
) })
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loadLatest: { loadLatest: {

View File

@ -6,23 +6,21 @@ import {ListCard} from 'view/com/lists/ListCard'
import {AppBskyGraphDefs} from '@atproto/api' import {AppBskyGraphDefs} from '@atproto/api'
import {s} from 'lib/styles' import {s} from 'lib/styles'
export const ListEmbed = observer( export const ListEmbed = observer(function ListEmbedImpl({
({ item,
item, style,
style, }: {
}: { item: AppBskyGraphDefs.ListView
item: AppBskyGraphDefs.ListView style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default')
return ( return (
<View style={[pal.view, pal.border, s.border1, styles.container]}> <View style={[pal.view, pal.border, s.border1, styles.container]}>
<ListCard list={item} style={[style, styles.card]} /> <ListCard list={item} style={[style, styles.card]} />
</View> </View>
) )
}, })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

@ -19,7 +19,7 @@ import {CenteredView} from 'view/com/util/Views'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export const AppPasswords = withAuthRequired( export const AppPasswords = withAuthRequired(
observer(({}: Props) => { observer(function AppPasswordsImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {screen} = useAnalytics() const {screen} = useAnalytics()

View File

@ -42,7 +42,7 @@ import {NavigationProp} from 'lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired( export const CustomFeedScreen = withAuthRequired(
observer((props: Props) => { observer(function CustomFeedScreenImpl(props: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@ -119,7 +119,10 @@ export const CustomFeedScreen = withAuthRequired(
) )
export const CustomFeedScreenInner = observer( export const CustomFeedScreenInner = observer(
({route, feedOwnerDid}: Props & {feedOwnerDid: string}) => { function CustomFeedScreenInnerImpl({
route,
feedOwnerDid,
}: Props & {feedOwnerDid: string}) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()

View File

@ -19,7 +19,7 @@ import debounce from 'lodash.debounce'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
export const DiscoverFeedsScreen = withAuthRequired( export const DiscoverFeedsScreen = withAuthRequired(
observer(({}: Props) => { observer(function DiscoverFeedsScreenImpl({}: Props) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])

View File

@ -25,7 +25,7 @@ const MOBILE_HEADER_OFFSET = 40
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired( export const FeedsScreen = withAuthRequired(
observer<Props>(({}: Props) => { observer<Props>(function FeedsScreenImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()

View File

@ -28,7 +28,7 @@ const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired( export const HomeScreen = withAuthRequired(
observer(({}: Props) => { observer(function HomeScreenImpl({}: Props) {
const store = useStores() const store = useStores()
const pagerRef = React.useRef<PagerRef>(null) const pagerRef = React.useRef<PagerRef>(null)
const [selectedPage, setSelectedPage] = React.useState(0) const [selectedPage, setSelectedPage] = React.useState(0)
@ -142,152 +142,141 @@ export const HomeScreen = withAuthRequired(
}), }),
) )
const FeedPage = observer( const FeedPage = observer(function FeedPageImpl({
({ testID,
testID, isPageFocused,
isPageFocused, feed,
feed, renderEmptyState,
renderEmptyState, }: {
}: { testID?: string
testID?: string feed: PostsFeedModel
feed: PostsFeedModel isPageFocused: boolean
isPageFocused: boolean renderEmptyState?: () => JSX.Element
renderEmptyState?: () => JSX.Element }) {
}) => { const store = useStores()
const store = useStores() const {isMobile} = useWebMediaQueries()
const {isMobile} = useWebMediaQueries() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
const [onMainScroll, isScrolledDown, resetMainScroll] = const {screen, track} = useAnalytics()
useOnMainScroll(store) const [headerOffset, setHeaderOffset] = React.useState(
const {screen, track} = useAnalytics() isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
const [headerOffset, setHeaderOffset] = React.useState( )
isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, const scrollElRef = React.useRef<FlatList>(null)
) const {appState} = useAppState({
const scrollElRef = React.useRef<FlatList>(null) onForeground: () => doPoll(true),
const {appState} = useAppState({ })
onForeground: () => doPoll(true), const isScreenFocused = useIsFocused()
})
const isScreenFocused = useIsFocused()
React.useEffect(() => { React.useEffect(() => {
// called on first load // called on first load
if (!feed.hasLoaded && isPageFocused) { if (!feed.hasLoaded && isPageFocused) {
feed.setup() feed.setup()
} }
}, [isPageFocused, feed]) }, [isPageFocused, feed])
const doPoll = React.useCallback( const doPoll = React.useCallback(
(knownActive = false) => { (knownActive = false) => {
if ( if (
(!knownActive && appState !== 'active') || (!knownActive && appState !== 'active') ||
!isScreenFocused || !isScreenFocused ||
!isPageFocused !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 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])
// fires when page within screen is activated/deactivated
// - check for latest
React.useEffect(() => {
if (!isPageFocused || !isScreenFocused) {
return return
} }
if (feed.isLoading) {
const softResetSub = store.onScreenSoftReset(onSoftReset) return
const feedCleanup = feed.registerListeners() }
const pollInterval = setInterval(doPoll, POLL_FREQ) store.log.debug('HomeScreen: Polling for new posts')
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
feed.checkForLatest() feed.checkForLatest()
if (feed.hasContent) { },
feed.update() [appState, isScreenFocused, isPageFocused, store, feed],
} )
return () => { const scrollToTop = React.useCallback(() => {
clearInterval(pollInterval) scrollElRef.current?.scrollToOffset({offset: -headerOffset})
softResetSub.remove() resetMainScroll()
feedCleanup() }, [headerOffset, resetMainScroll])
}
}, [
store,
doPoll,
onSoftReset,
screen,
feed,
isPageFocused,
isScreenFocused,
])
const onPressCompose = React.useCallback(() => { const onSoftReset = React.useCallback(() => {
track('HomeScreen:PressCompose') if (isPageFocused) {
store.shell.openComposer({})
}, [store, track])
const onPressTryAgain = React.useCallback(() => {
feed.refresh()
}, [feed])
const onPressLoadLatest = React.useCallback(() => {
scrollToTop() scrollToTop()
feed.refresh() feed.refresh()
}, [feed, scrollToTop]) }
}, [isPageFocused, scrollToTop, feed])
const hasNew = feed.hasNewLatest && !feed.isRefreshing // listens for resize events
return ( React.useEffect(() => {
<View testID={testID} style={s.h100pct}> setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
<Feed }, [isMobile])
testID={testID ? `${testID}-feed` : undefined}
key="default" // fires when page within screen is activated/deactivated
feed={feed} // - check for latest
scrollElRef={scrollElRef} React.useEffect(() => {
onPressTryAgain={onPressTryAgain} if (!isPageFocused || !isScreenFocused) {
onScroll={onMainScroll} return
scrollEventThrottle={100} }
renderEmptyState={renderEmptyState}
headerOffset={headerOffset} 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()
}
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 onPressTryAgain = React.useCallback(() => {
feed.refresh()
}, [feed])
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}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
minimalShellMode={store.shell.minimalShellMode}
/> />
{(isScrolledDown || hasNew) && ( )}
<LoadLatestBtn <FAB
onPress={onPressLoadLatest} testID="composeFAB"
label="Load new posts" onPress={onPressCompose}
showIndicator={hasNew} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
minimalShellMode={store.shell.minimalShellMode} accessibilityRole="button"
/> accessibilityLabel="New post"
)} accessibilityHint=""
<FAB />
testID="composeFAB" </View>
onPress={onPressCompose} )
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} })
accessibilityRole="button"
accessibilityLabel="New post"
accessibilityHint=""
/>
</View>
)
},
)

View File

@ -27,7 +27,7 @@ type Props = NativeStackScreenProps<
'ModerationBlockedAccounts' 'ModerationBlockedAccounts'
> >
export const ModerationBlockedAccounts = withAuthRequired( export const ModerationBlockedAccounts = withAuthRequired(
observer(({}: Props) => { observer(function ModerationBlockedAccountsImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
@ -116,6 +116,8 @@ export const ModerationBlockedAccounts = withAuthRequired(
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{blockedAccounts.isLoading && <ActivityIndicator />} {blockedAccounts.isLoading && <ActivityIndicator />}

View File

@ -27,7 +27,7 @@ type Props = NativeStackScreenProps<
'ModerationMutedAccounts' 'ModerationMutedAccounts'
> >
export const ModerationMutedAccounts = withAuthRequired( export const ModerationMutedAccounts = withAuthRequired(
observer(({}: Props) => { observer(function ModerationMutedAccountsImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
@ -112,6 +112,8 @@ export const ModerationMutedAccounts = withAuthRequired(
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={renderItem} renderItem={renderItem}
initialNumToRender={15} initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => ( ListFooterComponent={() => (
<View style={styles.footer}> <View style={styles.footer}>
{mutedAccounts.isLoading && <ActivityIndicator />} {mutedAccounts.isLoading && <ActivityIndicator />}

View File

@ -24,7 +24,7 @@ type Props = NativeStackScreenProps<
'Notifications' 'Notifications'
> >
export const NotificationsScreen = withAuthRequired( export const NotificationsScreen = withAuthRequired(
observer(({}: Props) => { observer(function NotificationsScreenImpl({}: Props) {
const store = useStores() const store = useStores()
const [onMainScroll, isScrolledDown, resetMainScroll] = const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store) useOnMainScroll(store)

View File

@ -48,7 +48,9 @@ type Props = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
'PreferencesHomeFeed' 'PreferencesHomeFeed'
> >
export const PreferencesHomeFeed = observer(({navigation}: Props) => { export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
navigation,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()

View File

@ -32,7 +32,7 @@ import {combinedDisplayName} from 'lib/strings/display-names'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export const ProfileScreen = withAuthRequired( export const ProfileScreen = withAuthRequired(
observer(({route}: Props) => { observer(function ProfileScreenImpl({route}: Props) {
const store = useStores() const store = useStores()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)

View File

@ -23,7 +23,7 @@ import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
export const ProfileListScreen = withAuthRequired( export const ProfileListScreen = withAuthRequired(
observer(({route}: Props) => { observer(function ProfileListScreenImpl({route}: Props) {
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()

View File

@ -35,7 +35,7 @@ import {Link, TextLink} from 'view/com/util/Link'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export const SavedFeeds = withAuthRequired( export const SavedFeeds = withAuthRequired(
observer(({}: Props) => { observer(function SavedFeedsImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
@ -151,96 +151,98 @@ export const SavedFeeds = withAuthRequired(
}), }),
) )
const ListItem = observer( const ListItem = observer(function ListItemImpl({
({item, drag}: {item: CustomFeedModel; drag: () => void}) => { item,
const pal = usePalette('default') drag,
const store = useStores() }: {
const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) item: CustomFeedModel
const isPinned = savedFeeds.isPinned(item) drag: () => void
}) {
const pal = usePalette('default')
const store = useStores()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const isPinned = savedFeeds.isPinned(item)
const onTogglePinned = useCallback(() => { const onTogglePinned = useCallback(() => {
Haptics.default() Haptics.default()
savedFeeds.togglePinnedFeed(item).catch(e => { 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') Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e}) store.log.error('Failed to set pinned feed order', {e})
}) }),
}, [savedFeeds, item, store]) [store, savedFeeds, item],
const onPressUp = useCallback( )
() => const onPressDown = useCallback(
savedFeeds.movePinnedFeed(item, 'up').catch(e => { () =>
Toast.show('There was an issue contacting the server') savedFeeds.movePinnedFeed(item, 'down').catch(e => {
store.log.error('Failed to set pinned feed order', {e}) Toast.show('There was an issue contacting the server')
}), store.log.error('Failed to set pinned feed order', {e})
[store, savedFeeds, item], }),
) [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 ( return (
<ScaleDecorator> <ScaleDecorator>
<ShadowDecorator> <ShadowDecorator>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
onLongPress={isPinned ? drag : undefined} onLongPress={isPinned ? drag : undefined}
delayLongPress={200} delayLongPress={200}
style={[styles.itemContainer, pal.border]}> style={[styles.itemContainer, pal.border]}>
{isPinned && isWeb ? ( {isPinned && isWeb ? (
<View style={styles.webArrowButtonsContainer}> <View style={styles.webArrowButtonsContainer}>
<TouchableOpacity <TouchableOpacity accessibilityRole="button" onPress={onPressUp}>
accessibilityRole="button" <FontAwesomeIcon
onPress={onPressUp}> icon="arrow-up"
<FontAwesomeIcon size={12}
icon="arrow-up" style={[pal.text, styles.webArrowUpButton]}
size={12} />
style={[pal.text, styles.webArrowUpButton]} </TouchableOpacity>
/> <TouchableOpacity
</TouchableOpacity> accessibilityRole="button"
<TouchableOpacity onPress={onPressDown}>
accessibilityRole="button" <FontAwesomeIcon
onPress={onPressDown}> icon="arrow-down"
<FontAwesomeIcon size={12}
icon="arrow-down" style={[pal.text]}
size={12} />
style={[pal.text]} </TouchableOpacity>
/> </View>
</TouchableOpacity> ) : isPinned ? (
</View> <FontAwesomeIcon
) : isPinned ? ( icon="bars"
<FontAwesomeIcon size={20}
icon="bars" color={pal.colors.text}
size={20} style={s.ml20}
color={pal.colors.text}
style={s.ml20}
/>
) : null}
<CustomFeed
key={item.data.uri}
item={item}
showSaveBtn
style={styles.noBorder}
/> />
<TouchableOpacity ) : null}
accessibilityRole="button" <CustomFeed
hitSlop={10} key={item.data.uri}
onPress={onTogglePinned}> item={item}
<FontAwesomeIcon showSaveBtn
icon="thumb-tack" style={styles.noBorder}
size={20} />
color={isPinned ? colors.blue3 : pal.colors.icon} <TouchableOpacity
/> accessibilityRole="button"
</TouchableOpacity> hitSlop={10}
</Pressable> onPress={onTogglePinned}>
</ShadowDecorator> <FontAwesomeIcon
</ScaleDecorator> icon="thumb-tack"
) size={20}
}, color={isPinned ? colors.blue3 : pal.colors.icon}
) />
</TouchableOpacity>
</Pressable>
</ShadowDecorator>
</ScaleDecorator>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
desktopContainer: { desktopContainer: {

View File

@ -18,7 +18,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = withAuthRequired( export const SearchScreen = withAuthRequired(
observer(({navigation, route}: Props) => { observer(function SearchScreenImpl({navigation, route}: Props) {
const store = useStores() const store = useStores()
const params = route.params || {} const params = route.params || {}
const foafs = React.useMemo<FoafsModel>( const foafs = React.useMemo<FoafsModel>(

View File

@ -30,7 +30,7 @@ import {isAndroid, isIOS} from 'platform/detection'
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = withAuthRequired( export const SearchScreen = withAuthRequired(
observer<Props>(({}: Props) => { observer<Props>(function SearchScreenImpl({}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const scrollViewRef = React.useRef<ScrollView>(null) const scrollViewRef = React.useRef<ScrollView>(null)

View File

@ -6,73 +6,71 @@ import {ComposerOpts} from 'state/models/ui/shell'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
export const Composer = observer( export const Composer = observer(function ComposerImpl({
({ active,
active, winHeight,
winHeight, replyTo,
replyTo, onPost,
onPost, onClose,
onClose, quote,
quote, mention,
mention, }: {
}: { active: boolean
active: boolean winHeight: number
winHeight: number replyTo?: ComposerOpts['replyTo']
replyTo?: ComposerOpts['replyTo'] onPost?: ComposerOpts['onPost']
onPost?: ComposerOpts['onPost'] onClose: () => void
onClose: () => void quote?: ComposerOpts['quote']
quote?: ComposerOpts['quote'] mention?: ComposerOpts['mention']
mention?: ComposerOpts['mention'] }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const initInterp = useAnimatedValue(0)
const initInterp = useAnimatedValue(0)
useEffect(() => { useEffect(() => {
if (active) { if (active) {
Animated.timing(initInterp, { Animated.timing(initInterp, {
toValue: 1, toValue: 1,
duration: 300, duration: 300,
easing: Easing.out(Easing.exp), easing: Easing.out(Easing.exp),
useNativeDriver: true, useNativeDriver: true,
}).start() }).start()
} else { } else {
initInterp.setValue(0) initInterp.setValue(0)
}
}, [initInterp, active])
const wrapperAnimStyle = {
transform: [
{
translateY: initInterp.interpolate({
inputRange: [0, 1],
outputRange: [winHeight, 0],
}),
},
],
} }
}, [initInterp, active])
const wrapperAnimStyle = {
transform: [
{
translateY: initInterp.interpolate({
inputRange: [0, 1],
outputRange: [winHeight, 0],
}),
},
],
}
// rendering // rendering
// = // =
if (!active) { if (!active) {
return <View /> return <View />
} }
return ( return (
<Animated.View <Animated.View
style={[styles.wrapper, pal.view, wrapperAnimStyle]} style={[styles.wrapper, pal.view, wrapperAnimStyle]}
aria-modal aria-modal
accessibilityViewIsModal> accessibilityViewIsModal>
<ComposePost <ComposePost
replyTo={replyTo} replyTo={replyTo}
onPost={onPost} onPost={onPost}
onClose={onClose} onClose={onClose}
quote={quote} quote={quote}
mention={mention} mention={mention}
/> />
</Animated.View> </Animated.View>
) )
}, })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {

View File

@ -8,54 +8,52 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
const BOTTOM_BAR_HEIGHT = 61 const BOTTOM_BAR_HEIGHT = 61
export const Composer = observer( export const Composer = observer(function ComposerImpl({
({ active,
active, replyTo,
replyTo, quote,
quote, onPost,
onPost, onClose,
onClose, mention,
mention, }: {
}: { active: boolean
active: boolean winHeight: number
winHeight: number replyTo?: ComposerOpts['replyTo']
replyTo?: ComposerOpts['replyTo'] quote: ComposerOpts['quote']
quote: ComposerOpts['quote'] onPost?: ComposerOpts['onPost']
onPost?: ComposerOpts['onPost'] onClose: () => void
onClose: () => void mention?: ComposerOpts['mention']
mention?: ComposerOpts['mention'] }) {
}) => { const pal = usePalette('default')
const pal = usePalette('default') const {isMobile} = useWebMediaQueries()
const {isMobile} = useWebMediaQueries()
// rendering // rendering
// = // =
if (!active) { if (!active) {
return <View /> return <View />
} }
return ( return (
<View style={styles.mask} aria-modal accessibilityViewIsModal> <View style={styles.mask} aria-modal accessibilityViewIsModal>
<View <View
style={[ style={[
styles.container, styles.container,
isMobile && styles.containerMobile, isMobile && styles.containerMobile,
pal.view, pal.view,
pal.border, pal.border,
]}> ]}>
<ComposePost <ComposePost
replyTo={replyTo} replyTo={replyTo}
quote={quote} quote={quote}
onPost={onPost} onPost={onPost}
onClose={onClose} onClose={onClose}
mention={mention} mention={mention}
/> />
</View>
</View> </View>
) </View>
}, )
) })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
mask: { mask: {

View File

@ -44,7 +44,7 @@ import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format'
export const DrawerContent = observer(() => { export const DrawerContent = observer(function DrawerContentImpl() {
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@ -400,7 +400,7 @@ function MenuItem({
) )
} }
const InviteCodes = observer(() => { const InviteCodes = observer(function InviteCodesImpl() {
const {track} = useAnalytics() const {track} = useAnalytics()
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -32,7 +32,9 @@ import {UserAvatar} from 'view/com/util/UserAvatar'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' 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 store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()

View File

@ -23,7 +23,7 @@ import {Link} from 'view/com/util/Link'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
export const BottomBarWeb = observer(() => { export const BottomBarWeb = observer(function BottomBarWebImpl() {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()

View File

@ -40,7 +40,7 @@ import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
import {router} from '../../../routes' import {router} from '../../../routes'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
const ProfileCard = observer(() => { const ProfileCard = observer(function ProfileCardImpl() {
const store = useStores() const store = useStores()
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const size = isDesktop ? 64 : 48 const size = isDesktop ? 64 : 48
@ -103,78 +103,82 @@ interface NavItemProps {
iconFilled: JSX.Element iconFilled: JSX.Element
label: string label: string
} }
const NavItem = observer( const NavItem = observer(function NavItemImpl({
({count, href, icon, iconFilled, label}: NavItemProps) => { count,
const pal = usePalette('default') href,
const store = useStores() icon,
const {isDesktop, isTablet} = useWebMediaQueries() iconFilled,
const [pathName] = React.useMemo(() => router.matchPath(href), [href]) label,
const currentRouteInfo = useNavigationState(state => { }: NavItemProps) {
if (!state) { const pal = usePalette('default')
return {name: 'Home'} 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) e.preventDefault()
}) if (isCurrent) {
let isCurrent = store.emitScreenSoftReset()
currentRouteInfo.name === 'Profile' } else {
? isTab(currentRouteInfo.name, pathName) && onPress()
(currentRouteInfo.params as CommonNavigatorParams['Profile']).name === }
store.me.handle },
: isTab(currentRouteInfo.name, pathName) [onPress, isCurrent, store],
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],
)
return ( return (
<PressableWithHover <PressableWithHover
style={styles.navItemWrapper} style={styles.navItemWrapper}
hoverStyle={pal.viewLight} hoverStyle={pal.viewLight}
// @ts-ignore the function signature differs on web -prf // @ts-ignore the function signature differs on web -prf
onPress={onPressWrapped} onPress={onPressWrapped}
// @ts-ignore web only -prf // @ts-ignore web only -prf
href={href} href={href}
dataSet={{noUnderline: 1}} dataSet={{noUnderline: 1}}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint=""> accessibilityHint="">
<View <View
style={[ style={[
styles.navItemIconWrapper, styles.navItemIconWrapper,
isTablet && styles.navItemIconWrapperTablet, isTablet && styles.navItemIconWrapperTablet,
]}> ]}>
{isCurrent ? iconFilled : icon} {isCurrent ? iconFilled : icon}
{typeof count === 'string' && count ? ( {typeof count === 'string' && count ? (
<Text <Text
type="button" type="button"
style={[ style={[
styles.navItemCount, styles.navItemCount,
isTablet && styles.navItemCountTablet, isTablet && styles.navItemCountTablet,
]}> ]}>
{count} {count}
</Text>
) : null}
</View>
{isDesktop && (
<Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
{label}
</Text> </Text>
)} ) : null}
</PressableWithHover> </View>
) {isDesktop && (
}, <Text type="title" style={[isCurrent ? s.bold : s.normal, pal.text]}>
) {label}
</Text>
)}
</PressableWithHover>
)
})
function ComposeBtn() { function ComposeBtn() {
const store = useStores() const store = useStores()

View File

@ -13,7 +13,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {formatCount} from 'view/com/util/numeric/format' import {formatCount} from 'view/com/util/numeric/format'
export const DesktopRightNav = observer(function DesktopRightNav() { export const DesktopRightNav = observer(function DesktopRightNavImpl() {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const palError = usePalette('error') 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 store = useStores()
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -24,7 +24,7 @@ import {isStateAtTabRoot} from 'lib/routes/helpers'
import {SafeAreaProvider} from 'react-native-safe-area-context' import {SafeAreaProvider} from 'react-native-safe-area-context'
import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
const ShellInner = observer(() => { const ShellInner = observer(function ShellInnerImpl() {
const store = useStores() const store = useStores()
useOTAUpdate() // this hook polls for OTA updates every few seconds useOTAUpdate() // this hook polls for OTA updates every few seconds
const winDim = useWindowDimensions() 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 pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
return ( return (

View File

@ -17,7 +17,7 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
const ShellInner = observer(() => { const ShellInner = observer(function ShellInnerImpl() {
const store = useStores() const store = useStores()
const {isDesktop, isMobile} = useWebMediaQueries() const {isDesktop, isMobile} = useWebMediaQueries()
const navigator = useNavigation<NavigationProp>() 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) const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
return ( return (
<View style={[s.hContentRegion, pageBg]}> <View style={[s.hContentRegion, pageBg]}>