* feed items * update some more * moar * profile card * composer and notifications * settings screen * remove border from first item in feeds * remove border from first item in feeds * more removal of top border * fix flatlist rendering * oops * scroll to top fab * a.border * centeredview/list * placeholder * web sidebar * search posts * feeds list * user lists * list header * account list width 1 * hide top border feedgens * same for lists * fix tab bar web desktop * wait... * show the border on desktop web * fix lists * fix lists * round
876 lines
24 KiB
TypeScript
876 lines
24 KiB
TypeScript
import React from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
type FlatList,
|
|
Pressable,
|
|
StyleSheet,
|
|
View,
|
|
} from 'react-native'
|
|
import {AppBskyActorDefs} from '@atproto/api'
|
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
|
import {msg, Trans} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {useFocusEffect} from '@react-navigation/native'
|
|
import debounce from 'lodash.debounce'
|
|
|
|
import {isNative, isWeb} from '#/platform/detection'
|
|
import {
|
|
getAvatarTypeFromUri,
|
|
useFeedSourceInfoQuery,
|
|
useGetPopularFeedsQuery,
|
|
useSearchPopularFeedsMutation,
|
|
} from '#/state/queries/feed'
|
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
import {useSession} from '#/state/session'
|
|
import {useSetMinimalShellMode} from '#/state/shell'
|
|
import {useComposerControls} from '#/state/shell/composer'
|
|
import {HITSLOP_10} from 'lib/constants'
|
|
import {usePalette} from 'lib/hooks/usePalette'
|
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
|
import {CogIcon, ComposeIcon2, MagnifyingGlassIcon2} from 'lib/icons'
|
|
import {FeedsTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
|
import {cleanError} from 'lib/strings/errors'
|
|
import {s} from 'lib/styles'
|
|
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
|
import {FAB} from 'view/com/util/fab/FAB'
|
|
import {SearchInput, SearchInputRef} from 'view/com/util/forms/SearchInput'
|
|
import {Link} from 'view/com/util/Link'
|
|
import {List} from 'view/com/util/List'
|
|
import {
|
|
FeedFeedLoadingPlaceholder,
|
|
LoadingPlaceholder,
|
|
} from 'view/com/util/LoadingPlaceholder'
|
|
import {Text} from 'view/com/util/text/Text'
|
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
|
import {ViewHeader} from 'view/com/util/ViewHeader'
|
|
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
|
|
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import {IconCircle} from '#/components/IconCircle'
|
|
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
|
|
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
|
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
|
import hairlineWidth = StyleSheet.hairlineWidth
|
|
|
|
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
|
|
|
|
type FlatlistSlice =
|
|
| {
|
|
type: 'error'
|
|
key: string
|
|
error: string
|
|
}
|
|
| {
|
|
type: 'savedFeedsHeader'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'savedFeedsLoading'
|
|
key: string
|
|
// pendingItems: number,
|
|
}
|
|
| {
|
|
type: 'savedFeedNoResults'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'savedFeed'
|
|
key: string
|
|
feedUri: string
|
|
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
|
}
|
|
| {
|
|
type: 'savedFeedsLoadMore'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'popularFeedsHeader'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'popularFeedsLoading'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'popularFeedsNoResults'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'popularFeed'
|
|
key: string
|
|
feedUri: string
|
|
}
|
|
| {
|
|
type: 'popularFeedsLoadingMore'
|
|
key: string
|
|
}
|
|
| {
|
|
type: 'noFollowingFeed'
|
|
key: string
|
|
}
|
|
|
|
// HACK
|
|
// the protocol doesn't yet tell us which feeds are personalized
|
|
// this list is used to filter out feed recommendations from logged out users
|
|
// for the ones we know need it
|
|
// -prf
|
|
const KNOWN_AUTHED_ONLY_FEEDS = [
|
|
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
|
|
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
|
|
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
|
|
'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
|
|
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
|
|
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
|
|
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
|
|
'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
|
|
]
|
|
|
|
export function FeedsScreen(_props: Props) {
|
|
const pal = usePalette('default')
|
|
const {openComposer} = useComposerControls()
|
|
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
|
|
const [query, setQuery] = React.useState('')
|
|
const [isPTR, setIsPTR] = React.useState(false)
|
|
const {
|
|
data: preferences,
|
|
isLoading: isPreferencesLoading,
|
|
error: preferencesError,
|
|
refetch: refetchPreferences,
|
|
} = usePreferencesQuery()
|
|
const {
|
|
data: popularFeeds,
|
|
isFetching: isPopularFeedsFetching,
|
|
error: popularFeedsError,
|
|
refetch: refetchPopularFeeds,
|
|
fetchNextPage: fetchNextPopularFeedsPage,
|
|
isFetchingNextPage: isPopularFeedsFetchingNextPage,
|
|
hasNextPage: hasNextPopularFeedsPage,
|
|
} = useGetPopularFeedsQuery()
|
|
const {_} = useLingui()
|
|
const setMinimalShellMode = useSetMinimalShellMode()
|
|
const {
|
|
data: searchResults,
|
|
mutate: search,
|
|
reset: resetSearch,
|
|
isPending: isSearchPending,
|
|
error: searchError,
|
|
} = useSearchPopularFeedsMutation()
|
|
const {hasSession} = useSession()
|
|
const listRef = React.useRef<FlatList>(null)
|
|
const searchInputRef = React.useRef<SearchInputRef>(null)
|
|
|
|
/**
|
|
* A search query is present. We may not have search results yet.
|
|
*/
|
|
const isUserSearching = query.length > 1
|
|
const debouncedSearch = React.useMemo(
|
|
() => debounce(q => search(q), 500), // debounce for 500ms
|
|
[search],
|
|
)
|
|
const onPressCompose = React.useCallback(() => {
|
|
openComposer({})
|
|
}, [openComposer])
|
|
const onChangeQuery = React.useCallback(
|
|
(text: string) => {
|
|
setQuery(text)
|
|
if (text.length > 1) {
|
|
debouncedSearch(text)
|
|
} else {
|
|
refetchPopularFeeds()
|
|
resetSearch()
|
|
}
|
|
},
|
|
[setQuery, refetchPopularFeeds, debouncedSearch, resetSearch],
|
|
)
|
|
const onPressCancelSearch = React.useCallback(() => {
|
|
setQuery('')
|
|
refetchPopularFeeds()
|
|
resetSearch()
|
|
}, [refetchPopularFeeds, setQuery, resetSearch])
|
|
const onSubmitQuery = React.useCallback(() => {
|
|
debouncedSearch(query)
|
|
}, [query, debouncedSearch])
|
|
const onPullToRefresh = React.useCallback(async () => {
|
|
setIsPTR(true)
|
|
await Promise.all([
|
|
refetchPreferences().catch(_e => undefined),
|
|
refetchPopularFeeds().catch(_e => undefined),
|
|
])
|
|
setIsPTR(false)
|
|
}, [setIsPTR, refetchPreferences, refetchPopularFeeds])
|
|
const onEndReached = React.useCallback(() => {
|
|
if (
|
|
isPopularFeedsFetching ||
|
|
isUserSearching ||
|
|
!hasNextPopularFeedsPage ||
|
|
popularFeedsError
|
|
)
|
|
return
|
|
fetchNextPopularFeedsPage()
|
|
}, [
|
|
isPopularFeedsFetching,
|
|
isUserSearching,
|
|
popularFeedsError,
|
|
hasNextPopularFeedsPage,
|
|
fetchNextPopularFeedsPage,
|
|
])
|
|
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
setMinimalShellMode(false)
|
|
}, [setMinimalShellMode]),
|
|
)
|
|
|
|
const items = React.useMemo(() => {
|
|
let slices: FlatlistSlice[] = []
|
|
|
|
if (hasSession) {
|
|
slices.push({
|
|
key: 'savedFeedsHeader',
|
|
type: 'savedFeedsHeader',
|
|
})
|
|
|
|
if (preferencesError) {
|
|
slices.push({
|
|
key: 'savedFeedsError',
|
|
type: 'error',
|
|
error: cleanError(preferencesError.toString()),
|
|
})
|
|
} else {
|
|
if (isPreferencesLoading || !preferences?.savedFeeds) {
|
|
slices.push({
|
|
key: 'savedFeedsLoading',
|
|
type: 'savedFeedsLoading',
|
|
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
|
})
|
|
} else {
|
|
if (preferences.savedFeeds?.length) {
|
|
const noFollowingFeed = preferences.savedFeeds.every(
|
|
f => f.type !== 'timeline',
|
|
)
|
|
|
|
slices = slices.concat(
|
|
preferences.savedFeeds
|
|
.filter(f => {
|
|
return f.pinned
|
|
})
|
|
.map(feed => ({
|
|
key: `savedFeed:${feed.value}:${feed.id}`,
|
|
type: 'savedFeed',
|
|
feedUri: feed.value,
|
|
savedFeedConfig: feed,
|
|
})),
|
|
)
|
|
slices = slices.concat(
|
|
preferences.savedFeeds
|
|
.filter(f => {
|
|
return !f.pinned
|
|
})
|
|
.map(feed => ({
|
|
key: `savedFeed:${feed.value}:${feed.id}`,
|
|
type: 'savedFeed',
|
|
feedUri: feed.value,
|
|
savedFeedConfig: feed,
|
|
})),
|
|
)
|
|
|
|
if (noFollowingFeed) {
|
|
slices.push({
|
|
key: 'noFollowingFeed',
|
|
type: 'noFollowingFeed',
|
|
})
|
|
}
|
|
} else {
|
|
slices.push({
|
|
key: 'savedFeedNoResults',
|
|
type: 'savedFeedNoResults',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
slices.push({
|
|
key: 'popularFeedsHeader',
|
|
type: 'popularFeedsHeader',
|
|
})
|
|
|
|
if (popularFeedsError || searchError) {
|
|
slices.push({
|
|
key: 'popularFeedsError',
|
|
type: 'error',
|
|
error: cleanError(
|
|
popularFeedsError?.toString() ?? searchError?.toString() ?? '',
|
|
),
|
|
})
|
|
} else {
|
|
if (isUserSearching) {
|
|
if (isSearchPending || !searchResults) {
|
|
slices.push({
|
|
key: 'popularFeedsLoading',
|
|
type: 'popularFeedsLoading',
|
|
})
|
|
} else {
|
|
if (!searchResults || searchResults?.length === 0) {
|
|
slices.push({
|
|
key: 'popularFeedsNoResults',
|
|
type: 'popularFeedsNoResults',
|
|
})
|
|
} else {
|
|
slices = slices.concat(
|
|
searchResults.map(feed => ({
|
|
key: `popularFeed:${feed.uri}`,
|
|
type: 'popularFeed',
|
|
feedUri: feed.uri,
|
|
})),
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
if (isPopularFeedsFetching && !popularFeeds?.pages) {
|
|
slices.push({
|
|
key: 'popularFeedsLoading',
|
|
type: 'popularFeedsLoading',
|
|
})
|
|
} else {
|
|
if (
|
|
!popularFeeds?.pages ||
|
|
popularFeeds?.pages[0]?.feeds?.length === 0
|
|
) {
|
|
slices.push({
|
|
key: 'popularFeedsNoResults',
|
|
type: 'popularFeedsNoResults',
|
|
})
|
|
} else {
|
|
for (const page of popularFeeds.pages || []) {
|
|
slices = slices.concat(
|
|
page.feeds
|
|
.filter(feed => {
|
|
if (
|
|
!hasSession &&
|
|
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
|
|
) {
|
|
return false
|
|
}
|
|
const alreadySaved = Boolean(
|
|
preferences?.savedFeeds?.find(f => {
|
|
return f.value === feed.uri
|
|
}),
|
|
)
|
|
return !alreadySaved
|
|
})
|
|
.map(feed => ({
|
|
key: `popularFeed:${feed.uri}`,
|
|
type: 'popularFeed',
|
|
feedUri: feed.uri,
|
|
})),
|
|
)
|
|
}
|
|
|
|
if (isPopularFeedsFetchingNextPage) {
|
|
slices.push({
|
|
key: 'popularFeedsLoadingMore',
|
|
type: 'popularFeedsLoadingMore',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return slices
|
|
}, [
|
|
hasSession,
|
|
preferences,
|
|
isPreferencesLoading,
|
|
preferencesError,
|
|
popularFeeds,
|
|
isPopularFeedsFetching,
|
|
popularFeedsError,
|
|
isPopularFeedsFetchingNextPage,
|
|
searchResults,
|
|
isSearchPending,
|
|
searchError,
|
|
isUserSearching,
|
|
])
|
|
|
|
const renderHeaderBtn = React.useCallback(() => {
|
|
return (
|
|
<View style={styles.headerBtnGroup}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
hitSlop={HITSLOP_10}
|
|
onPress={searchInputRef.current?.focus}>
|
|
<MagnifyingGlassIcon2
|
|
size={22}
|
|
strokeWidth={2}
|
|
style={pal.textLight}
|
|
/>
|
|
</Pressable>
|
|
<Link
|
|
href="/settings/saved-feeds"
|
|
hitSlop={10}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
|
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
|
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
|
</Link>
|
|
</View>
|
|
)
|
|
}, [pal, _])
|
|
|
|
const searchBarIndex = items.findIndex(
|
|
item => item.type === 'popularFeedsHeader',
|
|
)
|
|
|
|
const onChangeSearchFocus = React.useCallback(
|
|
(focus: boolean) => {
|
|
if (focus && searchBarIndex > -1) {
|
|
if (isNative) {
|
|
// scrollToIndex scrolls the exact right amount, so use if available
|
|
listRef.current?.scrollToIndex({
|
|
index: searchBarIndex,
|
|
animated: true,
|
|
})
|
|
} else {
|
|
// web implementation only supports scrollToOffset
|
|
// thus, we calculate the offset based on the index
|
|
// pixel values are estimates, I wasn't able to get it pixel perfect :(
|
|
const headerHeight = isMobile ? 43 : 53
|
|
const feedItemHeight = isMobile ? 49 : 58
|
|
listRef.current?.scrollToOffset({
|
|
offset: searchBarIndex * feedItemHeight - headerHeight,
|
|
animated: true,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
[searchBarIndex, isMobile],
|
|
)
|
|
|
|
const renderItem = React.useCallback(
|
|
({item}: {item: FlatlistSlice}) => {
|
|
if (item.type === 'error') {
|
|
return <ErrorMessage message={item.error} />
|
|
} else if (
|
|
item.type === 'popularFeedsLoadingMore' ||
|
|
item.type === 'savedFeedsLoading'
|
|
) {
|
|
return (
|
|
<View style={s.p10}>
|
|
<ActivityIndicator size="large" />
|
|
</View>
|
|
)
|
|
} else if (item.type === 'savedFeedsHeader') {
|
|
return (
|
|
<>
|
|
{!isMobile && (
|
|
<View
|
|
style={[
|
|
pal.view,
|
|
styles.header,
|
|
pal.border,
|
|
{
|
|
borderBottomWidth: 1,
|
|
},
|
|
]}>
|
|
<Text type="title-lg" style={[pal.text, s.bold]}>
|
|
<Trans>Feeds</Trans>
|
|
</Text>
|
|
<View style={styles.headerBtnGroup}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
hitSlop={HITSLOP_10}
|
|
onPress={searchInputRef.current?.focus}>
|
|
<MagnifyingGlassIcon2
|
|
size={22}
|
|
strokeWidth={2}
|
|
style={pal.icon}
|
|
/>
|
|
</Pressable>
|
|
<Link
|
|
href="/settings/saved-feeds"
|
|
accessibilityLabel={_(msg`Edit My Feeds`)}
|
|
accessibilityHint="">
|
|
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
|
|
</Link>
|
|
</View>
|
|
</View>
|
|
)}
|
|
<FeedsSavedHeader />
|
|
</>
|
|
)
|
|
} else if (item.type === 'savedFeedNoResults') {
|
|
return (
|
|
<View
|
|
style={[
|
|
pal.border,
|
|
{
|
|
borderBottomWidth: 1,
|
|
},
|
|
]}>
|
|
<NoSavedFeedsOfAnyType />
|
|
</View>
|
|
)
|
|
} else if (item.type === 'savedFeed') {
|
|
return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} />
|
|
} else if (item.type === 'popularFeedsHeader') {
|
|
return (
|
|
<>
|
|
<FeedsAboutHeader />
|
|
<View style={{paddingHorizontal: 12, paddingBottom: 12}}>
|
|
<SearchInput
|
|
ref={searchInputRef}
|
|
query={query}
|
|
onChangeQuery={onChangeQuery}
|
|
onPressCancelSearch={onPressCancelSearch}
|
|
onSubmitQuery={onSubmitQuery}
|
|
setIsInputFocused={onChangeSearchFocus}
|
|
/>
|
|
</View>
|
|
</>
|
|
)
|
|
} else if (item.type === 'popularFeedsLoading') {
|
|
return <FeedFeedLoadingPlaceholder />
|
|
} else if (item.type === 'popularFeed') {
|
|
return (
|
|
<FeedSourceCard
|
|
feedUri={item.feedUri}
|
|
showSaveBtn={hasSession}
|
|
showDescription
|
|
showLikes
|
|
pinOnSave
|
|
/>
|
|
)
|
|
} else if (item.type === 'popularFeedsNoResults') {
|
|
return (
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 16,
|
|
paddingTop: 10,
|
|
paddingBottom: '150%',
|
|
}}>
|
|
<Text type="lg" style={pal.textLight}>
|
|
<Trans>No results found for "{query}"</Trans>
|
|
</Text>
|
|
</View>
|
|
)
|
|
} else if (item.type === 'noFollowingFeed') {
|
|
return (
|
|
<View
|
|
style={[
|
|
pal.border,
|
|
{
|
|
borderBottomWidth: 1,
|
|
},
|
|
]}>
|
|
<NoFollowingFeed />
|
|
</View>
|
|
)
|
|
}
|
|
return null
|
|
},
|
|
[
|
|
isMobile,
|
|
pal.view,
|
|
pal.border,
|
|
pal.text,
|
|
pal.icon,
|
|
pal.textLight,
|
|
_,
|
|
query,
|
|
onChangeQuery,
|
|
onPressCancelSearch,
|
|
onSubmitQuery,
|
|
onChangeSearchFocus,
|
|
hasSession,
|
|
],
|
|
)
|
|
|
|
return (
|
|
<View style={[pal.view, styles.container]}>
|
|
{isMobile && (
|
|
<ViewHeader
|
|
title={_(msg`Feeds`)}
|
|
canGoBack={false}
|
|
renderButton={renderHeaderBtn}
|
|
showBorder
|
|
/>
|
|
)}
|
|
|
|
<List
|
|
ref={listRef}
|
|
style={[!isTabletOrDesktop && s.flex1, styles.list]}
|
|
data={items}
|
|
keyExtractor={item => item.key}
|
|
contentContainerStyle={styles.contentContainer}
|
|
renderItem={renderItem}
|
|
refreshing={isPTR}
|
|
onRefresh={isUserSearching ? undefined : onPullToRefresh}
|
|
initialNumToRender={10}
|
|
onEndReached={onEndReached}
|
|
// @ts-ignore our .web version only -prf
|
|
desktopFixedHeight
|
|
scrollIndicatorInsets={{right: 1}}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="on-drag"
|
|
/>
|
|
|
|
{hasSession && (
|
|
<FAB
|
|
testID="composeFAB"
|
|
onPress={onPressCompose}
|
|
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={_(msg`New post`)}
|
|
accessibilityHint=""
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function FeedOrFollowing({
|
|
savedFeedConfig: feed,
|
|
}: {
|
|
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
|
}) {
|
|
return feed.type === 'timeline' ? (
|
|
<FollowingFeed />
|
|
) : (
|
|
<SavedFeed savedFeedConfig={feed} />
|
|
)
|
|
}
|
|
|
|
function FollowingFeed() {
|
|
const pal = usePalette('default')
|
|
const t = useTheme()
|
|
const {isMobile} = useWebMediaQueries()
|
|
return (
|
|
<View
|
|
testID={`saved-feed-timeline`}
|
|
style={[
|
|
pal.border,
|
|
styles.savedFeed,
|
|
isMobile && styles.savedFeedMobile,
|
|
]}>
|
|
<View
|
|
style={[
|
|
a.align_center,
|
|
a.justify_center,
|
|
{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 3,
|
|
backgroundColor: t.palette.primary_500,
|
|
},
|
|
]}>
|
|
<FilterTimeline
|
|
style={[
|
|
{
|
|
width: 18,
|
|
height: 18,
|
|
},
|
|
]}
|
|
fill={t.palette.white}
|
|
/>
|
|
</View>
|
|
<View
|
|
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
|
|
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
|
|
<Trans>Following</Trans>
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function SavedFeed({
|
|
savedFeedConfig: feed,
|
|
}: {
|
|
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
|
}) {
|
|
const pal = usePalette('default')
|
|
const {isMobile} = useWebMediaQueries()
|
|
const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
|
|
const typeAvatar = getAvatarTypeFromUri(feed.value)
|
|
|
|
if (!info)
|
|
return (
|
|
<SavedFeedLoadingPlaceholder
|
|
key={`savedFeedLoadingPlaceholder:${feed.value}`}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<Link
|
|
testID={`saved-feed-${info.displayName}`}
|
|
href={info.route.href}
|
|
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
|
|
hoverStyle={pal.viewLight}
|
|
accessibilityLabel={info.displayName}
|
|
accessibilityHint=""
|
|
asAnchor
|
|
anchorNoUnderline>
|
|
{error ? (
|
|
<View
|
|
style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
|
|
<FontAwesomeIcon
|
|
icon="exclamation-circle"
|
|
color={pal.colors.textLight}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<UserAvatar type={typeAvatar} size={28} avatar={info.avatar} />
|
|
)}
|
|
<View
|
|
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
|
|
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
|
|
{info.displayName}
|
|
</Text>
|
|
{error ? (
|
|
<View style={[styles.offlineSlug, pal.borderDark]}>
|
|
<Text type="xs" style={pal.textLight}>
|
|
<Trans>Feed offline</Trans>
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
{isMobile && (
|
|
<FontAwesomeIcon
|
|
icon="chevron-right"
|
|
size={14}
|
|
style={pal.textLight as FontAwesomeIconStyle}
|
|
/>
|
|
)}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function SavedFeedLoadingPlaceholder() {
|
|
const pal = usePalette('default')
|
|
const {isMobile} = useWebMediaQueries()
|
|
return (
|
|
<View
|
|
style={[
|
|
pal.border,
|
|
styles.savedFeed,
|
|
isMobile && styles.savedFeedMobile,
|
|
]}>
|
|
<LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
|
|
<LoadingPlaceholder width={140} height={12} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function FeedsSavedHeader() {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View
|
|
style={
|
|
isWeb
|
|
? [
|
|
a.flex_row,
|
|
a.px_md,
|
|
a.py_lg,
|
|
a.gap_md,
|
|
a.border_b,
|
|
t.atoms.border_contrast_low,
|
|
]
|
|
: [
|
|
{flexDirection: 'row-reverse'},
|
|
a.p_lg,
|
|
a.gap_md,
|
|
a.border_b,
|
|
t.atoms.border_contrast_low,
|
|
]
|
|
}>
|
|
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
|
|
<View style={[a.flex_1, a.gap_xs]}>
|
|
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
|
<Trans>My Feeds</Trans>
|
|
</Text>
|
|
<Text style={[t.atoms.text_contrast_high]}>
|
|
<Trans>All the feeds you've saved, right in one place.</Trans>
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function FeedsAboutHeader() {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View
|
|
style={
|
|
isWeb
|
|
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
|
|
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
|
|
}>
|
|
<IconCircle
|
|
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
|
|
size="lg"
|
|
/>
|
|
<View style={[a.flex_1, a.gap_sm]}>
|
|
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
|
<Trans>Discover New Feeds</Trans>
|
|
</Text>
|
|
<Text style={[t.atoms.text_contrast_high]}>
|
|
<Trans>
|
|
Custom feeds built by the community bring you new experiences and
|
|
help you find the content you love.
|
|
</Trans>
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
list: {
|
|
height: '100%',
|
|
},
|
|
contentContainer: {
|
|
paddingBottom: 100,
|
|
},
|
|
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 16,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 12,
|
|
},
|
|
|
|
savedFeed: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
gap: 12,
|
|
borderBottomWidth: hairlineWidth,
|
|
},
|
|
savedFeedMobile: {
|
|
paddingVertical: 10,
|
|
},
|
|
offlineSlug: {
|
|
borderWidth: hairlineWidth,
|
|
borderRadius: 4,
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 2,
|
|
},
|
|
headerBtnGroup: {
|
|
flexDirection: 'row',
|
|
gap: 15,
|
|
alignItems: 'center',
|
|
},
|
|
})
|