ALF saved feeds screen (#8844)
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -9,27 +8,26 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function NoFollowingFeed() {
|
||||
export function NoFollowingFeed({onAddFeed}: {onAddFeed?: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation()
|
||||
|
||||
const addRecommendedFeeds = React.useCallback(
|
||||
(e: any) => {
|
||||
e.preventDefault()
|
||||
const addRecommendedFeeds = (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
addSavedFeeds([
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
addSavedFeeds([
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
|
||||
// prevent navigation
|
||||
return false
|
||||
},
|
||||
[addSavedFeeds],
|
||||
)
|
||||
onAddFeed?.()
|
||||
|
||||
// prevent navigation
|
||||
return false as const
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.align_center, a.py_md, a.px_lg]}>
|
||||
@@ -37,7 +35,7 @@ export function NoFollowingFeed() {
|
||||
<Trans>
|
||||
Looks like you're missing a following feed.{' '}
|
||||
<InlineLinkText
|
||||
to="/"
|
||||
to="#"
|
||||
label={_(msg`Add the default feed of only people you follow`)}
|
||||
onPress={addRecommendedFeeds}
|
||||
style={[a.leading_snug]}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -16,20 +15,25 @@ import {Text} from '#/components/Typography'
|
||||
* feeds if pressed. It should only be presented to the user if they actually
|
||||
* have no other feeds saved.
|
||||
*/
|
||||
export function NoSavedFeedsOfAnyType() {
|
||||
export function NoSavedFeedsOfAnyType({
|
||||
onAddRecommendedFeeds,
|
||||
}: {
|
||||
onAddRecommendedFeeds?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isPending, mutateAsync: overwriteSavedFeeds} =
|
||||
useOverwriteSavedFeedsMutation()
|
||||
|
||||
const addRecommendedFeeds = React.useCallback(async () => {
|
||||
const addRecommendedFeeds = async () => {
|
||||
onAddRecommendedFeeds?.()
|
||||
await overwriteSavedFeeds(
|
||||
RECOMMENDED_SAVED_FEEDS.map(f => ({
|
||||
...f,
|
||||
id: TID.nextStr(),
|
||||
})),
|
||||
)
|
||||
}, [overwriteSavedFeeds])
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -46,10 +50,9 @@ export function NoSavedFeedsOfAnyType() {
|
||||
disabled={isPending}
|
||||
label={_(msg`Apply default recommended feeds`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
color="primary_subtle"
|
||||
onPress={addRecommendedFeeds}>
|
||||
<ButtonIcon icon={Plus} position="left" />
|
||||
<ButtonIcon icon={Plus} />
|
||||
<ButtonText>{_(msg`Use recommended`)}</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NavigationProp,
|
||||
} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
useOverwriteSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
|
||||
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon,
|
||||
ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon,
|
||||
} from '#/components/icons/Arrow'
|
||||
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
|
||||
import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk'
|
||||
import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
|
||||
export function SavedFeeds({}: Props) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
if (!preferences) {
|
||||
return <View />
|
||||
}
|
||||
return <SavedFeedsInner preferences={preferences} />
|
||||
}
|
||||
|
||||
function SavedFeedsInner({
|
||||
preferences,
|
||||
}: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
|
||||
useOverwriteSavedFeedsMutation()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
/*
|
||||
* Use optimistic data if exists and no error, otherwise fallback to remote
|
||||
* data
|
||||
*/
|
||||
const [currentFeeds, setCurrentFeeds] = useState(
|
||||
() => preferences.savedFeeds || [],
|
||||
)
|
||||
const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds
|
||||
const pinnedFeeds = currentFeeds.filter(f => f.pinned)
|
||||
const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
|
||||
const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
|
||||
const noFollowingFeed =
|
||||
currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const onSaveChanges = async () => {
|
||||
try {
|
||||
await overwriteSavedFeeds(currentFeeds)
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Feeds')
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
|
||||
logger.error('Failed to toggle pinned feed', {message: e})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content align="left">
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Feeds</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Button
|
||||
testID="saveChangesBtn"
|
||||
size="small"
|
||||
color={hasUnsavedChanges ? 'primary' : 'secondary'}
|
||||
onPress={onSaveChanges}
|
||||
label={_(msg`Save changes`)}
|
||||
disabled={isOverwritePending || !hasUnsavedChanges}>
|
||||
<ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} />
|
||||
<ButtonText>
|
||||
{gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</Layout.Header.Outer>
|
||||
|
||||
<Layout.Content>
|
||||
{noSavedFeedsOfAnyType && (
|
||||
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
||||
<NoSavedFeedsOfAnyType
|
||||
onAddRecommendedFeeds={() =>
|
||||
setCurrentFeeds(
|
||||
RECOMMENDED_SAVED_FEEDS.map(f => ({
|
||||
...f,
|
||||
id: TID.nextStr(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionHeaderText>
|
||||
<Trans>Pinned Feeds</Trans>
|
||||
</SectionHeaderText>
|
||||
|
||||
{preferences ? (
|
||||
!pinnedFeeds.length ? (
|
||||
<View style={[a.flex_1, a.p_lg]}>
|
||||
<Admonition type="info">
|
||||
<Trans>You don't have any pinned feeds.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
pinnedFeeds.map(f => (
|
||||
<ListItem
|
||||
key={f.id}
|
||||
feed={f}
|
||||
isPinned
|
||||
currentFeeds={currentFeeds}
|
||||
setCurrentFeeds={setCurrentFeeds}
|
||||
preferences={preferences}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<View style={[a.w_full, a.py_2xl, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{noFollowingFeed && (
|
||||
<View style={[t.atoms.border_contrast_low, a.border_b]}>
|
||||
<NoFollowingFeed
|
||||
onAddFeed={() =>
|
||||
setCurrentFeeds(feeds => [
|
||||
...feeds,
|
||||
{...TIMELINE_SAVED_FEED, id: TID.next().toString()},
|
||||
])
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionHeaderText>
|
||||
<Trans>Saved Feeds</Trans>
|
||||
</SectionHeaderText>
|
||||
|
||||
{preferences ? (
|
||||
!unpinnedFeeds.length ? (
|
||||
<View style={[a.flex_1, a.p_lg]}>
|
||||
<Admonition type="info">
|
||||
<Trans>You don't have any saved feeds.</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : (
|
||||
unpinnedFeeds.map(f => (
|
||||
<ListItem
|
||||
key={f.id}
|
||||
feed={f}
|
||||
isPinned={false}
|
||||
currentFeeds={currentFeeds}
|
||||
setCurrentFeeds={setCurrentFeeds}
|
||||
preferences={preferences}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<View style={[a.w_full, a.py_2xl, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.px_lg, a.py_xl]}>
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>
|
||||
Feeds are custom algorithms that users build with a little coding
|
||||
expertise.{' '}
|
||||
<InlineLinkText
|
||||
to="https://github.com/bluesky-social/feed-generator"
|
||||
label={_(msg`See this guide`)}
|
||||
disableMismatchWarning
|
||||
style={[a.leading_snug]}>
|
||||
See this guide
|
||||
</InlineLinkText>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function ListItem({
|
||||
feed,
|
||||
isPinned,
|
||||
currentFeeds,
|
||||
setCurrentFeeds,
|
||||
}: {
|
||||
feed: AppBskyActorDefs.SavedFeed
|
||||
isPinned: boolean
|
||||
currentFeeds: AppBskyActorDefs.SavedFeed[]
|
||||
setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]>
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const feedUri = feed.value
|
||||
|
||||
const onTogglePinned = async () => {
|
||||
playHaptic()
|
||||
setCurrentFeeds(
|
||||
currentFeeds.map(f =>
|
||||
f.id === feed.id ? {...feed, pinned: !feed.pinned} : f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const onPressUp = async () => {
|
||||
if (!isPinned) return
|
||||
|
||||
const nextFeeds = currentFeeds.slice()
|
||||
const ids = currentFeeds.map(f => f.id)
|
||||
const index = ids.indexOf(feed.id)
|
||||
const nextIndex = index - 1
|
||||
|
||||
if (index === -1 || index === 0) return
|
||||
;[nextFeeds[index], nextFeeds[nextIndex]] = [
|
||||
nextFeeds[nextIndex],
|
||||
nextFeeds[index],
|
||||
]
|
||||
|
||||
setCurrentFeeds(nextFeeds)
|
||||
}
|
||||
|
||||
const onPressDown = async () => {
|
||||
if (!isPinned) return
|
||||
|
||||
const nextFeeds = currentFeeds.slice()
|
||||
const ids = currentFeeds.map(f => f.id)
|
||||
const index = ids.indexOf(feed.id)
|
||||
const nextIndex = index + 1
|
||||
|
||||
if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1)
|
||||
return
|
||||
;[nextFeeds[index], nextFeeds[nextIndex]] = [
|
||||
nextFeeds[nextIndex],
|
||||
nextFeeds[index],
|
||||
]
|
||||
|
||||
setCurrentFeeds(nextFeeds)
|
||||
}
|
||||
|
||||
const onPressRemove = async () => {
|
||||
playHaptic()
|
||||
setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}
|
||||
layout={LinearTransition.duration(100)}>
|
||||
{feed.type === 'timeline' ? (
|
||||
<FollowingFeedCard />
|
||||
) : (
|
||||
<FeedSourceCard
|
||||
key={feedUri}
|
||||
feedUri={feedUri}
|
||||
style={[isPinned && a.pr_sm]}
|
||||
showMinimalPlaceholder
|
||||
hideTopBorder={true}
|
||||
/>
|
||||
)}
|
||||
<View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Button
|
||||
testID={`feed-${feed.type}-moveUp`}
|
||||
label={_(msg`Move feed up`)}
|
||||
onPress={onPressUp}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="square">
|
||||
<ButtonIcon icon={ArrowUpIcon} />
|
||||
</Button>
|
||||
<Button
|
||||
testID={`feed-${feed.type}-moveDown`}
|
||||
label={_(msg`Move feed down`)}
|
||||
onPress={onPressDown}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="square">
|
||||
<ButtonIcon icon={ArrowDownIcon} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
testID={`feed-${feedUri}-toggleSave`}
|
||||
label={_(msg`Remove from my feeds`)}
|
||||
onPress={onPressRemove}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="square">
|
||||
<ButtonIcon icon={TrashIcon} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
testID={`feed-${feed.type}-togglePin`}
|
||||
label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
|
||||
onPress={onTogglePinned}
|
||||
size="small"
|
||||
color={isPinned ? 'primary_subtle' : 'secondary'}
|
||||
shape="square">
|
||||
<ButtonIcon icon={PinIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeaderText({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.px_lg,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>{children}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FollowingFeedCard() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.mr_md,
|
||||
{
|
||||
width: 36,
|
||||
height: 36,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}>
|
||||
<FilterTimeline
|
||||
style={[
|
||||
{
|
||||
width: 22,
|
||||
height: 22,
|
||||
},
|
||||
]}
|
||||
fill={t.palette.white}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
|
||||
<Trans context="feed-name">Following</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user