From 2fa23362c3ef17aed03b02a7f9d10d39af75e860 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 7 May 2026 21:05:59 +0800 Subject: [PATCH] fix: allow final entries to scroll past --- .../entry-column/Items/picture-masonry.tsx | 16 +++++++++- .../src/modules/entry-column/grid.tsx | 21 ++++++++----- .../hooks/useScrollMarkReadEndPadding.ts | 30 +++++++++++++++++++ .../src/modules/entry-column/list.tsx | 9 +++++- .../entry-list/EntryListContentArticle.tsx | 18 +++++++++-- .../entry-list/EntryListContentPicture.tsx | 11 ++++++- .../entry-list/EntryListContentSocial.tsx | 18 +++++++++-- .../entry-list/EntryListContentVideo.tsx | 13 ++++++-- .../entry-list/EntryListEndScrollSpacer.tsx | 10 +++++++ .../shared/src/scroll-mark-read.test.ts | 24 +++++++++++++++ .../internal/shared/src/scroll-mark-read.ts | 17 +++++++++++ 11 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadEndPadding.ts create mode 100644 apps/mobile/src/modules/entry-list/EntryListEndScrollSpacer.tsx create mode 100644 packages/internal/shared/src/scroll-mark-read.test.ts create mode 100644 packages/internal/shared/src/scroll-mark-read.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx index 5b25253b7..01ad31e2f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -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 = (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 = (props) => { }, [maybeLoadMore], ) - const scrollElement = useScrollViewElement() const [intersectionObserver, setIntersectionObserver] = useState(null!) const renderMarkRead = useGeneralSettingKey("renderMarkUnread") @@ -265,6 +272,13 @@ export const PictureMasonry: FC = (props) => {
{props.Footer}
) ) : null} + {hasEndSpacer && ( +
+ )} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx index 3a9c3c014..053b7ec84 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx @@ -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<
{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 ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadEndPadding.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadEndPadding.ts new file mode 100644 index 000000000..5bec6f933 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadEndPadding.ts @@ -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 +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx index 12c28bd45..a6748179a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx @@ -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 = 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 = memo( onKeyDown={handleKeyDown} className={"relative w-full select-none"} style={{ - height: `${rowVirtualizer.getTotalSize()}px`, + height: `${rowVirtualizer.getTotalSize() + endSpacerHeight}px`, }} > {rowVirtualizer.getVirtualItems().map((virtualRow) => { diff --git a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx index 62df35e71..fef47f284 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx @@ -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 ? : ), - [hasNextPage, fetchedTime], + () => + hasNextPage ? ( + + ) : ( + + + {hasEndSpacer && } + + ), + [hasEndSpacer, hasNextPage, fetchedTime], ) const ref = useRef>(null) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx index 0550c5f12..85adc9efa 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx @@ -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 }, []) + const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({ + entryCount: entryIds?.length ?? 0, + hasNextPage, + }) const headerHeight = useHeaderHeight() const tabBarHeight = useBottomTabBarHeight() @@ -122,7 +128,10 @@ export const EntryListContentPicture = ({ ) : ( - + + + {hasEndSpacer && } + ) } {...rest} diff --git a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx index 75d8dbd10..62d0e17c8 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx @@ -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 ? : ), - [hasNextPage], + () => + hasNextPage ? ( + + ) : ( + + + {hasEndSpacer && } + + ), + [hasEndSpacer, hasNextPage], ) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ diff --git a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx index 76b7a06ca..b6ddc7fa2 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx @@ -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 = ({ ) : ( - + + + {hasEndSpacer && } + ), - [hasNextPage], + [hasEndSpacer, hasNextPage], ) const renderItem = useTypeScriptHappyCallback(({ item }: { item: string }) => { diff --git a/apps/mobile/src/modules/entry-list/EntryListEndScrollSpacer.tsx b/apps/mobile/src/modules/entry-list/EntryListEndScrollSpacer.tsx new file mode 100644 index 000000000..08c71d557 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/EntryListEndScrollSpacer.tsx @@ -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 +} diff --git a/packages/internal/shared/src/scroll-mark-read.test.ts b/packages/internal/shared/src/scroll-mark-read.test.ts new file mode 100644 index 000000000..c86fe2d68 --- /dev/null +++ b/packages/internal/shared/src/scroll-mark-read.test.ts @@ -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) + }) +}) diff --git a/packages/internal/shared/src/scroll-mark-read.ts b/packages/internal/shared/src/scroll-mark-read.ts new file mode 100644 index 000000000..aa63f6860 --- /dev/null +++ b/packages/internal/shared/src/scroll-mark-read.ts @@ -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