fix: allow final entries to scroll past
This commit is contained in:
parent
43186b7ffb
commit
2fa23362c3
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue