+1
assets/icons/arrowTop_stroke2_corner0_rounded.svg
+1
assets/icons/arrowTop_stroke2_corner0_rounded.svg
···
1
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M11 20V6.164l-4.293 4.293a1 1 0 1 1-1.414-1.414l5.293-5.293.151-.138a2 2 0 0 1 2.677.138l5.293 5.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 6.164V20a1 1 0 0 1-2 0Z"/></svg>
+1
-1
src/components/SubtleWebHover.web.tsx
+1
-1
src/components/SubtleWebHover.web.tsx
+4
src/components/icons/Arrow.tsx
+4
src/components/icons/Arrow.tsx
···
4
4
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
5
5
})
6
6
7
+
export const ArrowTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
8
+
path: 'M11 20V6.164l-4.293 4.293a1 1 0 1 1-1.414-1.414l5.293-5.293.151-.138a2 2 0 0 1 2.677.138l5.293 5.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 6.164V20a1 1 0 0 1-2 0Z',
9
+
})
10
+
7
11
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
8
12
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
9
13
})
+13
-13
src/view/com/feeds/FeedPage.tsx
+13
-13
src/view/com/feeds/FeedPage.tsx
···
1
-
import React from 'react'
1
+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
import {View} from 'react-native'
3
3
import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
4
4
import {msg} from '@lingui/macro'
···
58
58
const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
59
59
const queryClient = useQueryClient()
60
60
const {openComposer} = useOpenComposer()
61
-
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
61
+
const [isScrolledDown, setIsScrolledDown] = useState(false)
62
62
const setMinimalShellMode = useSetMinimalShellMode()
63
63
const headerOffset = useHeaderOffset()
64
64
const feedFeedback = useFeedFeedback(feed, hasSession)
65
-
const scrollElRef = React.useRef<ListMethods>(null)
66
-
const [hasNew, setHasNew] = React.useState(false)
65
+
const scrollElRef = useRef<ListMethods>(null)
66
+
const [hasNew, setHasNew] = useState(false)
67
67
const setHomeBadge = useSetHomeBadge()
68
-
const isVideoFeed = React.useMemo(() => {
68
+
const isVideoFeed = useMemo(() => {
69
69
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
70
70
const feedIsVideoMode =
71
71
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
···
73
73
return isNative && _isVideoFeed
74
74
}, [feedInfo])
75
75
76
-
React.useEffect(() => {
76
+
useEffect(() => {
77
77
if (isPageFocused) {
78
78
setHomeBadge(hasNew)
79
79
}
80
80
}, [isPageFocused, hasNew, setHomeBadge])
81
81
82
-
const scrollToTop = React.useCallback(() => {
82
+
const scrollToTop = useCallback(() => {
83
83
scrollElRef.current?.scrollToOffset({
84
84
animated: isNative,
85
85
offset: -headerOffset,
···
87
87
setMinimalShellMode(false)
88
88
}, [headerOffset, setMinimalShellMode])
89
89
90
-
const onSoftReset = React.useCallback(() => {
90
+
const onSoftReset = useCallback(() => {
91
91
const isScreenFocused =
92
92
getTabState(getRootNavigation(navigation).getState(), 'Home') ===
93
93
TabState.InsideAtRoot
···
101
101
reason: 'soft-reset',
102
102
})
103
103
}
104
-
}, [navigation, isPageFocused, scrollToTop, queryClient, feed, setHasNew])
104
+
}, [navigation, isPageFocused, scrollToTop, queryClient, feed])
105
105
106
106
// fires when page within screen is activated/deactivated
107
-
React.useEffect(() => {
107
+
useEffect(() => {
108
108
if (!isPageFocused) {
109
109
return
110
110
}
111
111
return listenSoftReset(onSoftReset)
112
112
}, [onSoftReset, isPageFocused])
113
113
114
-
const onPressCompose = React.useCallback(() => {
114
+
const onPressCompose = useCallback(() => {
115
115
openComposer({})
116
116
}, [openComposer])
117
117
118
-
const onPressLoadLatest = React.useCallback(() => {
118
+
const onPressLoadLatest = useCallback(() => {
119
119
scrollToTop()
120
120
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
121
121
setHasNew(false)
···
124
124
feedUrl: feed,
125
125
reason: 'load-latest',
126
126
})
127
-
}, [scrollToTop, feed, queryClient, setHasNew])
127
+
}, [scrollToTop, feed, queryClient])
128
128
129
129
const shouldPrefetch = isNative && isPageAdjacent
130
130
return (
+35
-48
src/view/com/posts/PostFeed.tsx
+35
-48
src/view/com/posts/PostFeed.tsx
···
1
-
import React, {memo, useCallback, useRef} from 'react'
1
+
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
import {
3
3
ActivityIndicator,
4
4
AppState,
···
22
22
import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
23
23
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
24
24
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
25
+
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
25
26
import {logEvent} from '#/lib/statsig/statsig'
26
27
import {logger} from '#/logger'
27
28
import {isIOS, isNative, isWeb} from '#/platform/detection'
···
208
209
const {currentAccount, hasSession} = useSession()
209
210
const initialNumToRender = useInitialNumToRender()
210
211
const feedFeedback = useFeedFeedbackContext()
211
-
const [isPTRing, setIsPTRing] = React.useState(false)
212
-
const checkForNewRef = React.useRef<(() => void) | null>(null)
213
-
const lastFetchRef = React.useRef<number>(Date.now())
212
+
const [isPTRing, setIsPTRing] = useState(false)
213
+
const lastFetchRef = useRef<number>(Date.now())
214
214
const [feedType, feedUriOrActorDid, feedTab] = feed.split('|')
215
215
const {gtMobile} = useBreakpoints()
216
216
const {rightNavVisible} = useLayoutBreakpoints()
217
217
const areVideoFeedsEnabled = isNative
218
218
219
-
const [hasPressedShowLessUris, setHasPressedShowLessUris] = React.useState(
219
+
const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
220
220
() => new Set<string>(),
221
221
)
222
222
const onPressShowLess = useCallback(
···
231
231
)
232
232
233
233
const feedCacheKey = feedParams?.feedCacheKey
234
-
const opts = React.useMemo(
234
+
const opts = useMemo(
235
235
() => ({enabled, ignoreFilterFor}),
236
236
[enabled, ignoreFilterFor],
237
237
)
···
250
250
if (lastFetchedAt) {
251
251
lastFetchRef.current = lastFetchedAt
252
252
}
253
-
const isEmpty = React.useMemo(
253
+
const isEmpty = useMemo(
254
254
() => !isFetching && !data?.pages?.some(page => page.slices.length),
255
255
[isFetching, data],
256
256
)
257
257
258
-
const checkForNew = React.useCallback(async () => {
258
+
const checkForNew = useNonReactiveCallback(async () => {
259
+
if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
260
+
return
261
+
}
262
+
259
263
// Discover always has fresh content
260
264
if (feedUriOrActorDid === DISCOVER_FEED_URI) {
261
-
return onHasNew?.(true)
265
+
return onHasNew(true)
262
266
}
263
267
264
-
if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
265
-
return
266
-
}
267
268
try {
268
269
if (await pollLatest(data.pages[0])) {
269
270
if (isEmpty) {
···
275
276
} catch (e) {
276
277
logger.error('Poll latest failed', {feed, message: String(e)})
277
278
}
278
-
}, [
279
-
feed,
280
-
data,
281
-
isFetching,
282
-
isEmpty,
283
-
onHasNew,
284
-
enabled,
285
-
disablePoll,
286
-
refetch,
287
-
feedUriOrActorDid,
288
-
])
279
+
})
289
280
290
281
const myDid = currentAccount?.did || ''
291
-
const onPostCreated = React.useCallback(() => {
282
+
const onPostCreated = useCallback(() => {
292
283
// NOTE
293
284
// only invalidate if there's 1 page
294
285
// more than 1 page can trigger some UI freakouts on iOS and android
···
301
292
queryClient.invalidateQueries({queryKey: RQKEY(feed)})
302
293
}
303
294
}, [queryClient, feed, data, myDid])
304
-
React.useEffect(() => {
295
+
useEffect(() => {
305
296
return listenPostCreated(onPostCreated)
306
297
}, [onPostCreated])
307
298
308
-
React.useEffect(() => {
309
-
// we store the interval handler in a ref to avoid needless
310
-
// reassignments in other effects
311
-
checkForNewRef.current = checkForNew
312
-
}, [checkForNew])
313
-
React.useEffect(() => {
299
+
useEffect(() => {
314
300
if (enabled && !disablePoll) {
315
301
const timeSinceFirstLoad = Date.now() - lastFetchRef.current
316
-
if (
317
-
(isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) &&
318
-
checkForNewRef.current
319
-
) {
302
+
if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) {
320
303
// check for new on enable (aka on focus)
321
-
checkForNewRef.current()
304
+
checkForNew()
322
305
}
323
306
}
324
-
}, [enabled, disablePoll, feed, queryClient, scrollElRef, isEmpty])
325
-
React.useEffect(() => {
307
+
}, [enabled, isEmpty, disablePoll, checkForNew])
308
+
309
+
useEffect(() => {
326
310
let cleanup1: () => void | undefined, cleanup2: () => void | undefined
327
311
const subscription = AppState.addEventListener('change', nextAppState => {
328
312
// check for new on app foreground
329
313
if (nextAppState === 'active') {
330
-
checkForNewRef.current?.()
314
+
checkForNew()
331
315
}
332
316
})
333
317
cleanup1 = () => subscription.remove()
334
318
if (pollInterval) {
335
319
// check for new on interval
336
-
const i = setInterval(() => checkForNewRef.current?.(), pollInterval)
320
+
const i = setInterval(() => {
321
+
checkForNew()
322
+
}, pollInterval)
337
323
cleanup2 = () => clearInterval(i)
338
324
}
339
325
return () => {
340
326
cleanup1?.()
341
327
cleanup2?.()
342
328
}
343
-
}, [pollInterval])
329
+
}, [pollInterval, checkForNew])
344
330
345
331
const followProgressGuide = useProgressGuide('follow-10')
346
332
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
···
350
336
351
337
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
352
338
353
-
const feedItems: FeedRow[] = React.useMemo(() => {
339
+
const feedItems: FeedRow[] = useMemo(() => {
354
340
// wraps a slice item, and replaces it with a showLessFollowup item
355
341
// if the user has pressed show less on it
356
342
const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => {
···
407
393
for (const page of data.pages) {
408
394
for (const slice of page.slices) {
409
395
const item = slice.items.find(
396
+
// eslint-disable-next-line @typescript-eslint/no-shadow
410
397
item => item.uri === slice.feedPostUri,
411
398
)
412
399
if (item && AppBskyEmbedVideo.isView(item.post.embed)) {
···
599
586
// events
600
587
// =
601
588
602
-
const onRefresh = React.useCallback(async () => {
589
+
const onRefresh = useCallback(async () => {
603
590
logEvent('feed:refresh', {
604
591
feedType: feedType,
605
592
feedUrl: feed,
···
615
602
setIsPTRing(false)
616
603
}, [refetch, setIsPTRing, onHasNew, feed, feedType])
617
604
618
-
const onEndReached = React.useCallback(async () => {
605
+
const onEndReached = useCallback(async () => {
619
606
if (isFetching || !hasNextPage || isError) return
620
607
621
608
logEvent('feed:endReached', {
···
638
625
feedItems.length,
639
626
])
640
627
641
-
const onPressTryAgain = React.useCallback(() => {
628
+
const onPressTryAgain = useCallback(() => {
642
629
refetch()
643
630
onHasNew?.(false)
644
631
}, [refetch, onHasNew])
645
632
646
-
const onPressRetryLoadMore = React.useCallback(() => {
633
+
const onPressRetryLoadMore = useCallback(() => {
647
634
fetchNextPage()
648
635
}, [fetchNextPage])
649
636
650
637
// rendering
651
638
// =
652
639
653
-
const renderItem = React.useCallback(
640
+
const renderItem = useCallback(
654
641
({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => {
655
642
if (row.type === 'empty') {
656
643
return renderEmptyState()
···
773
760
774
761
const shouldRenderEndOfFeed =
775
762
!hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
776
-
const FeedFooter = React.useCallback(() => {
763
+
const FeedFooter = useCallback(() => {
777
764
/**
778
765
* A bit of padding at the bottom of the feed as you scroll and when you
779
766
* reach the end, so that content isn't cut off by the bottom of the
+55
-50
src/view/com/util/load-latest/LoadLatestBtn.tsx
+55
-50
src/view/com/util/load-latest/LoadLatestBtn.tsx
···
1
-
import {StyleSheet, View} from 'react-native'
1
+
import {StyleSheet} from 'react-native'
2
2
import Animated from 'react-native-reanimated'
3
3
import {useSafeAreaInsets} from 'react-native-safe-area-context'
4
-
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
5
4
import {useMediaQuery} from 'react-responsive'
6
5
7
6
import {HITSLOP_20} from '#/lib/constants'
8
7
import {PressableScale} from '#/lib/custom-animations/PressableScale'
9
8
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
10
-
import {usePalette} from '#/lib/hooks/usePalette'
11
9
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
12
10
import {clamp} from '#/lib/numbers'
13
11
import {useGate} from '#/lib/statsig/statsig'
14
-
import {colors} from '#/lib/styles'
15
12
import {useSession} from '#/state/session'
16
-
import {atoms as a, useLayoutBreakpoints} from '#/alf'
13
+
import {atoms as a, useLayoutBreakpoints, useTheme, web} from '#/alf'
14
+
import {useInteractionState} from '#/components/hooks/useInteractionState'
15
+
import {ArrowTop_Stroke2_Corner0_Rounded as ArrowIcon} from '#/components/icons/Arrow'
16
+
import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
17
+
import {SubtleWebHover} from '#/components/SubtleWebHover'
17
18
18
19
export function LoadLatestBtn({
19
20
onPress,
···
24
25
label: string
25
26
showIndicator: boolean
26
27
}) {
27
-
const pal = usePalette('default')
28
28
const {hasSession} = useSession()
29
29
const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
30
30
const {centerColumnOffset} = useLayoutBreakpoints()
31
31
const fabMinimalShellTransform = useMinimalShellFabTransform()
32
32
const insets = useSafeAreaInsets()
33
+
const t = useTheme()
34
+
const {
35
+
state: hovered,
36
+
onIn: onHoverIn,
37
+
onOut: onHoverOut,
38
+
} = useInteractionState()
33
39
34
40
// move button inline if it starts overlapping the left nav
35
41
const isTallViewport = useMediaQuery({minHeight: 700})
···
48
54
: {bottom: clamp(insets.bottom, 15, 60) + 15}
49
55
50
56
return (
51
-
<Animated.View style={[showBottomBar && fabMinimalShellTransform]}>
57
+
<Animated.View
58
+
testID="loadLatestBtn"
59
+
style={[
60
+
a.fixed,
61
+
a.z_20,
62
+
{left: 18},
63
+
isDesktop &&
64
+
(isTallViewport
65
+
? styles.loadLatestOutOfLine
66
+
: styles.loadLatestInline),
67
+
isTablet &&
68
+
(centerColumnOffset
69
+
? styles.loadLatestInlineOffset
70
+
: styles.loadLatestInline),
71
+
bottomPosition,
72
+
showBottomBar && fabMinimalShellTransform,
73
+
]}>
52
74
<PressableScale
53
75
style={[
54
-
styles.loadLatest,
55
-
isDesktop &&
56
-
(isTallViewport
57
-
? styles.loadLatestOutOfLine
58
-
: styles.loadLatestInline),
59
-
isTablet &&
60
-
(centerColumnOffset
61
-
? styles.loadLatestInlineOffset
62
-
: styles.loadLatestInline),
63
-
pal.borderDark,
64
-
pal.view,
65
-
bottomPosition,
76
+
{
77
+
width: 42,
78
+
height: 42,
79
+
},
80
+
a.rounded_full,
81
+
a.align_center,
82
+
a.justify_center,
83
+
a.border,
84
+
t.atoms.border_contrast_low,
85
+
showIndicator ? {backgroundColor: t.palette.primary_50} : t.atoms.bg,
66
86
]}
67
87
onPress={onPress}
68
88
hitSlop={HITSLOP_20}
69
89
accessibilityLabel={label}
70
90
accessibilityHint=""
71
-
targetScale={0.9}>
72
-
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
73
-
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
91
+
targetScale={0.9}
92
+
onPointerEnter={onHoverIn}
93
+
onPointerLeave={onHoverOut}>
94
+
<SubtleWebHover hover={hovered} style={[a.rounded_full]} />
95
+
<ArrowIcon
96
+
size="md"
97
+
style={[
98
+
a.z_10,
99
+
showIndicator
100
+
? {color: t.palette.primary_500}
101
+
: t.atoms.text_contrast_medium,
102
+
]}
103
+
/>
74
104
</PressableScale>
75
105
</Animated.View>
76
106
)
77
107
}
78
108
79
109
const styles = StyleSheet.create({
80
-
loadLatest: {
81
-
zIndex: 20,
82
-
...a.fixed,
83
-
left: 18,
84
-
borderWidth: StyleSheet.hairlineWidth,
85
-
width: 52,
86
-
height: 52,
87
-
borderRadius: 26,
88
-
flexDirection: 'row',
89
-
alignItems: 'center',
90
-
justifyContent: 'center',
91
-
},
92
110
loadLatestInline: {
93
-
// @ts-expect-error web only
94
-
left: 'calc(50vw - 282px)',
111
+
left: web('calc(50vw - 282px)'),
95
112
},
96
113
loadLatestInlineOffset: {
97
-
// @ts-expect-error web only
98
-
left: 'calc(50vw - 432px)',
114
+
left: web(`calc(50vw - 282px + ${CENTER_COLUMN_OFFSET}px)`),
99
115
},
100
116
loadLatestOutOfLine: {
101
-
// @ts-expect-error web only
102
-
left: 'calc(50vw - 382px)',
103
-
},
104
-
indicator: {
105
-
position: 'absolute',
106
-
top: 3,
107
-
right: 3,
108
-
backgroundColor: colors.blue3,
109
-
width: 12,
110
-
height: 12,
111
-
borderRadius: 6,
112
-
borderWidth: 1,
117
+
left: web('calc(50vw - 382px)'),
113
118
},
114
119
})