Bluesky app fork with some witchin' additions 💫

ALF saved feeds screen (#8844)

authored by samuel.fm and committed by

GitHub 372e20ef 9e8bceec

+442 -476
+1 -1
src/Navigation.tsx
··· 64 64 import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' 65 65 import {ProfileScreen} from '#/view/screens/Profile' 66 66 import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 67 - import {SavedFeeds} from '#/view/screens/SavedFeeds' 68 67 import {Storybook} from '#/view/screens/Storybook' 69 68 import {SupportScreen} from '#/view/screens/Support' 70 69 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' ··· 92 91 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 93 92 import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' 94 93 import {ProfileListScreen} from '#/screens/ProfileList' 94 + import {SavedFeeds} from '#/screens/SavedFeeds' 95 95 import {SearchScreen} from '#/screens/Search' 96 96 import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' 97 97 import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
+16 -18
src/screens/Feeds/NoFollowingFeed.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 1 + import {type GestureResponderEvent, View} from 'react-native' 3 2 import {msg, Trans} from '@lingui/macro' 4 3 import {useLingui} from '@lingui/react' 5 4 ··· 9 8 import {InlineLinkText} from '#/components/Link' 10 9 import {Text} from '#/components/Typography' 11 10 12 - export function NoFollowingFeed() { 11 + export function NoFollowingFeed({onAddFeed}: {onAddFeed?: () => void}) { 13 12 const t = useTheme() 14 13 const {_} = useLingui() 15 14 const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation() 16 15 17 - const addRecommendedFeeds = React.useCallback( 18 - (e: any) => { 19 - e.preventDefault() 16 + const addRecommendedFeeds = (e: GestureResponderEvent) => { 17 + e.preventDefault() 20 18 21 - addSavedFeeds([ 22 - { 23 - ...TIMELINE_SAVED_FEED, 24 - pinned: true, 25 - }, 26 - ]) 19 + addSavedFeeds([ 20 + { 21 + ...TIMELINE_SAVED_FEED, 22 + pinned: true, 23 + }, 24 + ]) 27 25 28 - // prevent navigation 29 - return false 30 - }, 31 - [addSavedFeeds], 32 - ) 26 + onAddFeed?.() 27 + 28 + // prevent navigation 29 + return false as const 30 + } 33 31 34 32 return ( 35 33 <View style={[a.flex_row, a.flex_wrap, a.align_center, a.py_md, a.px_lg]}> ··· 37 35 <Trans> 38 36 Looks like you're missing a following feed.{' '} 39 37 <InlineLinkText 40 - to="/" 38 + to="#" 41 39 label={_(msg`Add the default feed of only people you follow`)} 42 40 onPress={addRecommendedFeeds} 43 41 style={[a.leading_snug]}>
+10 -7
src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
··· 1 - import React from 'react' 2 1 import {View} from 'react-native' 3 2 import {TID} from '@atproto/common-web' 4 3 import {msg, Trans} from '@lingui/macro' ··· 16 15 * feeds if pressed. It should only be presented to the user if they actually 17 16 * have no other feeds saved. 18 17 */ 19 - export function NoSavedFeedsOfAnyType() { 18 + export function NoSavedFeedsOfAnyType({ 19 + onAddRecommendedFeeds, 20 + }: { 21 + onAddRecommendedFeeds?: () => void 22 + }) { 20 23 const t = useTheme() 21 24 const {_} = useLingui() 22 25 const {isPending, mutateAsync: overwriteSavedFeeds} = 23 26 useOverwriteSavedFeedsMutation() 24 27 25 - const addRecommendedFeeds = React.useCallback(async () => { 28 + const addRecommendedFeeds = async () => { 29 + onAddRecommendedFeeds?.() 26 30 await overwriteSavedFeeds( 27 31 RECOMMENDED_SAVED_FEEDS.map(f => ({ 28 32 ...f, 29 33 id: TID.nextStr(), 30 34 })), 31 35 ) 32 - }, [overwriteSavedFeeds]) 36 + } 33 37 34 38 return ( 35 39 <View ··· 46 50 disabled={isPending} 47 51 label={_(msg`Apply default recommended feeds`)} 48 52 size="small" 49 - variant="solid" 50 - color="primary" 53 + color="primary_subtle" 51 54 onPress={addRecommendedFeeds}> 52 - <ButtonIcon icon={Plus} position="left" /> 55 + <ButtonIcon icon={Plus} /> 53 56 <ButtonText>{_(msg`Use recommended`)}</ButtonText> 54 57 </Button> 55 58 </View>
+415
src/screens/SavedFeeds.tsx
··· 1 + import {useCallback, useState} from 'react' 2 + import {View} from 'react-native' 3 + import Animated, {LinearTransition} from 'react-native-reanimated' 4 + import {type AppBskyActorDefs} from '@atproto/api' 5 + import {TID} from '@atproto/common-web' 6 + import {msg, Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {useFocusEffect} from '@react-navigation/native' 9 + import {useNavigation} from '@react-navigation/native' 10 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 11 + 12 + import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants' 13 + import {useHaptics} from '#/lib/haptics' 14 + import { 15 + type CommonNavigatorParams, 16 + type NavigationProp, 17 + } from '#/lib/routes/types' 18 + import {logger} from '#/logger' 19 + import { 20 + useOverwriteSavedFeedsMutation, 21 + usePreferencesQuery, 22 + } from '#/state/queries/preferences' 23 + import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 24 + import {useSetMinimalShellMode} from '#/state/shell' 25 + import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 26 + import * as Toast from '#/view/com/util/Toast' 27 + import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 28 + import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 29 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 30 + import {Admonition} from '#/components/Admonition' 31 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 + import { 33 + ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, 34 + ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, 35 + } from '#/components/icons/Arrow' 36 + import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 37 + import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 38 + import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 39 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 40 + import * as Layout from '#/components/Layout' 41 + import {InlineLinkText} from '#/components/Link' 42 + import {Loader} from '#/components/Loader' 43 + import {Text} from '#/components/Typography' 44 + 45 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 46 + export function SavedFeeds({}: Props) { 47 + const {data: preferences} = usePreferencesQuery() 48 + if (!preferences) { 49 + return <View /> 50 + } 51 + return <SavedFeedsInner preferences={preferences} /> 52 + } 53 + 54 + function SavedFeedsInner({ 55 + preferences, 56 + }: { 57 + preferences: UsePreferencesQueryResponse 58 + }) { 59 + const t = useTheme() 60 + const {_} = useLingui() 61 + const {gtMobile} = useBreakpoints() 62 + const setMinimalShellMode = useSetMinimalShellMode() 63 + const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 64 + useOverwriteSavedFeedsMutation() 65 + const navigation = useNavigation<NavigationProp>() 66 + 67 + /* 68 + * Use optimistic data if exists and no error, otherwise fallback to remote 69 + * data 70 + */ 71 + const [currentFeeds, setCurrentFeeds] = useState( 72 + () => preferences.savedFeeds || [], 73 + ) 74 + const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 75 + const pinnedFeeds = currentFeeds.filter(f => f.pinned) 76 + const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 77 + const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 78 + const noFollowingFeed = 79 + currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 80 + 81 + useFocusEffect( 82 + useCallback(() => { 83 + setMinimalShellMode(false) 84 + }, [setMinimalShellMode]), 85 + ) 86 + 87 + const onSaveChanges = async () => { 88 + try { 89 + await overwriteSavedFeeds(currentFeeds) 90 + Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 91 + if (navigation.canGoBack()) { 92 + navigation.goBack() 93 + } else { 94 + navigation.navigate('Feeds') 95 + } 96 + } catch (e) { 97 + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 98 + logger.error('Failed to toggle pinned feed', {message: e}) 99 + } 100 + } 101 + 102 + return ( 103 + <Layout.Screen> 104 + <Layout.Header.Outer> 105 + <Layout.Header.BackButton /> 106 + <Layout.Header.Content align="left"> 107 + <Layout.Header.TitleText> 108 + <Trans>Feeds</Trans> 109 + </Layout.Header.TitleText> 110 + </Layout.Header.Content> 111 + <Button 112 + testID="saveChangesBtn" 113 + size="small" 114 + color={hasUnsavedChanges ? 'primary' : 'secondary'} 115 + onPress={onSaveChanges} 116 + label={_(msg`Save changes`)} 117 + disabled={isOverwritePending || !hasUnsavedChanges}> 118 + <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} /> 119 + <ButtonText> 120 + {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 121 + </ButtonText> 122 + </Button> 123 + </Layout.Header.Outer> 124 + 125 + <Layout.Content> 126 + {noSavedFeedsOfAnyType && ( 127 + <View style={[t.atoms.border_contrast_low, a.border_b]}> 128 + <NoSavedFeedsOfAnyType 129 + onAddRecommendedFeeds={() => 130 + setCurrentFeeds( 131 + RECOMMENDED_SAVED_FEEDS.map(f => ({ 132 + ...f, 133 + id: TID.nextStr(), 134 + })), 135 + ) 136 + } 137 + /> 138 + </View> 139 + )} 140 + 141 + <SectionHeaderText> 142 + <Trans>Pinned Feeds</Trans> 143 + </SectionHeaderText> 144 + 145 + {preferences ? ( 146 + !pinnedFeeds.length ? ( 147 + <View style={[a.flex_1, a.p_lg]}> 148 + <Admonition type="info"> 149 + <Trans>You don't have any pinned feeds.</Trans> 150 + </Admonition> 151 + </View> 152 + ) : ( 153 + pinnedFeeds.map(f => ( 154 + <ListItem 155 + key={f.id} 156 + feed={f} 157 + isPinned 158 + currentFeeds={currentFeeds} 159 + setCurrentFeeds={setCurrentFeeds} 160 + preferences={preferences} 161 + /> 162 + )) 163 + ) 164 + ) : ( 165 + <View style={[a.w_full, a.py_2xl, a.align_center]}> 166 + <Loader size="xl" /> 167 + </View> 168 + )} 169 + 170 + {noFollowingFeed && ( 171 + <View style={[t.atoms.border_contrast_low, a.border_b]}> 172 + <NoFollowingFeed 173 + onAddFeed={() => 174 + setCurrentFeeds(feeds => [ 175 + ...feeds, 176 + {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, 177 + ]) 178 + } 179 + /> 180 + </View> 181 + )} 182 + 183 + <SectionHeaderText> 184 + <Trans>Saved Feeds</Trans> 185 + </SectionHeaderText> 186 + 187 + {preferences ? ( 188 + !unpinnedFeeds.length ? ( 189 + <View style={[a.flex_1, a.p_lg]}> 190 + <Admonition type="info"> 191 + <Trans>You don't have any saved feeds.</Trans> 192 + </Admonition> 193 + </View> 194 + ) : ( 195 + unpinnedFeeds.map(f => ( 196 + <ListItem 197 + key={f.id} 198 + feed={f} 199 + isPinned={false} 200 + currentFeeds={currentFeeds} 201 + setCurrentFeeds={setCurrentFeeds} 202 + preferences={preferences} 203 + /> 204 + )) 205 + ) 206 + ) : ( 207 + <View style={[a.w_full, a.py_2xl, a.align_center]}> 208 + <Loader size="xl" /> 209 + </View> 210 + )} 211 + 212 + <View style={[a.px_lg, a.py_xl]}> 213 + <Text 214 + style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}> 215 + <Trans> 216 + Feeds are custom algorithms that users build with a little coding 217 + expertise.{' '} 218 + <InlineLinkText 219 + to="https://github.com/bluesky-social/feed-generator" 220 + label={_(msg`See this guide`)} 221 + disableMismatchWarning 222 + style={[a.leading_snug]}> 223 + See this guide 224 + </InlineLinkText>{' '} 225 + for more information. 226 + </Trans> 227 + </Text> 228 + </View> 229 + </Layout.Content> 230 + </Layout.Screen> 231 + ) 232 + } 233 + 234 + function ListItem({ 235 + feed, 236 + isPinned, 237 + currentFeeds, 238 + setCurrentFeeds, 239 + }: { 240 + feed: AppBskyActorDefs.SavedFeed 241 + isPinned: boolean 242 + currentFeeds: AppBskyActorDefs.SavedFeed[] 243 + setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]> 244 + preferences: UsePreferencesQueryResponse 245 + }) { 246 + const {_} = useLingui() 247 + const t = useTheme() 248 + const playHaptic = useHaptics() 249 + const feedUri = feed.value 250 + 251 + const onTogglePinned = async () => { 252 + playHaptic() 253 + setCurrentFeeds( 254 + currentFeeds.map(f => 255 + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 256 + ), 257 + ) 258 + } 259 + 260 + const onPressUp = async () => { 261 + if (!isPinned) return 262 + 263 + const nextFeeds = currentFeeds.slice() 264 + const ids = currentFeeds.map(f => f.id) 265 + const index = ids.indexOf(feed.id) 266 + const nextIndex = index - 1 267 + 268 + if (index === -1 || index === 0) return 269 + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 270 + nextFeeds[nextIndex], 271 + nextFeeds[index], 272 + ] 273 + 274 + setCurrentFeeds(nextFeeds) 275 + } 276 + 277 + const onPressDown = async () => { 278 + if (!isPinned) return 279 + 280 + const nextFeeds = currentFeeds.slice() 281 + const ids = currentFeeds.map(f => f.id) 282 + const index = ids.indexOf(feed.id) 283 + const nextIndex = index + 1 284 + 285 + if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) 286 + return 287 + ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 288 + nextFeeds[nextIndex], 289 + nextFeeds[index], 290 + ] 291 + 292 + setCurrentFeeds(nextFeeds) 293 + } 294 + 295 + const onPressRemove = async () => { 296 + playHaptic() 297 + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 298 + } 299 + 300 + return ( 301 + <Animated.View 302 + style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]} 303 + layout={LinearTransition.duration(100)}> 304 + {feed.type === 'timeline' ? ( 305 + <FollowingFeedCard /> 306 + ) : ( 307 + <FeedSourceCard 308 + key={feedUri} 309 + feedUri={feedUri} 310 + style={[isPinned && a.pr_sm]} 311 + showMinimalPlaceholder 312 + hideTopBorder={true} 313 + /> 314 + )} 315 + <View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}> 316 + {isPinned ? ( 317 + <> 318 + <Button 319 + testID={`feed-${feed.type}-moveUp`} 320 + label={_(msg`Move feed up`)} 321 + onPress={onPressUp} 322 + size="small" 323 + color="secondary" 324 + shape="square"> 325 + <ButtonIcon icon={ArrowUpIcon} /> 326 + </Button> 327 + <Button 328 + testID={`feed-${feed.type}-moveDown`} 329 + label={_(msg`Move feed down`)} 330 + onPress={onPressDown} 331 + size="small" 332 + color="secondary" 333 + shape="square"> 334 + <ButtonIcon icon={ArrowDownIcon} /> 335 + </Button> 336 + </> 337 + ) : ( 338 + <Button 339 + testID={`feed-${feedUri}-toggleSave`} 340 + label={_(msg`Remove from my feeds`)} 341 + onPress={onPressRemove} 342 + size="small" 343 + color="secondary" 344 + variant="ghost" 345 + shape="square"> 346 + <ButtonIcon icon={TrashIcon} /> 347 + </Button> 348 + )} 349 + <Button 350 + testID={`feed-${feed.type}-togglePin`} 351 + label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)} 352 + onPress={onTogglePinned} 353 + size="small" 354 + color={isPinned ? 'primary_subtle' : 'secondary'} 355 + shape="square"> 356 + <ButtonIcon icon={PinIcon} /> 357 + </Button> 358 + </View> 359 + </Animated.View> 360 + ) 361 + } 362 + 363 + function SectionHeaderText({children}: {children: React.ReactNode}) { 364 + const t = useTheme() 365 + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 366 + return ( 367 + <View 368 + style={[ 369 + a.flex_row, 370 + a.flex_1, 371 + a.px_lg, 372 + a.pt_2xl, 373 + a.pb_md, 374 + a.border_b, 375 + t.atoms.border_contrast_low, 376 + ]}> 377 + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>{children}</Text> 378 + </View> 379 + ) 380 + } 381 + 382 + function FollowingFeedCard() { 383 + const t = useTheme() 384 + return ( 385 + <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}> 386 + <View 387 + style={[ 388 + a.align_center, 389 + a.justify_center, 390 + a.rounded_sm, 391 + a.mr_md, 392 + { 393 + width: 36, 394 + height: 36, 395 + backgroundColor: t.palette.primary_500, 396 + }, 397 + ]}> 398 + <FilterTimeline 399 + style={[ 400 + { 401 + width: 22, 402 + height: 22, 403 + }, 404 + ]} 405 + fill={t.palette.white} 406 + /> 407 + </View> 408 + <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}> 409 + <Text style={[a.text_sm, a.font_bold, a.leading_snug]}> 410 + <Trans context="feed-name">Following</Trans> 411 + </Text> 412 + </View> 413 + </View> 414 + ) 415 + }
-450
src/view/screens/SavedFeeds.tsx
··· 1 - import React from 'react' 2 - import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' 3 - import Animated, {LinearTransition} from 'react-native-reanimated' 4 - import {type AppBskyActorDefs} from '@atproto/api' 5 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 - import {msg, Trans} from '@lingui/macro' 7 - import {useLingui} from '@lingui/react' 8 - import {useFocusEffect} from '@react-navigation/native' 9 - import {useNavigation} from '@react-navigation/native' 10 - import {type NativeStackScreenProps} from '@react-navigation/native-stack' 11 - 12 - import {useHaptics} from '#/lib/haptics' 13 - import {usePalette} from '#/lib/hooks/usePalette' 14 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 15 - import { 16 - type CommonNavigatorParams, 17 - type NavigationProp, 18 - } from '#/lib/routes/types' 19 - import {colors, s} from '#/lib/styles' 20 - import {logger} from '#/logger' 21 - import { 22 - useOverwriteSavedFeedsMutation, 23 - usePreferencesQuery, 24 - } from '#/state/queries/preferences' 25 - import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 26 - import {useSetMinimalShellMode} from '#/state/shell' 27 - import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 28 - import {TextLink} from '#/view/com/util/Link' 29 - import {Text} from '#/view/com/util/text/Text' 30 - import * as Toast from '#/view/com/util/Toast' 31 - import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 32 - import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 33 - import {atoms as a, useTheme} from '#/alf' 34 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 35 - import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 36 - import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 37 - import * as Layout from '#/components/Layout' 38 - import {Loader} from '#/components/Loader' 39 - import {Text as NewText} from '#/components/Typography' 40 - 41 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 42 - export function SavedFeeds({}: Props) { 43 - const {data: preferences} = usePreferencesQuery() 44 - if (!preferences) { 45 - return <View /> 46 - } 47 - return <SavedFeedsInner preferences={preferences} /> 48 - } 49 - 50 - function SavedFeedsInner({ 51 - preferences, 52 - }: { 53 - preferences: UsePreferencesQueryResponse 54 - }) { 55 - const pal = usePalette('default') 56 - const {_} = useLingui() 57 - const {isMobile, isDesktop} = useWebMediaQueries() 58 - const setMinimalShellMode = useSetMinimalShellMode() 59 - const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 60 - useOverwriteSavedFeedsMutation() 61 - const navigation = useNavigation<NavigationProp>() 62 - 63 - /* 64 - * Use optimistic data if exists and no error, otherwise fallback to remote 65 - * data 66 - */ 67 - const [currentFeeds, setCurrentFeeds] = React.useState( 68 - () => preferences.savedFeeds || [], 69 - ) 70 - const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 71 - const pinnedFeeds = currentFeeds.filter(f => f.pinned) 72 - const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 73 - const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 74 - const noFollowingFeed = 75 - currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType 76 - 77 - useFocusEffect( 78 - React.useCallback(() => { 79 - setMinimalShellMode(false) 80 - }, [setMinimalShellMode]), 81 - ) 82 - 83 - const onSaveChanges = React.useCallback(async () => { 84 - try { 85 - await overwriteSavedFeeds(currentFeeds) 86 - Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 87 - if (navigation.canGoBack()) { 88 - navigation.goBack() 89 - } else { 90 - navigation.navigate('Feeds') 91 - } 92 - } catch (e) { 93 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 94 - logger.error('Failed to toggle pinned feed', {message: e}) 95 - } 96 - }, [_, overwriteSavedFeeds, currentFeeds, navigation]) 97 - 98 - return ( 99 - <Layout.Screen> 100 - <Layout.Header.Outer> 101 - <Layout.Header.BackButton /> 102 - <Layout.Header.Content align="left"> 103 - <Layout.Header.TitleText> 104 - <Trans>Feeds</Trans> 105 - </Layout.Header.TitleText> 106 - </Layout.Header.Content> 107 - <Button 108 - testID="saveChangesBtn" 109 - size="small" 110 - variant={hasUnsavedChanges ? 'solid' : 'solid'} 111 - color={hasUnsavedChanges ? 'primary' : 'secondary'} 112 - onPress={onSaveChanges} 113 - label={_(msg`Save changes`)} 114 - disabled={isOverwritePending || !hasUnsavedChanges}> 115 - <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} /> 116 - <ButtonText> 117 - {isDesktop ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 118 - </ButtonText> 119 - </Button> 120 - </Layout.Header.Outer> 121 - 122 - <Layout.Content> 123 - {noSavedFeedsOfAnyType && ( 124 - <View style={[pal.border, a.border_b]}> 125 - <NoSavedFeedsOfAnyType /> 126 - </View> 127 - )} 128 - 129 - <View style={[pal.text, pal.border, styles.title]}> 130 - <Text type="title" style={pal.text}> 131 - <Trans>Pinned Feeds</Trans> 132 - </Text> 133 - </View> 134 - 135 - {preferences ? ( 136 - !pinnedFeeds.length ? ( 137 - <View 138 - style={[ 139 - pal.border, 140 - isMobile && s.flex1, 141 - pal.viewLight, 142 - styles.empty, 143 - ]}> 144 - <Text type="lg" style={[pal.text]}> 145 - <Trans>You don't have any pinned feeds.</Trans> 146 - </Text> 147 - </View> 148 - ) : ( 149 - pinnedFeeds.map(f => ( 150 - <ListItem 151 - key={f.id} 152 - feed={f} 153 - isPinned 154 - currentFeeds={currentFeeds} 155 - setCurrentFeeds={setCurrentFeeds} 156 - preferences={preferences} 157 - /> 158 - )) 159 - ) 160 - ) : ( 161 - <ActivityIndicator style={{marginTop: 20}} /> 162 - )} 163 - 164 - {noFollowingFeed && ( 165 - <View style={[pal.border, a.border_b]}> 166 - <NoFollowingFeed /> 167 - </View> 168 - )} 169 - 170 - <View style={[pal.text, pal.border, styles.title]}> 171 - <Text type="title" style={pal.text}> 172 - <Trans>Saved Feeds</Trans> 173 - </Text> 174 - </View> 175 - {preferences ? ( 176 - !unpinnedFeeds.length ? ( 177 - <View 178 - style={[ 179 - pal.border, 180 - isMobile && s.flex1, 181 - pal.viewLight, 182 - styles.empty, 183 - ]}> 184 - <Text type="lg" style={[pal.text]}> 185 - <Trans>You don't have any saved feeds.</Trans> 186 - </Text> 187 - </View> 188 - ) : ( 189 - unpinnedFeeds.map(f => ( 190 - <ListItem 191 - key={f.id} 192 - feed={f} 193 - isPinned={false} 194 - currentFeeds={currentFeeds} 195 - setCurrentFeeds={setCurrentFeeds} 196 - preferences={preferences} 197 - /> 198 - )) 199 - ) 200 - ) : ( 201 - <ActivityIndicator style={{marginTop: 20}} /> 202 - )} 203 - 204 - <View style={styles.footerText}> 205 - <Text type="sm" style={pal.textLight}> 206 - <Trans> 207 - Feeds are custom algorithms that users build with a little coding 208 - expertise.{' '} 209 - <TextLink 210 - type="sm" 211 - style={pal.link} 212 - href="https://github.com/bluesky-social/feed-generator" 213 - text={_(msg`See this guide`)} 214 - />{' '} 215 - for more information. 216 - </Trans> 217 - </Text> 218 - </View> 219 - </Layout.Content> 220 - </Layout.Screen> 221 - ) 222 - } 223 - 224 - function ListItem({ 225 - feed, 226 - isPinned, 227 - currentFeeds, 228 - setCurrentFeeds, 229 - }: { 230 - feed: AppBskyActorDefs.SavedFeed 231 - isPinned: boolean 232 - currentFeeds: AppBskyActorDefs.SavedFeed[] 233 - setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]> 234 - preferences: UsePreferencesQueryResponse 235 - }) { 236 - const {_} = useLingui() 237 - const pal = usePalette('default') 238 - const playHaptic = useHaptics() 239 - const feedUri = feed.value 240 - 241 - const onTogglePinned = React.useCallback(async () => { 242 - playHaptic() 243 - setCurrentFeeds( 244 - currentFeeds.map(f => 245 - f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 246 - ), 247 - ) 248 - }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) 249 - 250 - const onPressUp = React.useCallback(async () => { 251 - if (!isPinned) return 252 - 253 - const nextFeeds = currentFeeds.slice() 254 - const ids = currentFeeds.map(f => f.id) 255 - const index = ids.indexOf(feed.id) 256 - const nextIndex = index - 1 257 - 258 - if (index === -1 || index === 0) return 259 - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 260 - nextFeeds[nextIndex], 261 - nextFeeds[index], 262 - ] 263 - 264 - setCurrentFeeds(nextFeeds) 265 - }, [feed, isPinned, setCurrentFeeds, currentFeeds]) 266 - 267 - const onPressDown = React.useCallback(async () => { 268 - if (!isPinned) return 269 - 270 - const nextFeeds = currentFeeds.slice() 271 - const ids = currentFeeds.map(f => f.id) 272 - const index = ids.indexOf(feed.id) 273 - const nextIndex = index + 1 274 - 275 - if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) 276 - return 277 - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 278 - nextFeeds[nextIndex], 279 - nextFeeds[index], 280 - ] 281 - 282 - setCurrentFeeds(nextFeeds) 283 - }, [feed, isPinned, setCurrentFeeds, currentFeeds]) 284 - 285 - const onPressRemove = React.useCallback(async () => { 286 - playHaptic() 287 - setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 288 - }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) 289 - 290 - return ( 291 - <Animated.View 292 - style={[styles.itemContainer, pal.border]} 293 - layout={LinearTransition.duration(100)}> 294 - {feed.type === 'timeline' ? ( 295 - <FollowingFeedCard /> 296 - ) : ( 297 - <FeedSourceCard 298 - key={feedUri} 299 - feedUri={feedUri} 300 - style={[isPinned && a.pr_sm]} 301 - showMinimalPlaceholder 302 - hideTopBorder={true} 303 - /> 304 - )} 305 - {isPinned ? ( 306 - <> 307 - <Pressable 308 - accessibilityRole="button" 309 - onPress={onPressUp} 310 - hitSlop={5} 311 - style={state => ({ 312 - backgroundColor: pal.viewLight.backgroundColor, 313 - paddingHorizontal: 12, 314 - paddingVertical: 10, 315 - borderRadius: 4, 316 - marginRight: 8, 317 - opacity: state.hovered || state.pressed ? 0.5 : 1, 318 - })} 319 - testID={`feed-${feed.type}-moveUp`}> 320 - <FontAwesomeIcon 321 - icon="arrow-up" 322 - size={14} 323 - style={[pal.textLight]} 324 - /> 325 - </Pressable> 326 - <Pressable 327 - accessibilityRole="button" 328 - onPress={onPressDown} 329 - hitSlop={5} 330 - style={state => ({ 331 - backgroundColor: pal.viewLight.backgroundColor, 332 - paddingHorizontal: 12, 333 - paddingVertical: 10, 334 - borderRadius: 4, 335 - marginRight: 8, 336 - opacity: state.hovered || state.pressed ? 0.5 : 1, 337 - })} 338 - testID={`feed-${feed.type}-moveDown`}> 339 - <FontAwesomeIcon 340 - icon="arrow-down" 341 - size={14} 342 - style={[pal.textLight]} 343 - /> 344 - </Pressable> 345 - </> 346 - ) : ( 347 - <Pressable 348 - testID={`feed-${feedUri}-toggleSave`} 349 - accessibilityRole="button" 350 - accessibilityLabel={_(msg`Remove from my feeds`)} 351 - accessibilityHint="" 352 - onPress={onPressRemove} 353 - hitSlop={5} 354 - style={state => ({ 355 - marginRight: 8, 356 - paddingHorizontal: 12, 357 - paddingVertical: 10, 358 - borderRadius: 4, 359 - opacity: state.hovered || state.focused ? 0.5 : 1, 360 - })}> 361 - <FontAwesomeIcon 362 - icon={['far', 'trash-can']} 363 - size={19} 364 - color={pal.colors.icon} 365 - /> 366 - </Pressable> 367 - )} 368 - <View style={{paddingRight: 16}}> 369 - <Pressable 370 - accessibilityRole="button" 371 - hitSlop={5} 372 - onPress={onTogglePinned} 373 - style={state => ({ 374 - backgroundColor: pal.viewLight.backgroundColor, 375 - paddingHorizontal: 12, 376 - paddingVertical: 10, 377 - borderRadius: 4, 378 - opacity: state.hovered || state.focused ? 0.5 : 1, 379 - })} 380 - testID={`feed-${feed.type}-togglePin`}> 381 - <FontAwesomeIcon 382 - icon="thumb-tack" 383 - size={14} 384 - color={isPinned ? colors.blue3 : pal.colors.icon} 385 - /> 386 - </Pressable> 387 - </View> 388 - </Animated.View> 389 - ) 390 - } 391 - 392 - function FollowingFeedCard() { 393 - const t = useTheme() 394 - return ( 395 - <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}> 396 - <View 397 - style={[ 398 - a.align_center, 399 - a.justify_center, 400 - a.rounded_sm, 401 - a.mr_md, 402 - { 403 - width: 36, 404 - height: 36, 405 - backgroundColor: t.palette.primary_500, 406 - }, 407 - ]}> 408 - <FilterTimeline 409 - style={[ 410 - { 411 - width: 22, 412 - height: 22, 413 - }, 414 - ]} 415 - fill={t.palette.white} 416 - /> 417 - </View> 418 - <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}> 419 - <NewText style={[a.text_sm, a.font_bold, a.leading_snug]}> 420 - <Trans context="feed-name">Following</Trans> 421 - </NewText> 422 - </View> 423 - </View> 424 - ) 425 - } 426 - 427 - const styles = StyleSheet.create({ 428 - empty: { 429 - paddingHorizontal: 20, 430 - paddingVertical: 20, 431 - borderRadius: 8, 432 - marginHorizontal: 10, 433 - marginTop: 10, 434 - }, 435 - title: { 436 - paddingHorizontal: 14, 437 - paddingTop: 20, 438 - paddingBottom: 10, 439 - borderBottomWidth: StyleSheet.hairlineWidth, 440 - }, 441 - itemContainer: { 442 - flexDirection: 'row', 443 - alignItems: 'center', 444 - borderBottomWidth: StyleSheet.hairlineWidth, 445 - }, 446 - footerText: { 447 - paddingHorizontal: 26, 448 - paddingVertical: 22, 449 - }, 450 - })