Merge branch 'main' into inherit_system_theme

This commit is contained in:
Jaz 2023-05-30 18:25:29 -07:00 committed by GitHub
commit 09ade363fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
136 changed files with 5771 additions and 2428 deletions

View file

@ -1,6 +1,5 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Alert} from 'view/com/util/Alert'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ScrollView} from 'react-native-gesture-handler'
import {Text} from '../com/util/text/Text'
@ -160,24 +159,15 @@ function AppPassword({
const store = useStores()
const onDelete = React.useCallback(async () => {
Alert.alert(
'Delete App Password',
`Are you sure you want to delete the app password "${name}"?`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await store.me.deleteAppPassword(name)
Toast.show('App password deleted')
},
},
],
)
store.shell.openModal({
name: 'confirm',
title: 'Delete App Password',
message: `Are you sure you want to delete the app password "${name}"?`,
async onPressConfirm() {
await store.me.deleteAppPassword(name)
Toast.show('App password deleted')
},
})
}, [store, name])
const {contentLanguages} = store.preferences

View file

@ -0,0 +1,418 @@
import React, {useMemo, useRef} from 'react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {colors, s} from 'lib/styles'
import {observer} from 'mobx-react-lite'
import {FlatList, StyleSheet, View} from 'react-native'
import {useStores} from 'state/index'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {Feed} from 'view/com/posts/Feed'
import {pluralize} from 'lib/strings/helpers'
import {TextLink} from 'view/com/util/Link'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {isDesktopWeb} from 'platform/detection'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics'
import {ComposeIcon2} from 'lib/icons'
import {FAB} from '../com/util/fab/FAB'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired(
observer(({route}: Props) => {
const store = useStores()
const pal = usePalette('default')
const {rkey, name} = route.params
const uri = useMemo(
() => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
[rkey, name],
)
const scrollElRef = useRef<FlatList>(null)
const currentFeed = useCustomFeed(uri)
const algoFeed: PostsFeedModel = useMemo(() => {
const feed = new PostsFeedModel(store, 'custom', {
feed: uri,
})
feed.setup()
return feed
}, [store, uri])
const isPinned = store.me.savedFeeds.isPinned(uri)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
useSetTitle(currentFeed?.displayName)
const onToggleSaved = React.useCallback(async () => {
try {
Haptics.default()
if (currentFeed?.isSaved) {
await currentFeed?.unsave()
} else {
await currentFeed?.save()
}
} catch (err) {
Toast.show(
'There was an an issue updating your feeds, please check your internet connection and try again.',
)
store.log.error('Failed up update feeds', {err})
}
}, [store, currentFeed])
const onToggleLiked = React.useCallback(async () => {
Haptics.default()
try {
if (currentFeed?.isLiked) {
await currentFeed?.unlike()
} else {
await currentFeed?.like()
}
} catch (err) {
Toast.show(
'There was an an issue contacting the server, please check your internet connection and try again.',
)
store.log.error('Failed up toggle like', {err})
}
}, [store, currentFeed])
const onTogglePinned = React.useCallback(async () => {
Haptics.default()
store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
}, [store, currentFeed])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
shareUrl(url)
}, [name, rkey])
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'feedHeaderDropdownRemoveBtn',
label: 'Remove from my feeds',
onPress: onToggleSaved,
},
{
testID: 'feedHeaderDropdownShareBtn',
label: 'Share link',
onPress: onPressShare,
},
]
return items
}, [onToggleSaved, onPressShare])
const renderHeaderBtns = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
<Button
type="default-light"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{currentFeed?.isSaved ? (
<Button
type="default-light"
accessibilityLabel={
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}>
<FontAwesomeIcon
icon="thumb-tack"
size={17}
color={isPinned ? colors.blue3 : pal.colors.textLight}
style={styles.top1}
/>
</Button>
) : undefined}
{currentFeed?.isSaved ? (
<DropdownButton
testID="feedHeaderDropdownBtn"
type="default-light"
items={dropdownItems}
menuWidth={250}>
<FontAwesomeIcon
icon="ellipsis"
color={pal.colors.textLight}
size={18}
/>
</DropdownButton>
) : (
<Button
type="default-light"
onPress={onToggleSaved}
accessibilityLabel="Add to my feeds"
accessibilityHint=""
style={styles.headerAddBtn}>
<FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} />
<Text type="xl-medium" style={pal.link}>
Add to My Feeds
</Text>
</Button>
)}
</View>
)
}, [
pal,
currentFeed?.isSaved,
currentFeed?.isLiked,
isPinned,
onToggleSaved,
onTogglePinned,
onToggleLiked,
dropdownItems,
])
const renderListHeaderComponent = React.useCallback(() => {
return (
<>
<View style={[styles.header, pal.border]}>
<View style={s.flex1}>
<Text
testID="feedName"
type="title-xl"
style={[pal.text, s.bold]}>
{currentFeed?.displayName}
</Text>
{currentFeed && (
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
by{' '}
{currentFeed.data.creator.did === store.me.did ? (
'you'
) : (
<TextLink
text={`@${currentFeed.data.creator.handle}`}
href={`/profile/${currentFeed.data.creator.did}`}
style={[pal.textLight]}
/>
)}
</Text>
)}
{isDesktopWeb && (
<View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
<Button
type={currentFeed?.isSaved ? 'default' : 'inverted'}
onPress={onToggleSaved}
accessibilityLabel={
currentFeed?.isSaved
? 'Unsave this feed'
: 'Save this feed'
}
accessibilityHint=""
label={
currentFeed?.isSaved
? 'Remove from My Feeds'
: 'Add to My Feeds'
}
/>
<Button
type="default"
accessibilityLabel={
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}>
<FontAwesomeIcon
icon="thumb-tack"
size={15}
color={isPinned ? colors.blue3 : pal.colors.icon}
style={styles.top2}
/>
</Button>
<Button
type="default"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={18} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={18} style={pal.icon} />
)}
</Button>
<Button
type="default"
accessibilityLabel="Share this feed"
accessibilityHint=""
onPress={onPressShare}>
<FontAwesomeIcon
icon="share"
size={18}
color={pal.colors.icon}
/>
</Button>
</View>
)}
</View>
<View>
<UserAvatar
type="algo"
avatar={currentFeed?.data.avatar}
size={64}
/>
</View>
</View>
<View style={styles.headerDetails}>
{currentFeed?.data.description ? (
<Text style={[pal.text, s.mb10]} numberOfLines={6}>
{currentFeed.data.description}
</Text>
) : null}
<View style={styles.headerDetailsFooter}>
{currentFeed ? (
<TextLink
type="md-medium"
style={pal.textLight}
href={`/profile/${name}/feed/${rkey}/liked-by`}
text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
currentFeed?.data.likeCount || 0,
'user',
)}`}
/>
) : null}
</View>
</View>
<View style={[styles.fakeSelector, pal.border]}>
<View
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
Feed
</Text>
</View>
</View>
</>
)
}, [
pal,
currentFeed,
store.me.did,
onToggleSaved,
onToggleLiked,
onPressShare,
name,
rkey,
isPinned,
onTogglePinned,
])
const renderEmptyState = React.useCallback(() => {
return <EmptyState icon="feed" message="This list is empty!" />
}, [])
return (
<View style={s.hContentRegion}>
<ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} />
<Feed
scrollElRef={scrollElRef}
feed={algoFeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
ListHeaderComponent={renderListHeaderComponent}
renderEmptyState={renderEmptyState}
extraData={[uri, isPinned]}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onScrollToTop}
label="Scroll to top"
showIndicator={false}
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="Compose post"
accessibilityHint=""
/>
</View>
)
}),
)
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 16,
borderTopWidth: 1,
},
headerBtns: {
flexDirection: 'row',
alignItems: 'center',
},
headerBtnsDesktop: {
marginTop: 8,
gap: 4,
},
headerAddBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingLeft: 4,
},
headerDetails: {
paddingHorizontal: 16,
paddingBottom: 16,
},
headerDetailsFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
fakeSelector: {
flexDirection: 'row',
paddingHorizontal: isDesktopWeb ? 16 : 6,
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
},
liked: {
color: colors.red3,
},
top1: {
position: 'relative',
top: 1,
},
top2: {
position: 'relative',
top: 2,
},
})

View file

@ -0,0 +1,29 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'>
export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}, [store]),
)
return (
<View>
<ViewHeader title="Liked by" />
<PostLikedByComponent uri={uri} />
</View>
)
})

View file

@ -0,0 +1,112 @@
import React from 'react'
import {RefreshControl, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {useStores} from 'state/index'
import {FeedsDiscoveryModel} from 'state/models/discovery/feeds'
import {CenteredView, FlatList} from 'view/com/util/Views'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {Text} from 'view/com/util/text/Text'
import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
export const DiscoverFeedsScreen = withAuthRequired(
observer(({}: Props) => {
const store = useStores()
const pal = usePalette('default')
const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
feeds.refresh()
}, [store, feeds]),
)
const onRefresh = React.useCallback(() => {
store.me.savedFeeds.refresh()
}, [store])
const renderListEmptyComponent = React.useCallback(() => {
return (
<View
style={[
pal.border,
!isDesktopWeb && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
{feeds.isLoading
? 'Loading...'
: `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
</Text>
</View>
)
}, [pal, feeds.isLoading])
const renderItem = React.useCallback(
({item}) => (
<CustomFeed
key={item.data.uri}
item={item}
showSaveBtn
showDescription
showLikes
/>
),
[],
)
return (
<CenteredView style={[styles.container, pal.view]}>
<View style={[isDesktopWeb && styles.containerDesktop, pal.border]}>
<ViewHeader title="Discover Feeds" showOnDesktop />
</View>
<FlatList
style={[!isDesktopWeb && s.flex1]}
data={feeds.feeds}
keyExtractor={item => item.data.uri}
contentContainerStyle={styles.contentContainer}
refreshControl={
<RefreshControl
refreshing={feeds.isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
renderItem={renderItem}
initialNumToRender={10}
ListEmptyComponent={renderListEmptyComponent}
extraData={feeds.isLoading}
/>
</CenteredView>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingBottom: 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
empty: {
paddingHorizontal: 18,
paddingVertical: 16,
borderRadius: 8,
marginHorizontal: 18,
marginTop: 10,
},
})

126
src/view/screens/Feeds.tsx Normal file
View file

@ -0,0 +1,126 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import isEqual from 'lodash.isequal'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FlatList} from 'view/com/util/Views'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Link} from 'view/com/util/Link'
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
import {MultiFeed} from 'view/com/posts/MultiFeed'
import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles'
const HEADER_OFFSET = isDesktopWeb ? 0 : 40
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired(
observer<Props>(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const flatListRef = React.useRef<FlatList>(null)
const multifeed = React.useMemo<PostsMultiFeedModel>(
() => new PostsMultiFeedModel(store),
[store],
)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const onSoftReset = React.useCallback(() => {
flatListRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
}, [flatListRef, resetMainScroll])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const multifeedCleanup = multifeed.registerListeners()
const cleanup = () => {
softResetSub.remove()
multifeedCleanup()
}
store.shell.setMinimalShellMode(false)
return cleanup
}, [store, multifeed, onSoftReset]),
)
React.useEffect(() => {
if (
isEqual(
multifeed.feedInfos.map(f => f.uri),
store.me.savedFeeds.all.map(f => f.uri),
)
) {
// no changes
return
}
multifeed.refresh()
}, [multifeed, store.me.savedFeeds.all])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
const renderHeaderBtn = React.useCallback(() => {
return (
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel="Edit Saved Feeds"
accessibilityHint="Opens screen to edit Saved Feeds">
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
)
}, [pal])
return (
<View style={[pal.view, styles.container]}>
<MultiFeed
scrollElRef={flatListRef}
multifeed={multifeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
headerOffset={HEADER_OFFSET}
showPostFollowBtn
/>
<ViewHeader
title="My Feeds"
canGoBack={false}
hideOnScroll
renderButton={renderHeaderBtn}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onSoftReset}
label="Scroll to top"
showIndicator={false}
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="Compose post"
accessibilityHint=""
/>
</View>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
},
})

View file

@ -1,18 +1,20 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {FAB} from '../com/util/fab/FAB'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
@ -21,30 +23,37 @@ import {useAnalytics} from 'lib/analytics'
import {ComposeIcon2} from 'lib/icons'
import {isDesktopWeb} from 'platform/detection'
const HEADER_OFFSET = isDesktopWeb ? 50 : 40
const HEADER_OFFSET = isDesktopWeb ? 50 : 78
const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired(
observer((_opts: Props) => {
const store = useStores()
const pagerRef = React.useRef<PagerRef>(null)
const [selectedPage, setSelectedPage] = React.useState(0)
const [initialLanguages] = React.useState(
store.preferences.contentLanguages,
)
const algoFeed: PostsFeedModel = React.useMemo(() => {
const feed = new PostsFeedModel(store, 'goodstuff', {})
feed.setup()
return feed
}, [store])
const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([])
React.useEffect(() => {
// refresh whats hot when lang preferences change
if (initialLanguages !== store.preferences.contentLanguages) {
algoFeed.refresh()
const {pinned} = store.me.savedFeeds
if (
isEqual(
pinned.map(p => p.uri),
customFeeds.map(f => (f.params as GetCustomFeed.QueryParams).feed),
)
) {
// no changes
return
}
}, [initialLanguages, store.preferences.contentLanguages, algoFeed])
const feeds = []
for (const feed of pinned) {
const model = new PostsFeedModel(store, 'custom', {feed: feed.uri})
model.setup()
feeds.push(model)
}
setCustomFeeds(feeds)
}, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds])
useFocusEffect(
React.useCallback(() => {
@ -86,18 +95,17 @@ export const HomeScreen = withAuthRequired(
return <FollowingEmptyState />
}, [])
const renderWhatsHotEmptyState = React.useCallback(() => {
return <WhatsHotEmptyState />
const renderCustomFeedEmptyState = React.useCallback(() => {
return <CustomFeedEmptyState />
}, [])
const initialPage = store.me.followsCount === 0 ? 1 : 0
return (
<Pager
ref={pagerRef}
testID="homeScreen"
onPageSelected={onPageSelected}
renderTabBar={renderTabBar}
tabBarPosition="top"
initialPage={initialPage}>
tabBarPosition="top">
<FeedPage
key="1"
testID="followingFeedPage"
@ -105,13 +113,17 @@ export const HomeScreen = withAuthRequired(
feed={store.me.mainFeed}
renderEmptyState={renderFollowingEmptyState}
/>
<FeedPage
key="2"
testID="whatshotFeedPage"
isPageFocused={selectedPage === 1}
feed={algoFeed}
renderEmptyState={renderWhatsHotEmptyState}
/>
{customFeeds.map((f, index) => {
return (
<FeedPage
key={(f.params as GetCustomFeed.QueryParams).feed}
testID="customFeedPage"
isPageFocused={selectedPage === 1 + index}
feed={f}
renderEmptyState={renderCustomFeedEmptyState}
/>
)
})}
</Pager>
)
}),
@ -130,7 +142,8 @@ const FeedPage = observer(
renderEmptyState?: () => JSX.Element
}) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const {screen, track} = useAnalytics()
const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({
@ -158,12 +171,13 @@ const FeedPage = observer(
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET})
}, [scrollElRef])
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onSoftReset = React.useCallback(() => {
if (isPageFocused) {
feed.refresh()
scrollToTop()
feed.refresh()
}
}, [isPageFocused, scrollToTop, feed])
@ -188,7 +202,7 @@ const FeedPage = observer(
}
}, [store, doPoll, onSoftReset, screen, feed]),
)
// fires when tab is actived/deactivated
// fires when tab is activated/deactivated
// - check for latest
useTabFocusEffect(
'Home',
@ -224,6 +238,7 @@ const FeedPage = observer(
feed.refresh()
}, [feed, scrollToTop])
const hasNew = feed.hasNewLatest && !feed.isRefreshing
return (
<View testID={testID} style={s.h100pct}>
<Feed
@ -234,11 +249,17 @@ const FeedPage = observer(
showPostFollowBtn
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState}
headerOffset={HEADER_OFFSET}
/>
{feed.hasNewLatest && !feed.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} label="posts" />
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new posts"
showIndicator={hasNew}
minimalShellMode={store.shell.minimalShellMode}
/>
)}
<FAB
testID="composeFAB"

View file

@ -100,7 +100,7 @@ export const ModerationMutedAccounts = withAuthRequired(
<FlatList
style={[!isDesktopWeb && styles.flex1]}
data={mutedAccounts.mutes}
keyExtractor={(item: ActorDefs.ProfileView) => item.did}
keyExtractor={item => item.did}
refreshControl={
<RefreshControl
refreshing={mutedAccounts.isRefreshing}

View file

@ -25,7 +25,8 @@ type Props = NativeStackScreenProps<
export const NotificationsScreen = withAuthRequired(
observer(({}: Props) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired(
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef])
resetMainScroll()
}, [scrollElRef, resetMainScroll])
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
@ -86,6 +88,9 @@ export const NotificationsScreen = withAuthRequired(
),
)
const hasNew =
store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing
return (
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
@ -96,10 +101,14 @@ export const NotificationsScreen = withAuthRequired(
onScroll={onMainScroll}
scrollElRef={scrollElRef}
/>
{store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} label="notifications" />
)}
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label="Load new notifications"
showIndicator={hasNew}
minimalShellMode={true}
/>
)}
</View>
)
}),

View file

@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {ProfileUiModel, Sections} from 'state/models/ui/profile'
import {useStores} from 'state/index'
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
import {PostsFeedSliceModel} from 'state/models/feeds/post'
import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedSlice} from '../com/posts/FeedSlice'
import {ListCard} from 'view/com/lists/ListCard'
@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {ComposeIcon2} from 'lib/icons'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
@ -45,6 +47,10 @@ export const ProfileScreen = withAuthRequired(
)
useSetTitle(combinedDisplayName(uiState.profile))
useEffect(() => {
setHasSetup(false)
}, [route.params.name])
useFocusEffect(
React.useCallback(() => {
let aborted = false
@ -118,6 +124,7 @@ export const ProfileScreen = withAuthRequired(
}, [uiState.showLoadingMoreFooter])
const renderItem = React.useCallback(
(item: any) => {
// if section is lists
if (uiState.selectedView === Sections.Lists) {
if (item === ProfileUiModel.LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
@ -142,6 +149,32 @@ export const ProfileScreen = withAuthRequired(
} else {
return <ListCard testID={`list-${item.name}`} list={item} />
}
// if section is custom algorithms
} else if (uiState.selectedView === Sections.CustomAlgorithms) {
if (item === ProfileUiModel.LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
} else if (item._reactKey === '__error__') {
return (
<View style={s.p5}>
<ErrorMessage
message={item.error}
onPressTryAgain={onPressTryAgain}
/>
</View>
)
} else if (item === ProfileUiModel.EMPTY_ITEM) {
return (
<EmptyState
testID="customAlgorithmsEmpty"
icon="list-ul"
message="No custom algorithms yet!"
style={styles.emptyState}
/>
)
} else if (item instanceof CustomFeedModel) {
return <CustomFeed item={item} showSaveBtn showLikes />
}
// if section is posts or posts & replies
} else {
if (item === ProfileUiModel.END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>

View file

@ -87,7 +87,7 @@ export const ProfileListScreen = withAuthRequired(
return <EmptyState icon="users-slash" message="This list is empty!" />
}, [])
const renderHeaderBtn = React.useCallback(() => {
const renderHeaderBtns = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
{list?.isOwner && (
@ -148,7 +148,7 @@ export const ProfileListScreen = withAuthRequired(
pal.border,
]}
testID="moderationMutelistsScreen">
<ViewHeader title="" renderButton={renderHeaderBtn} />
<ViewHeader title="" renderButton={renderHeaderBtns} />
<ListItems
list={list}
renderEmptyState={renderEmptyState}

View file

@ -0,0 +1,293 @@
import React, {useCallback, useMemo} from 'react'
import {
RefreshControl,
StyleSheet,
View,
ActivityIndicator,
Pressable,
TouchableOpacity,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {Text} from 'view/com/util/text/Text'
import {isDesktopWeb, isWeb} from 'platform/detection'
import {s, colors} from 'lib/styles'
import DraggableFlatList, {
ShadowDecorator,
ScaleDecorator,
} from 'react-native-draggable-flatlist'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics'
import {Link, TextLink} from 'view/com/util/Link'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export const SavedFeeds = withAuthRequired(
observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const {screen} = useAnalytics()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
useFocusEffect(
useCallback(() => {
screen('SavedFeeds')
store.shell.setMinimalShellMode(false)
savedFeeds.refresh()
}, [screen, store, savedFeeds]),
)
const renderListEmptyComponent = useCallback(() => {
return (
<View
style={[
pal.border,
!isDesktopWeb && s.flex1,
pal.viewLight,
styles.empty,
]}>
<Text type="lg" style={[pal.text]}>
You don't have any saved feeds.
</Text>
</View>
)
}, [pal])
const renderListFooterComponent = useCallback(() => {
return (
<>
<View style={[styles.footerLinks, pal.border]}>
<Link style={styles.footerLink} href="/search/feeds">
<FontAwesomeIcon
icon="search"
size={18}
color={pal.colors.icon}
/>
<Text type="lg-medium" style={pal.textLight}>
Discover new feeds
</Text>
</Link>
</View>
<View style={styles.footerText}>
<Text type="sm" style={pal.textLight}>
Feeds are custom algorithms that users build with a little coding
expertise.{' '}
<TextLink
type="sm"
style={pal.link}
href="https://github.com/bluesky-social/feed-generator"
text="See this guide"
/>{' '}
for more information.
</Text>
</View>
{savedFeeds.isLoading && <ActivityIndicator />}
</>
)
}, [pal, savedFeeds.isLoading])
const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
const onDragEnd = useCallback(
async ({data}) => {
try {
await savedFeeds.reorderPinnedFeeds(data)
} catch (e) {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to save pinned feed order', {e})
}
},
[savedFeeds, store],
)
return (
<CenteredView
style={[
s.hContentRegion,
pal.border,
isDesktopWeb && styles.desktopContainer,
]}>
<ViewHeader
title="Edit My Feeds"
showOnDesktop
showBorder={!isDesktopWeb}
/>
<DraggableFlatList
containerStyle={[!isDesktopWeb && s.flex1]}
data={savedFeeds.all}
keyExtractor={item => item.data.uri}
refreshing={savedFeeds.isRefreshing}
refreshControl={
<RefreshControl
refreshing={savedFeeds.isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
getItemLayout={(data, index) => ({
length: 77,
offset: 77 * index,
index,
})}
initialNumToRender={10}
ListFooterComponent={renderListFooterComponent}
ListEmptyComponent={renderListEmptyComponent}
extraData={savedFeeds.isLoading}
onDragEnd={onDragEnd}
/>
</CenteredView>
)
}),
)
const ListItem = observer(
({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const isPinned = savedFeeds.isPinned(item)
const onTogglePinned = useCallback(() => {
Haptics.default()
savedFeeds.togglePinnedFeed(item).catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
})
}, [savedFeeds, item, store])
const onPressUp = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to set pinned feed order', {e})
}),
[store, savedFeeds, item],
)
const onPressDown = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'down').catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to set pinned feed order', {e})
}),
[store, savedFeeds, item],
)
return (
<ScaleDecorator>
<ShadowDecorator>
<Pressable
accessibilityRole="button"
onLongPress={isPinned ? drag : undefined}
delayLongPress={200}
style={[styles.itemContainer, pal.border]}>
{isPinned && isWeb ? (
<View style={styles.webArrowButtonsContainer}>
<TouchableOpacity
accessibilityRole="button"
onPress={onPressUp}>
<FontAwesomeIcon
icon="arrow-up"
size={12}
style={[pal.text, styles.webArrowUpButton]}
/>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={onPressDown}>
<FontAwesomeIcon
icon="arrow-down"
size={12}
style={[pal.text]}
/>
</TouchableOpacity>
</View>
) : isPinned ? (
<FontAwesomeIcon
icon="bars"
size={20}
color={pal.colors.text}
style={s.ml20}
/>
) : null}
<CustomFeed
key={item.data.uri}
item={item}
showSaveBtn
style={styles.noBorder}
/>
<TouchableOpacity
accessibilityRole="button"
hitSlop={10}
onPress={onTogglePinned}>
<FontAwesomeIcon
icon="thumb-tack"
size={20}
color={isPinned ? colors.blue3 : pal.colors.icon}
/>
</TouchableOpacity>
</Pressable>
</ShadowDecorator>
</ScaleDecorator>
)
},
)
const styles = StyleSheet.create({
desktopContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
minHeight: '100vh',
},
empty: {
paddingHorizontal: 20,
paddingVertical: 20,
borderRadius: 16,
marginHorizontal: 24,
marginTop: 10,
},
itemContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
paddingRight: 16,
},
webArrowButtonsContainer: {
paddingLeft: 16,
flexDirection: 'column',
justifyContent: 'space-around',
},
webArrowUpButton: {
marginBottom: 10,
},
noBorder: {
borderTopWidth: 0,
},
footerText: {
paddingHorizontal: 26,
paddingTop: 22,
paddingBottom: 100,
},
footerLinks: {
borderBottomWidth: 1,
borderTopWidth: 0,
},
footerLink: {
flexDirection: 'row',
paddingHorizontal: 26,
paddingVertical: 18,
gap: 18,
},
})

View file

@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired(
const store = useStores()
const scrollViewRef = React.useRef<ScrollView>(null)
const flatListRef = React.useRef<FlatList>(null)
const onMainScroll = useOnMainScroll(store)
const [onMainScroll] = useOnMainScroll(store)
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteModel>(

View file

@ -142,6 +142,11 @@ export const SettingsScreen = withAuthRequired(
store.shell.openModal({name: 'delete-account'})
}, [store])
const onPressResetPreferences = React.useCallback(async () => {
await store.preferences.reset()
Toast.show('Preferences reset')
}, [store])
return (
<View style={[s.hContentRegion]} testID="settingsScreen">
<ViewHeader title="Settings" />
@ -330,6 +335,22 @@ export const SettingsScreen = withAuthRequired(
App passwords
</Text>
</Link>
<Link
testID="savedFeedsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
accessibilityHint="Saved Feeds"
accessibilityLabel="Opens screen with all saved feeds"
href="/settings/saved-feeds">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="satellite-dish"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Saved Feeds
</Text>
</Link>
<TouchableOpacity
testID="contentLanguagesBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@ -406,8 +427,18 @@ export const SettingsScreen = withAuthRequired(
Storybook
</Text>
</Link>
{__DEV__ ? (
<Link
style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressResetPreferences}
title="Debug tools">
<Text type="lg" style={pal.text}>
Reset preferences state
</Text>
</Link>
) : null}
<Text type="sm" style={[styles.buildInfo, pal.textLight]}>
Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
Build version {AppInfo.appVersion}
</Text>
<View style={s.footerSpacer} />
</ScrollView>