Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cb54cd87bb36c92ebcd59cd0b2edb77245b1b999 415 lines 13 kB view raw
1import {useCallback, useState} from 'react' 2import {View} from 'react-native' 3import Animated, {LinearTransition} from 'react-native-reanimated' 4import {type AppBskyActorDefs} from '@atproto/api' 5import {TID} from '@atproto/common-web' 6import {msg, Trans} from '@lingui/macro' 7import {useLingui} from '@lingui/react' 8import {useFocusEffect} from '@react-navigation/native' 9import {useNavigation} from '@react-navigation/native' 10import {type NativeStackScreenProps} from '@react-navigation/native-stack' 11 12import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants' 13import {useHaptics} from '#/lib/haptics' 14import { 15 type CommonNavigatorParams, 16 type NavigationProp, 17} from '#/lib/routes/types' 18import {logger} from '#/logger' 19import { 20 useOverwriteSavedFeedsMutation, 21 usePreferencesQuery, 22} from '#/state/queries/preferences' 23import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 24import {useSetMinimalShellMode} from '#/state/shell' 25import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' 26import * as Toast from '#/view/com/util/Toast' 27import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 28import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 29import {atoms as a, useBreakpoints, useTheme} from '#/alf' 30import {Admonition} from '#/components/Admonition' 31import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32import { 33 ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, 34 ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, 35} from '#/components/icons/Arrow' 36import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 37import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 38import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 39import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 40import * as Layout from '#/components/Layout' 41import {InlineLinkText} from '#/components/Link' 42import {Loader} from '#/components/Loader' 43import {Text} from '#/components/Typography' 44 45type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 46export function SavedFeeds({}: Props) { 47 const {data: preferences} = usePreferencesQuery() 48 if (!preferences) { 49 return <View /> 50 } 51 return <SavedFeedsInner preferences={preferences} /> 52} 53 54function 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 234function 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 363function 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_bold, a.leading_snug]}>{children}</Text> 378 </View> 379 ) 380} 381 382function 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_semi_bold, a.leading_snug]}> 410 <Trans context="feed-name">Following</Trans> 411 </Text> 412 </View> 413 </View> 414 ) 415}