forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}