Skip to content

Commit 2fa2336

Browse files
committed
fix: allow final entries to scroll past
1 parent 43186b7 commit 2fa2336

11 files changed

Lines changed: 171 additions & 16 deletions

File tree

apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Masonry } from "@follow/components/ui/masonry/index.js"
1010
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
1111
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
1212
import { useRefValue, useScrollMarkReadGracePeriod } from "@follow/hooks"
13+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
1314
import { getEntry } from "@follow/store/entry/getter"
1415
import { useEntryTranslation } from "@follow/store/translation/hooks"
1516
import { clsx } from "@follow/utils/utils"
@@ -38,6 +39,7 @@ import { imageActions } from "~/store/image"
3839

3940
import { useEntriesState } from "../context/EntriesContext"
4041
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
42+
import { useScrollMarkReadEndPadding } from "../hooks/useScrollMarkReadEndPadding"
4143
import { PictureWaterFallItem } from "./picture-item"
4244

4345
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
@@ -135,14 +137,19 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
135137
})
136138

137139
const currentRange = useRef<{ start: number; end: number }>(undefined)
140+
const scrollElement = useScrollViewElement()
141+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
142+
entryCount: data.length,
143+
hasNextPage: props.hasNextPage,
144+
})
145+
const endSpacerHeight = useScrollMarkReadEndPadding(scrollElement, hasEndSpacer)
138146
const handleRender = useCallback(
139147
(startIndex: number, stopIndex: number, items: any[]) => {
140148
currentRange.current = { start: startIndex, end: stopIndex }
141149
return maybeLoadMore(startIndex, stopIndex, items)
142150
},
143151
[maybeLoadMore],
144152
)
145-
const scrollElement = useScrollViewElement()
146153

147154
const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>(null!)
148155
const renderMarkRead = useGeneralSettingKey("renderMarkUnread")
@@ -265,6 +272,13 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
265272
<div className="mb-4">{props.Footer}</div>
266273
)
267274
) : null}
275+
{hasEndSpacer && (
276+
<div
277+
aria-hidden
278+
className="pointer-events-none"
279+
style={{ height: `${endSpacerHeight}px` }}
280+
/>
281+
)}
268282
</FirstScreenReadyContext>
269283
</MediaContainerWidthProvider>
270284
</MasonryForceRerenderContext>

apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMobile } from "@follow/components/hooks/useMobile.js"
22
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
33
import { FeedViewType } from "@follow/constants"
44
import { useTypeScriptHappyCallback } from "@follow/hooks"
5+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
56
import { LRUCache } from "@follow/utils/lru-cache"
67
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
78
import { useVirtualizer } from "@tanstack/react-virtual"
@@ -20,6 +21,7 @@ import { useUISettingKey } from "~/atoms/settings/ui"
2021
import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContainerWidthProvider"
2122

2223
import { EntryItemSkeleton } from "./EntryItemSkeleton"
24+
import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding"
2325
import { EntryItem } from "./item"
2426
import { PictureMasonry } from "./Items/picture-masonry"
2527
import type { EntryListProps } from "./list"
@@ -117,6 +119,11 @@ const VirtualGridImpl: FC<
117119

118120
const pictureViewImageOnly = useUISettingKey("pictureViewImageOnly")
119121
const isImageOnly = view === FeedViewType.Pictures && pictureViewImageOnly
122+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
123+
entryCount: entriesIds.length,
124+
hasNextPage,
125+
})
126+
const endSpacerHeight = useScrollMarkReadEndPadding(scrollRef, hasEndSpacer)
120127

121128
// Calculate rows based on entries
122129
const rows = useMemo(() => {
@@ -129,6 +136,9 @@ const VirtualGridImpl: FC<
129136

130137
const rowCacheKey = `${feedId}-row`
131138
const columnCacheKey = `${feedId}-column`
139+
const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)
140+
const rowCount = footerRowIndex + (Footer ? 1 : 0)
141+
const estimatedRowHeight = columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
132142

133143
const columnVirtualizer = useVirtualizer({
134144
horizontal: true,
@@ -150,10 +160,8 @@ const VirtualGridImpl: FC<
150160
})
151161

152162
const rowVirtualizer = useVirtualizer({
153-
count: rows.length + (hasNextPage ? 1 : 0) + (Footer ? 1 : 0),
154-
estimateSize: () => {
155-
return columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
156-
},
163+
count: rowCount,
164+
estimateSize: () => estimatedRowHeight,
157165
overscan: 5,
158166
gap: 8,
159167
getScrollElement: () => scrollRef,
@@ -223,12 +231,11 @@ const VirtualGridImpl: FC<
223231
<div
224232
className="relative mx-4"
225233
style={{
226-
height: `${rowVirtualizer.getTotalSize()}px`,
234+
height: `${rowVirtualizer.getTotalSize() + endSpacerHeight}px`,
227235
}}
228236
>
229237
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
230-
const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)
231-
const isFooterRow = Footer && virtualRow.key === footerRowIndex
238+
const isFooterRow = Footer && virtualRow.index === footerRowIndex
232239

233240
if (isFooterRow && ready) {
234241
return (
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getScrollMarkReadEndPadding } from "@follow/shared/scroll-mark-read"
2+
import { useEffect, useState } from "react"
3+
4+
export const useScrollMarkReadEndPadding = (
5+
scrollElement: HTMLElement | null,
6+
enabled: boolean,
7+
) => {
8+
const [padding, setPadding] = useState(() => getScrollMarkReadEndPadding(null))
9+
10+
useEffect(() => {
11+
if (!enabled || !scrollElement) {
12+
return
13+
}
14+
15+
const updatePadding = () => {
16+
setPadding(getScrollMarkReadEndPadding(scrollElement.clientHeight))
17+
}
18+
19+
updatePadding()
20+
21+
const observer = new ResizeObserver(updatePadding)
22+
observer.observe(scrollElement)
23+
24+
return () => {
25+
observer.disconnect()
26+
}
27+
}, [enabled, scrollElement])
28+
29+
return enabled ? padding : 0
30+
}

apps/desktop/layer/renderer/src/modules/entry-column/list.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { EmptyIcon } from "@follow/components/icons/empty.jsx"
22
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
33
import type { FeedViewType } from "@follow/constants"
44
import { useTypeScriptHappyCallback } from "@follow/hooks"
5+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
56
import { LRUCache } from "@follow/utils/lru-cache"
67
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
78
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
@@ -18,6 +19,7 @@ import { useFeedHeaderTitle } from "~/store/feed/hooks"
1819
import { VirtualRowItem } from "./components/VirtualRowItem"
1920
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
2021
import { EntryItemSkeleton } from "./EntryItemSkeleton"
22+
import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding"
2123

2224
export const EntryEmptyList = ({
2325
ref,
@@ -91,6 +93,11 @@ export const EntryList: FC<EntryListProps> = memo(
9193
syncType,
9294
}) => {
9395
const scrollRef = useScrollViewElement()
96+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
97+
entryCount: entriesIds.length,
98+
hasNextPage,
99+
})
100+
const endSpacerHeight = useScrollMarkReadEndPadding(scrollRef, hasEndSpacer)
94101

95102
const stickyIndexes = useMemo(
96103
() =>
@@ -198,7 +205,7 @@ export const EntryList: FC<EntryListProps> = memo(
198205
onKeyDown={handleKeyDown}
199206
className={"relative w-full select-none"}
200207
style={{
201-
height: `${rowVirtualizer.getTotalSize()}px`,
208+
height: `${rowVirtualizer.getTotalSize() + endSpacerHeight}px`,
202209
}}
203210
>
204211
{rowVirtualizer.getVirtualItems().map((virtualRow) => {

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FeedViewType } from "@follow/constants"
22
import { UserRole } from "@follow/constants"
3+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
34
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
45
import { useUserRole } from "@follow/store/user/hooks"
56
import type { FlashListRef, ListRenderItemInfo } from "@shopify/flash-list"
@@ -14,6 +15,7 @@ import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
1415

1516
import { useEntries } from "../screen/atoms"
1617
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
18+
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
1719
import { EntryListFooter } from "./EntryListFooter"
1820
import { useOnViewableItemsChanged } from "./hooks"
1921
import { EntryNormalItem } from "./templates/EntryNormalItem"
@@ -66,9 +68,21 @@ export const EntryListContentArticle = ({
6668
[readableItemStyle, view],
6769
)
6870

71+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
72+
entryCount: entryIds?.length ?? 0,
73+
hasNextPage,
74+
})
6975
const ListFooterComponent = useMemo(
70-
() => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter fetchedTime={fetchedTime} />),
71-
[hasNextPage, fetchedTime],
76+
() =>
77+
hasNextPage ? (
78+
<EntryItemSkeleton />
79+
) : (
80+
<View>
81+
<EntryListFooter fetchedTime={fetchedTime} />
82+
{hasEndSpacer && <EntryListEndScrollSpacer />}
83+
</View>
84+
),
85+
[hasEndSpacer, hasNextPage, fetchedTime],
7286
)
7387

7488
const ref = useRef<FlashListRef<any>>(null)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FeedViewType } from "@follow/constants"
22
import { UserRole } from "@follow/constants"
33
import { useTypeScriptHappyCallback } from "@follow/hooks"
4+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
45
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
56
import { useUserRole } from "@follow/store/user/hooks"
67
import type { FlashListProps, FlashListRef } from "@shopify/flash-list"
@@ -16,6 +17,7 @@ import { useEntries } from "@/src/modules/screen/atoms"
1617
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
1718

1819
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
20+
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
1921
import { GridEntryListFooter } from "./EntryListFooter"
2022
import { useOnViewableItemsChanged } from "./hooks"
2123
// import type { MasonryItem } from "./templates/EntryGridItem"
@@ -74,6 +76,10 @@ export const EntryListContentPicture = ({
7476
const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {
7577
return <EntryPictureItem id={item} />
7678
}, [])
79+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
80+
entryCount: entryIds?.length ?? 0,
81+
hasNextPage,
82+
})
7783

7884
const headerHeight = useHeaderHeight()
7985
const tabBarHeight = useBottomTabBarHeight()
@@ -122,7 +128,10 @@ export const EntryListContentPicture = ({
122128
<PlatformActivityIndicator />
123129
</View>
124130
) : (
125-
<GridEntryListFooter />
131+
<View>
132+
<GridEntryListFooter />
133+
{hasEndSpacer && <EntryListEndScrollSpacer />}
134+
</View>
126135
)
127136
}
128137
{...rest}

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FeedViewType } from "@follow/constants"
22
import { UserRole } from "@follow/constants"
3+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
34
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
45
import { useUserRole } from "@follow/store/user/hooks"
56
import type { FlashListRef, ListRenderItemInfo } from "@shopify/flash-list"
@@ -11,6 +12,7 @@ import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/ge
1112

1213
import { useEntries } from "../screen/atoms"
1314
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
15+
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
1416
import { EntryListFooter } from "./EntryListFooter"
1517
import { useOnViewableItemsChanged } from "./hooks"
1618
import { ItemSeparatorFullWidth } from "./ItemSeparator"
@@ -49,9 +51,21 @@ export const EntryListContentSocial = ({
4951
[],
5052
)
5153

54+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
55+
entryCount: entryIds?.length ?? 0,
56+
hasNextPage,
57+
})
5258
const ListFooterComponent = useMemo(
53-
() => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter />),
54-
[hasNextPage],
59+
() =>
60+
hasNextPage ? (
61+
<EntryItemSkeleton />
62+
) : (
63+
<View>
64+
<EntryListFooter />
65+
{hasEndSpacer && <EntryListEndScrollSpacer />}
66+
</View>
67+
),
68+
[hasEndSpacer, hasNextPage],
5569
)
5670

5771
const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FeedViewType } from "@follow/constants"
22
import { UserRole } from "@follow/constants"
33
import { useTypeScriptHappyCallback } from "@follow/hooks"
4+
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
45
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
56
import { useUserRole } from "@follow/store/user/hooks"
67
import type { FlashListProps, FlashListRef } from "@shopify/flash-list"
@@ -15,6 +16,7 @@ import { useEntries } from "@/src/modules/screen/atoms"
1516
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
1617

1718
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
19+
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
1820
import { GridEntryListFooter } from "./EntryListFooter"
1921
import { useOnViewableItemsChanged } from "./hooks"
2022
import { EntryVideoItem } from "./templates/EntryVideoItem"
@@ -64,6 +66,10 @@ export const EntryListContentVideo = ({
6466
mode: translationMode,
6567
})
6668

69+
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
70+
entryCount: entryIds?.length ?? 0,
71+
hasNextPage,
72+
})
6773
const ListFooterComponent = useMemo(
6874
() =>
6975
hasNextPage ? (
@@ -72,9 +78,12 @@ export const EntryListContentVideo = ({
7278
<EntryItemSkeleton />
7379
</View>
7480
) : (
75-
<GridEntryListFooter />
81+
<View>
82+
<GridEntryListFooter />
83+
{hasEndSpacer && <EntryListEndScrollSpacer />}
84+
</View>
7685
),
77-
[hasNextPage],
86+
[hasEndSpacer, hasNextPage],
7887
)
7988

8089
const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { getScrollMarkReadEndPadding } from "@follow/shared/scroll-mark-read"
2+
import { useMemo } from "react"
3+
import { useWindowDimensions, View } from "react-native"
4+
5+
export const EntryListEndScrollSpacer = () => {
6+
const { height } = useWindowDimensions()
7+
const style = useMemo(() => ({ height: getScrollMarkReadEndPadding(height) }), [height])
8+
9+
return <View pointerEvents="none" style={style} />
10+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import {
4+
getScrollMarkReadEndPadding,
5+
MIN_SCROLL_MARK_READ_END_PADDING,
6+
shouldRenderScrollMarkReadEndSpacer,
7+
} from "./scroll-mark-read"
8+
9+
describe("scroll mark-read trailing space", () => {
10+
it("uses at least one viewport of trailing space on the final page", () => {
11+
expect(getScrollMarkReadEndPadding(720)).toBe(720)
12+
})
13+
14+
it("falls back to a stable minimum before the viewport is measured", () => {
15+
expect(getScrollMarkReadEndPadding(null)).toBe(MIN_SCROLL_MARK_READ_END_PADDING)
16+
expect(getScrollMarkReadEndPadding(240)).toBe(MIN_SCROLL_MARK_READ_END_PADDING)
17+
})
18+
19+
it("only enables the trailing spacer for non-empty final pages", () => {
20+
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 3, hasNextPage: false })).toBe(true)
21+
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 3, hasNextPage: true })).toBe(false)
22+
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 0, hasNextPage: false })).toBe(false)
23+
})
24+
})

0 commit comments

Comments
 (0)