Skip to content

Commit 7f984ec

Browse files
authored
feat: implement pull up to next functionality (#3760)
* feat: implement pull-up-to-next functionality in EntryDetailScreen * feat: add onScroll callback support in SafeNavigationScrollView * refactor: utilize EntryExtraData * feat: refactor Entry components to utilize EntryExtraData type * refactor: replace useSharedValue with useRef and useState for pull-up functionality * feat: enhance pull-up-to-next functionality with enabled prop * fix: types
1 parent fc1e7ea commit 7f984ec

File tree

9 files changed

+304
-156
lines changed

9 files changed

+304
-156
lines changed

apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ export const SafeNavigationScrollView = ({
9494
if (reanimatedScrollY) {
9595
reanimatedScrollY.value = event.contentOffset.y
9696
}
97-
97+
if (onScroll) {
98+
runOnJS(onScroll)(event)
99+
}
98100
runOnJS(checkScrollToBottom)()
99101
screenCtxValue.reAnimatedScrollY.value = event.contentOffset.y
100102
},

apps/mobile/src/modules/context-menu/feeds.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ const PreviewFeeds = (props: { id: string; view: FeedViewType }) => {
343343

344344
const renderItem = useCallback(
345345
({ item: id }: ListRenderItemInfo<string>) => (
346-
<EntryNormalItem entryId={id} extraData="" view={props.view} />
346+
<EntryNormalItem
347+
entryId={id}
348+
extraData={{ entryIds: null, playingAudioUrl: null }}
349+
view={props.view}
350+
/>
347351
),
348352
[props.view],
349353
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as Haptics from "expo-haptics"
2+
import { useCallback, useRef, useState } from "react"
3+
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
4+
import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes"
5+
6+
export const usePullUpToNext = ({
7+
enabled: enabled = true,
8+
onRefresh,
9+
progressViewOffset = 70,
10+
}: {
11+
enabled?: boolean
12+
onRefresh?: (() => void) | undefined
13+
progressViewOffset?: number
14+
} = {}) => {
15+
const dragging = useRef(false)
16+
const isOverThreshold = useRef(false)
17+
const [refreshing, setRefreshing] = useState(false)
18+
19+
const onScroll = useCallback(
20+
(e: ReanimatedScrollEvent) => {
21+
if (!dragging.current) return
22+
const overOffset = e.contentOffset.y - e.contentSize.height + e.layoutMeasurement.height
23+
24+
if (overOffset > progressViewOffset) {
25+
if (!isOverThreshold.current && onRefresh) {
26+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
27+
}
28+
isOverThreshold.current = true
29+
setRefreshing(true)
30+
} else {
31+
if (isOverThreshold.current && onRefresh) {
32+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Soft)
33+
}
34+
isOverThreshold.current = false
35+
setRefreshing(false)
36+
}
37+
return
38+
},
39+
[dragging, onRefresh, progressViewOffset],
40+
)
41+
42+
const onScrollBeginDrag = useCallback(
43+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
44+
const beginOffset =
45+
e.nativeEvent.contentOffset.y -
46+
e.nativeEvent.contentSize.height +
47+
e.nativeEvent.layoutMeasurement.height
48+
if (beginOffset < -50) {
49+
// Maybe user is pulling down fast for overview
50+
return
51+
}
52+
dragging.current = true
53+
},
54+
[dragging],
55+
)
56+
57+
const onScrollEndDrag = useCallback(
58+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
59+
dragging.current = false
60+
const velocity = event.nativeEvent.velocity?.y || 0
61+
if (isOverThreshold.current && velocity < 3) {
62+
onRefresh?.()
63+
}
64+
isOverThreshold.current = false
65+
setRefreshing(false)
66+
},
67+
[dragging, onRefresh],
68+
)
69+
70+
if (!enabled) {
71+
return {
72+
scrollViewEventHandlers: {},
73+
pullUpViewProps: {},
74+
EntryPullUpToNext: () => null,
75+
}
76+
}
77+
78+
return {
79+
scrollViewEventHandlers: {
80+
onScroll,
81+
onScrollBeginDrag,
82+
onScrollEndDrag,
83+
},
84+
pullUpViewProps: {
85+
refreshing,
86+
},
87+
}
88+
}

apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { EntryListFooter } from "./EntryListFooter"
1313
import { useOnViewableItemsChanged, usePagerListPerformanceHack } from "./hooks"
1414
import { ItemSeparator } from "./ItemSeparator"
1515
import { EntryNormalItem } from "./templates/EntryNormalItem"
16+
import type { EntryExtraData } from "./types"
1617

1718
export const EntryListContentArticle = ({
1819
ref: forwardRef,
@@ -23,13 +24,17 @@ export const EntryListContentArticle = ({
2324
ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | null>
2425
}) => {
2526
const playingAudioUrl = usePlayingUrl()
27+
const extraData: EntryExtraData = useMemo(
28+
() => ({ playingAudioUrl, entryIds }),
29+
[playingAudioUrl, entryIds],
30+
)
2631

2732
const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage } =
2833
useFetchEntriesControls()
2934

3035
const renderItem = useCallback(
3136
({ item: id, extraData }: ListRenderItemInfo<string>) => (
32-
<EntryNormalItem entryId={id} extraData={extraData} view={view} />
37+
<EntryNormalItem entryId={id} extraData={extraData as EntryExtraData} view={view} />
3338
),
3439
[view],
3540
)
@@ -56,7 +61,7 @@ export const EntryListContentArticle = ({
5661
onRefresh={refetch}
5762
isRefetching={isRefetching}
5863
data={entryIds}
59-
extraData={playingAudioUrl}
64+
extraData={extraData}
6065
keyExtractor={defaultKeyExtractor}
6166
estimatedItemSize={100}
6267
renderItem={renderItem}

apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EntryListFooter } from "./EntryListFooter"
1111
import { useOnViewableItemsChanged, usePagerListPerformanceHack } from "./hooks"
1212
import { ItemSeparatorFullWidth } from "./ItemSeparator"
1313
import { EntrySocialItem } from "./templates/EntrySocialItem"
14+
import type { EntryExtraData } from "./types"
1415

1516
export const EntryListContentSocial = ({
1617
ref: forwardRef,
@@ -21,12 +22,15 @@ export const EntryListContentSocial = ({
2122
}) => {
2223
const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage } =
2324
useFetchEntriesControls()
25+
const extraData: EntryExtraData = useMemo(() => ({ playingAudioUrl: null, entryIds }), [entryIds])
2426

2527
const { onScroll: hackOnScroll, ref, style: hackStyle } = usePagerListPerformanceHack()
2628
useImperativeHandle(forwardRef, () => ref.current!)
2729
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback
2830
const renderItem = useCallback(
29-
({ item: id }: ListRenderItemInfo<string>) => <EntrySocialItem entryId={id} />,
31+
({ item: id, extraData }: ListRenderItemInfo<string>) => (
32+
<EntrySocialItem entryId={id} extraData={extraData as EntryExtraData} />
33+
),
3034
[],
3135
)
3236

@@ -50,6 +54,7 @@ export const EntryListContentSocial = ({
5054
}}
5155
isRefetching={isRefetching}
5256
data={entryIds}
57+
extraData={extraData}
5358
keyExtractor={(id) => id}
5459
estimatedItemSize={100}
5560
renderItem={renderItem}

apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@ import { useEntryTranslation } from "@/src/store/translation/hooks"
2727

2828
import { EntryItemContextMenu } from "../../context-menu/entry"
2929
import { EntryItemSkeleton } from "../EntryListContentArticle"
30+
import type { EntryExtraData } from "../types"
3031
import { EntryTranslation } from "./EntryTranslation"
3132

3233
export const EntryNormalItem = memo(
33-
({ entryId, extraData, view }: { entryId: string; extraData: string; view: FeedViewType }) => {
34+
({
35+
entryId,
36+
extraData,
37+
view,
38+
}: {
39+
entryId: string
40+
extraData: EntryExtraData
41+
view: FeedViewType
42+
}) => {
3443
const entry = useEntry(entryId)
3544
const translation = useEntryTranslation(entryId)
3645
const from = getInboxFrom(entry)
@@ -46,10 +55,11 @@ export const EntryNormalItem = memo(
4655

4756
navigation.pushControllerView(EntryDetailScreen, {
4857
entryId,
58+
entryIds: extraData.entryIds ?? [],
4959
view,
5060
})
5161
}
52-
}, [entryId, entry, navigation, view])
62+
}, [entry, navigation, entryId, extraData.entryIds, view])
5363

5464
const audio = entry?.attachments?.find((attachment) =>
5565
attachment.mime_type?.startsWith("audio/"),
@@ -125,7 +135,7 @@ export const EntryNormalItem = memo(
125135
)}
126136
</View>
127137
{view !== FeedViewType.Notifications && (
128-
<ThumbnailImage entry={entry} view={view} extraData={extraData} />
138+
<ThumbnailImage entry={entry} view={view} playingAudioUrl={extraData.playingAudioUrl} />
129139
)}
130140
</ItemPressable>
131141
</EntryItemContextMenu>
@@ -137,11 +147,11 @@ EntryNormalItem.displayName = "EntryNormalItem"
137147

138148
const ThumbnailImage = ({
139149
view,
140-
extraData,
150+
playingAudioUrl,
141151
entry,
142152
}: {
143153
view: FeedViewType
144-
extraData: string
154+
playingAudioUrl: string | null
145155
entry: EntryModel
146156
}) => {
147157
const feed = useFeed(entry?.feedId as string)
@@ -152,7 +162,7 @@ const ThumbnailImage = ({
152162
const blurhash = coverImage?.blurhash
153163

154164
const audio = entry?.attachments?.find((attachment) => attachment.mime_type?.startsWith("audio/"))
155-
const audioState = getAttachmentState(extraData, audio)
165+
const audioState = getAttachmentState(playingAudioUrl ?? undefined, audio)
156166
const isPlaying = audioState === "playing"
157167
const isLoading = audioState === "loading"
158168

0 commit comments

Comments
 (0)