fix: allow final entries to scroll past

This commit is contained in:
DIYgod 2026-05-07 21:05:59 +08:00
parent 43186b7ffb
commit 2fa23362c3
11 changed files with 171 additions and 16 deletions

View File

@ -10,6 +10,7 @@ import { Masonry } from "@follow/components/ui/masonry/index.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { useRefValue, useScrollMarkReadGracePeriod } from "@follow/hooks"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { getEntry } from "@follow/store/entry/getter"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { clsx } from "@follow/utils/utils"
@ -38,6 +39,7 @@ import { imageActions } from "~/store/image"
import { useEntriesState } from "../context/EntriesContext"
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
import { useScrollMarkReadEndPadding } from "../hooks/useScrollMarkReadEndPadding"
import { PictureWaterFallItem } from "./picture-item"
// 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,6 +137,12 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
})
const currentRange = useRef<{ start: number; end: number }>(undefined)
const scrollElement = useScrollViewElement()
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: data.length,
hasNextPage: props.hasNextPage,
})
const endSpacerHeight = useScrollMarkReadEndPadding(scrollElement, hasEndSpacer)
const handleRender = useCallback(
(startIndex: number, stopIndex: number, items: any[]) => {
currentRange.current = { start: startIndex, end: stopIndex }
@ -142,7 +150,6 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
},
[maybeLoadMore],
)
const scrollElement = useScrollViewElement()
const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>(null!)
const renderMarkRead = useGeneralSettingKey("renderMarkUnread")
@ -265,6 +272,13 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
<div className="mb-4">{props.Footer}</div>
)
) : null}
{hasEndSpacer && (
<div
aria-hidden
className="pointer-events-none"
style={{ height: `${endSpacerHeight}px` }}
/>
)}
</FirstScreenReadyContext>
</MediaContainerWidthProvider>
</MasonryForceRerenderContext>

View File

@ -2,6 +2,7 @@ import { useMobile } from "@follow/components/hooks/useMobile.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { FeedViewType } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { LRUCache } from "@follow/utils/lru-cache"
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
import { useVirtualizer } from "@tanstack/react-virtual"
@ -20,6 +21,7 @@ import { useUISettingKey } from "~/atoms/settings/ui"
import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContainerWidthProvider"
import { EntryItemSkeleton } from "./EntryItemSkeleton"
import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding"
import { EntryItem } from "./item"
import { PictureMasonry } from "./Items/picture-masonry"
import type { EntryListProps } from "./list"
@ -117,6 +119,11 @@ const VirtualGridImpl: FC<
const pictureViewImageOnly = useUISettingKey("pictureViewImageOnly")
const isImageOnly = view === FeedViewType.Pictures && pictureViewImageOnly
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entriesIds.length,
hasNextPage,
})
const endSpacerHeight = useScrollMarkReadEndPadding(scrollRef, hasEndSpacer)
// Calculate rows based on entries
const rows = useMemo(() => {
@ -129,6 +136,9 @@ const VirtualGridImpl: FC<
const rowCacheKey = `${feedId}-row`
const columnCacheKey = `${feedId}-column`
const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)
const rowCount = footerRowIndex + (Footer ? 1 : 0)
const estimatedRowHeight = columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
const columnVirtualizer = useVirtualizer({
horizontal: true,
@ -150,10 +160,8 @@ const VirtualGridImpl: FC<
})
const rowVirtualizer = useVirtualizer({
count: rows.length + (hasNextPage ? 1 : 0) + (Footer ? 1 : 0),
estimateSize: () => {
return columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
},
count: rowCount,
estimateSize: () => estimatedRowHeight,
overscan: 5,
gap: 8,
getScrollElement: () => scrollRef,
@ -223,12 +231,11 @@ const VirtualGridImpl: FC<
<div
className="relative mx-4"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${rowVirtualizer.getTotalSize() + endSpacerHeight}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)
const isFooterRow = Footer && virtualRow.key === footerRowIndex
const isFooterRow = Footer && virtualRow.index === footerRowIndex
if (isFooterRow && ready) {
return (

View File

@ -0,0 +1,30 @@
import { getScrollMarkReadEndPadding } from "@follow/shared/scroll-mark-read"
import { useEffect, useState } from "react"
export const useScrollMarkReadEndPadding = (
scrollElement: HTMLElement | null,
enabled: boolean,
) => {
const [padding, setPadding] = useState(() => getScrollMarkReadEndPadding(null))
useEffect(() => {
if (!enabled || !scrollElement) {
return
}
const updatePadding = () => {
setPadding(getScrollMarkReadEndPadding(scrollElement.clientHeight))
}
updatePadding()
const observer = new ResizeObserver(updatePadding)
observer.observe(scrollElement)
return () => {
observer.disconnect()
}
}, [enabled, scrollElement])
return enabled ? padding : 0
}

View File

@ -2,6 +2,7 @@ import { EmptyIcon } from "@follow/components/icons/empty.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import type { FeedViewType } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { LRUCache } from "@follow/utils/lru-cache"
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
@ -18,6 +19,7 @@ import { useFeedHeaderTitle } from "~/store/feed/hooks"
import { VirtualRowItem } from "./components/VirtualRowItem"
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
import { EntryItemSkeleton } from "./EntryItemSkeleton"
import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding"
export const EntryEmptyList = ({
ref,
@ -91,6 +93,11 @@ export const EntryList: FC<EntryListProps> = memo(
syncType,
}) => {
const scrollRef = useScrollViewElement()
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entriesIds.length,
hasNextPage,
})
const endSpacerHeight = useScrollMarkReadEndPadding(scrollRef, hasEndSpacer)
const stickyIndexes = useMemo(
() =>
@ -198,7 +205,7 @@ export const EntryList: FC<EntryListProps> = memo(
onKeyDown={handleKeyDown}
className={"relative w-full select-none"}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${rowVirtualizer.getTotalSize() + endSpacerHeight}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {

View File

@ -1,5 +1,6 @@
import type { FeedViewType } from "@follow/constants"
import { UserRole } from "@follow/constants"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import type { FlashListRef, ListRenderItemInfo } from "@shopify/flash-list"
@ -14,6 +15,7 @@ import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { useEntries } from "../screen/atoms"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
import { EntryListFooter } from "./EntryListFooter"
import { useOnViewableItemsChanged } from "./hooks"
import { EntryNormalItem } from "./templates/EntryNormalItem"
@ -66,9 +68,21 @@ export const EntryListContentArticle = ({
[readableItemStyle, view],
)
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entryIds?.length ?? 0,
hasNextPage,
})
const ListFooterComponent = useMemo(
() => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter fetchedTime={fetchedTime} />),
[hasNextPage, fetchedTime],
() =>
hasNextPage ? (
<EntryItemSkeleton />
) : (
<View>
<EntryListFooter fetchedTime={fetchedTime} />
{hasEndSpacer && <EntryListEndScrollSpacer />}
</View>
),
[hasEndSpacer, hasNextPage, fetchedTime],
)
const ref = useRef<FlashListRef<any>>(null)

View File

@ -1,6 +1,7 @@
import type { FeedViewType } from "@follow/constants"
import { UserRole } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import type { FlashListProps, FlashListRef } from "@shopify/flash-list"
@ -16,6 +17,7 @@ import { useEntries } from "@/src/modules/screen/atoms"
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
import { GridEntryListFooter } from "./EntryListFooter"
import { useOnViewableItemsChanged } from "./hooks"
// import type { MasonryItem } from "./templates/EntryGridItem"
@ -74,6 +76,10 @@ export const EntryListContentPicture = ({
const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {
return <EntryPictureItem id={item} />
}, [])
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entryIds?.length ?? 0,
hasNextPage,
})
const headerHeight = useHeaderHeight()
const tabBarHeight = useBottomTabBarHeight()
@ -122,7 +128,10 @@ export const EntryListContentPicture = ({
<PlatformActivityIndicator />
</View>
) : (
<GridEntryListFooter />
<View>
<GridEntryListFooter />
{hasEndSpacer && <EntryListEndScrollSpacer />}
</View>
)
}
{...rest}

View File

@ -1,5 +1,6 @@
import type { FeedViewType } from "@follow/constants"
import { UserRole } from "@follow/constants"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import type { FlashListRef, ListRenderItemInfo } from "@shopify/flash-list"
@ -11,6 +12,7 @@ import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/ge
import { useEntries } from "../screen/atoms"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
import { EntryListFooter } from "./EntryListFooter"
import { useOnViewableItemsChanged } from "./hooks"
import { ItemSeparatorFullWidth } from "./ItemSeparator"
@ -49,9 +51,21 @@ export const EntryListContentSocial = ({
[],
)
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entryIds?.length ?? 0,
hasNextPage,
})
const ListFooterComponent = useMemo(
() => (hasNextPage ? <EntryItemSkeleton /> : <EntryListFooter />),
[hasNextPage],
() =>
hasNextPage ? (
<EntryItemSkeleton />
) : (
<View>
<EntryListFooter />
{hasEndSpacer && <EntryListEndScrollSpacer />}
</View>
),
[hasEndSpacer, hasNextPage],
)
const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({

View File

@ -1,6 +1,7 @@
import type { FeedViewType } from "@follow/constants"
import { UserRole } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { shouldRenderScrollMarkReadEndSpacer } from "@follow/shared/scroll-mark-read"
import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import type { FlashListProps, FlashListRef } from "@shopify/flash-list"
@ -15,6 +16,7 @@ import { useEntries } from "@/src/modules/screen/atoms"
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer"
import { GridEntryListFooter } from "./EntryListFooter"
import { useOnViewableItemsChanged } from "./hooks"
import { EntryVideoItem } from "./templates/EntryVideoItem"
@ -64,6 +66,10 @@ export const EntryListContentVideo = ({
mode: translationMode,
})
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
entryCount: entryIds?.length ?? 0,
hasNextPage,
})
const ListFooterComponent = useMemo(
() =>
hasNextPage ? (
@ -72,9 +78,12 @@ export const EntryListContentVideo = ({
<EntryItemSkeleton />
</View>
) : (
<GridEntryListFooter />
<View>
<GridEntryListFooter />
{hasEndSpacer && <EntryListEndScrollSpacer />}
</View>
),
[hasNextPage],
[hasEndSpacer, hasNextPage],
)
const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => {

View File

@ -0,0 +1,10 @@
import { getScrollMarkReadEndPadding } from "@follow/shared/scroll-mark-read"
import { useMemo } from "react"
import { useWindowDimensions, View } from "react-native"
export const EntryListEndScrollSpacer = () => {
const { height } = useWindowDimensions()
const style = useMemo(() => ({ height: getScrollMarkReadEndPadding(height) }), [height])
return <View pointerEvents="none" style={style} />
}

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import {
getScrollMarkReadEndPadding,
MIN_SCROLL_MARK_READ_END_PADDING,
shouldRenderScrollMarkReadEndSpacer,
} from "./scroll-mark-read"
describe("scroll mark-read trailing space", () => {
it("uses at least one viewport of trailing space on the final page", () => {
expect(getScrollMarkReadEndPadding(720)).toBe(720)
})
it("falls back to a stable minimum before the viewport is measured", () => {
expect(getScrollMarkReadEndPadding(null)).toBe(MIN_SCROLL_MARK_READ_END_PADDING)
expect(getScrollMarkReadEndPadding(240)).toBe(MIN_SCROLL_MARK_READ_END_PADDING)
})
it("only enables the trailing spacer for non-empty final pages", () => {
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 3, hasNextPage: false })).toBe(true)
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 3, hasNextPage: true })).toBe(false)
expect(shouldRenderScrollMarkReadEndSpacer({ entryCount: 0, hasNextPage: false })).toBe(false)
})
})

View File

@ -0,0 +1,17 @@
export const MIN_SCROLL_MARK_READ_END_PADDING = 480
export const getScrollMarkReadEndPadding = (viewportHeight: number | null | undefined) => {
if (typeof viewportHeight !== "number" || !Number.isFinite(viewportHeight)) {
return MIN_SCROLL_MARK_READ_END_PADDING
}
return Math.max(viewportHeight, MIN_SCROLL_MARK_READ_END_PADDING)
}
export const shouldRenderScrollMarkReadEndSpacer = ({
entryCount,
hasNextPage,
}: {
entryCount: number
hasNextPage: boolean
}) => entryCount > 0 && !hasNextPage