From e551882bb36478789c3e4c5583b5ee2b50ad013e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 16:09:17 +0800 Subject: [PATCH 01/17] fix(release): extend OTA sync trigger timeout --- .github/scripts/trigger-ota-sync.mjs | 17 ++++++++++++++++- .github/scripts/trigger-ota-sync.test.ts | 8 ++++++++ .github/workflows/publish-ota.yml | 8 +++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/scripts/trigger-ota-sync.mjs b/.github/scripts/trigger-ota-sync.mjs index 70aa6687a..5978fccb5 100644 --- a/.github/scripts/trigger-ota-sync.mjs +++ b/.github/scripts/trigger-ota-sync.mjs @@ -11,7 +11,21 @@ import { pathToFileURL } from "node:url" * }} TriggerOtaSyncOptions */ -const DEFAULT_TIMEOUT_MS = 10_000 +const DEFAULT_TIMEOUT_MS = 120_000 + +export function readOtaSyncTimeoutMs(value) { + if (!value) { + return DEFAULT_TIMEOUT_MS + } + + const timeoutMs = Number(value) + + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + throw new TypeError("OTA sync timeout must be a positive integer") + } + + return timeoutMs +} /** * @param {TriggerOtaSyncOptions} options @@ -81,6 +95,7 @@ async function main() { baseUrl: process.env.OTA_BASE_URL ?? "", token: process.env.OTA_SYNC_TOKEN ?? "", headerName: process.env.OTA_SYNC_TOKEN_HEADER ?? "", + timeoutMs: readOtaSyncTimeoutMs(process.env.OTA_SYNC_TIMEOUT_MS), }) console.info("Triggered OTA sync successfully") diff --git a/.github/scripts/trigger-ota-sync.test.ts b/.github/scripts/trigger-ota-sync.test.ts index 21fedbd7d..86e0f38e2 100644 --- a/.github/scripts/trigger-ota-sync.test.ts +++ b/.github/scripts/trigger-ota-sync.test.ts @@ -25,6 +25,14 @@ afterEach(async () => { }) describe("triggerOtaSync", () => { + it("reads a configurable OTA sync timeout", async () => { + const { readOtaSyncTimeoutMs } = await import("./trigger-ota-sync.mjs") + + expect(readOtaSyncTimeoutMs()).toBe(120_000) + expect(readOtaSyncTimeoutMs("30000")).toBe(30_000) + expect(() => readOtaSyncTimeoutMs("0")).toThrow("OTA sync timeout must be a positive integer") + }) + it("POSTs to /internal/sync with the configured auth header", async () => { const requests: Array<{ method?: string; url?: string; headerValue?: string }> = [] const headerName = "x-ota-sync-token" diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml index 689cf32d4..7bdad2935 100644 --- a/.github/workflows/publish-ota.yml +++ b/.github/workflows/publish-ota.yml @@ -39,6 +39,11 @@ jobs: with: fetch-depth: 0 + - name: Preserve workflow helper scripts + run: | + mkdir -p "$RUNNER_TEMP/folo-release-scripts" + cp .github/scripts/trigger-ota-sync.mjs "$RUNNER_TEMP/folo-release-scripts/trigger-ota-sync.mjs" + - name: Resolve target release tag run: | git fetch --tags --force @@ -96,4 +101,5 @@ jobs: OTA_BASE_URL: ${{ secrets.OTA_BASE_URL }} OTA_SYNC_TOKEN: ${{ secrets.OTA_SYNC_TOKEN }} OTA_SYNC_TOKEN_HEADER: ${{ secrets.OTA_SYNC_TOKEN_HEADER }} - run: node .github/scripts/trigger-ota-sync.mjs + OTA_SYNC_TIMEOUT_MS: 120000 + run: node "$RUNNER_TEMP/folo-release-scripts/trigger-ota-sync.mjs" From 2d2ec2eb7ab8cd640fcae46946c0728968310d83 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 21:48:12 +0800 Subject: [PATCH 02/17] docs(release): clarify mobile OTA runtime selection --- .agents/skills/mobile-release/SKILL.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.agents/skills/mobile-release/SKILL.md b/.agents/skills/mobile-release/SKILL.md index 8c30f1a72..e6ff53ad5 100644 --- a/.agents/skills/mobile-release/SKILL.md +++ b/.agents/skills/mobile-release/SKILL.md @@ -83,7 +83,17 @@ The CI release flow is file-driven: ### Determine the target runtime -If recommending `ota`, derive the target store binary version from recent `origin/mobile-main` releases and propose it as the `runtimeVersion`. +If recommending `ota`, derive the target runtime from the store binaries that users currently have installed, not from the new release version, latest mobile tag, or latest OTA release. + +1. Check the public store versions first: + ```bash + curl --fail --silent --show-error https://ota.folo.is/versions | jq '.store.mobile' + ``` +2. Cross-check the store runtime model in `apps/mobile/app.config.base.ts`. Today the mobile runtime defaults to the binary package version unless `OTA_RUNTIME_VERSION` is explicitly set during an OTA export. +3. Use the current App Store / Google Play binary version as the OTA `runtimeVersion`. Example: if the stores still show `0.5.0`, an OTA release for `0.5.4` must use `"runtimeVersion": "0.5.0"` so existing store users can receive it. +4. If iOS and Android store versions differ, or if the target installed runtime is not clear, stop and ask the user. The release plan supports only one OTA `runtimeVersion`; do not guess or silently pick the newest version. + +Never choose the previous OTA release version just because it is the latest working manifest. A runtime mismatch publishes valid assets that only newer binaries can see, leaving current store users stuck on the older OTA. If you cannot determine the runtime confidently, stop and ask the user to confirm it. @@ -190,6 +200,8 @@ Examples: - trigger OTA publish only - no store builds +Do not require live OTA manifest verification during release PR preparation. The user manually merges the PR later, so the OTA publish happens after this workflow finishes and there may be a time gap before the Worker syncs. If the user later asks to check the rollout, verify the workflow run, GitHub Release assets, and `/manifest` at that time. + ## References - Bump config: `apps/mobile/bump.config.ts` From fe1fcff315b4a39851e24b2697eb1a355585374c Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 1 Jun 2026 10:43:23 +0800 Subject: [PATCH 03/17] fix(timeline): scroll to top before refresh --- .../src/modules/entry-column/index.tsx | 10 +++++- .../entry-column/refresh-reset.test.ts | 35 +++++++++++++++++++ .../src/modules/entry-column/refresh-reset.ts | 7 ++++ .../modules/entry-list/EntryListSelector.tsx | 26 +++++++++++++- .../modules/entry-list/refresh-reset.test.ts | 35 +++++++++++++++++++ .../src/modules/entry-list/refresh-reset.ts | 7 ++++ 6 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.test.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.ts create mode 100644 apps/mobile/src/modules/entry-list/refresh-reset.test.ts create mode 100644 apps/mobile/src/modules/entry-list/refresh-reset.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index b6ee943a2..d6fb07aaf 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -34,6 +34,7 @@ import { useEntryMarkReadHandler } from "./hooks/useEntryMarkReadHandler" import { useNavigateFirstEntry } from "./hooks/useNavigateFirstEntry" import { EntryListHeader } from "./layouts/EntryListHeader" import { EntryEmptyList, EntryList } from "./list" +import { shouldScrollTimelineToTopOnRefreshStateChange } from "./refresh-reset" import { EntryRootStateContext } from "./store/EntryColumnContext" function EntryColumnContent() { @@ -121,7 +122,14 @@ function EntryColumnContent() { const wasRefreshing = wasRefreshingRef.current wasRefreshingRef.current = isRefreshing - if (!wasRefreshing || isRefreshing) return + if ( + !shouldScrollTimelineToTopOnRefreshStateChange({ + wasRefreshing, + isRefreshing, + }) + ) { + return + } scrollTimelineToTop() }, [isRefreshing, scrollTimelineToTop]) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.test.ts b/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.test.ts new file mode 100644 index 000000000..6accc62d4 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest" + +import { shouldScrollTimelineToTopOnRefreshStateChange } from "./refresh-reset" + +describe("shouldScrollTimelineToTopOnRefreshStateChange", () => { + test("scrolls only when the first-page refresh starts", () => { + expect( + shouldScrollTimelineToTopOnRefreshStateChange({ + wasRefreshing: false, + isRefreshing: true, + }), + ).toBe(true) + + expect( + shouldScrollTimelineToTopOnRefreshStateChange({ + wasRefreshing: true, + isRefreshing: false, + }), + ).toBe(false) + + expect( + shouldScrollTimelineToTopOnRefreshStateChange({ + wasRefreshing: true, + isRefreshing: true, + }), + ).toBe(false) + + expect( + shouldScrollTimelineToTopOnRefreshStateChange({ + wasRefreshing: false, + isRefreshing: false, + }), + ).toBe(false) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.ts b/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.ts new file mode 100644 index 000000000..8840dc176 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/refresh-reset.ts @@ -0,0 +1,7 @@ +export const shouldScrollTimelineToTopOnRefreshStateChange = ({ + wasRefreshing, + isRefreshing, +}: { + wasRefreshing: boolean + isRefreshing: boolean +}) => !wasRefreshing && isRefreshing diff --git a/apps/mobile/src/modules/entry-list/EntryListSelector.tsx b/apps/mobile/src/modules/entry-list/EntryListSelector.tsx index 5bb019a26..8c0514b06 100644 --- a/apps/mobile/src/modules/entry-list/EntryListSelector.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListSelector.tsx @@ -17,6 +17,7 @@ import { useEntries, useEntryListContext } from "../screen/atoms" import { EntryListContentArticle } from "./EntryListContentArticle" import { EntryListContentSocial } from "./EntryListContentSocial" import { EntryListContentVideo } from "./EntryListContentVideo" +import { shouldScrollEntryListToTopOnRefreshStateChange } from "./refresh-reset" const NoLoginGuard = ({ children }: { children: React.ReactNode }) => { const whoami = useWhoami() @@ -70,7 +71,30 @@ function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSel }) }, [unreadOnly, ref]) - const { isReady } = useEntries({ viewId, active }) + const { isFetching, isFetchingNextPage, isReady } = useEntries({ viewId, active }) + const isRefreshing = isFetching && !isFetchingNextPage + const wasRefreshingRef = useRef(isRefreshing) + useEffect(() => { + if (!active) return + + const wasRefreshing = wasRefreshingRef.current + wasRefreshingRef.current = isRefreshing + + if ( + !shouldScrollEntryListToTopOnRefreshStateChange({ + wasRefreshing, + isRefreshing, + }) + ) { + return + } + + ref?.current?.scrollToOffset({ + offset: 0, + animated: false, + }) + }, [active, isRefreshing, ref]) + const hasResetAfterReadyRef = useRef(false) useEffect(() => { if (!active) return diff --git a/apps/mobile/src/modules/entry-list/refresh-reset.test.ts b/apps/mobile/src/modules/entry-list/refresh-reset.test.ts new file mode 100644 index 000000000..7c8ecbd4f --- /dev/null +++ b/apps/mobile/src/modules/entry-list/refresh-reset.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest" + +import { shouldScrollEntryListToTopOnRefreshStateChange } from "./refresh-reset" + +describe("shouldScrollEntryListToTopOnRefreshStateChange", () => { + test("scrolls only when the first-page refresh starts", () => { + expect( + shouldScrollEntryListToTopOnRefreshStateChange({ + wasRefreshing: false, + isRefreshing: true, + }), + ).toBe(true) + + expect( + shouldScrollEntryListToTopOnRefreshStateChange({ + wasRefreshing: true, + isRefreshing: false, + }), + ).toBe(false) + + expect( + shouldScrollEntryListToTopOnRefreshStateChange({ + wasRefreshing: true, + isRefreshing: true, + }), + ).toBe(false) + + expect( + shouldScrollEntryListToTopOnRefreshStateChange({ + wasRefreshing: false, + isRefreshing: false, + }), + ).toBe(false) + }) +}) diff --git a/apps/mobile/src/modules/entry-list/refresh-reset.ts b/apps/mobile/src/modules/entry-list/refresh-reset.ts new file mode 100644 index 000000000..510c1f084 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/refresh-reset.ts @@ -0,0 +1,7 @@ +export const shouldScrollEntryListToTopOnRefreshStateChange = ({ + wasRefreshing, + isRefreshing, +}: { + wasRefreshing: boolean + isRefreshing: boolean +}) => !wasRefreshing && isRefreshing From 2ed4ac546bca64d7652fa467e39207821bed7664 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 2 Jun 2026 10:04:30 +0800 Subject: [PATCH 04/17] fix(timeline): guard mark read during scroll reset --- .../entry-column/Items/picture-masonry.tsx | 38 +++++- .../src/modules/entry-column/grid.tsx | 47 ++++++- .../src/modules/entry-column/index.tsx | 31 ++++- .../src/modules/entry-column/list.tsx | 39 +++++- .../modules/entry-column/scroll-reset.test.ts | 59 ++++++++ .../src/modules/entry-column/scroll-reset.ts | 25 ++++ .../entry-list/EntryListContentArticle.tsx | 10 +- .../entry-list/EntryListContentPicture.tsx | 14 +- .../entry-list/EntryListContentSocial.tsx | 12 +- .../entry-list/EntryListContentVideo.tsx | 14 +- .../modules/entry-list/EntryListSelector.tsx | 69 +++++----- apps/mobile/src/modules/entry-list/hooks.ts | 16 ++- .../entry-list/viewable-mark-read.test.ts | 39 ++++++ .../modules/entry-list/viewable-mark-read.ts | 9 ++ .../modules/screen/TimelineSelectorList.tsx | 129 +++++++++++++++++- .../src/modules/screen/scroll-reset.test.ts | 37 +++++ .../mobile/src/modules/screen/scroll-reset.ts | 11 ++ 17 files changed, 543 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts create mode 100644 apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts create mode 100644 apps/mobile/src/modules/entry-list/viewable-mark-read.ts create mode 100644 apps/mobile/src/modules/screen/scroll-reset.test.ts create mode 100644 apps/mobile/src/modules/screen/scroll-reset.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 ea2f3496f..a7743268d 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 @@ -43,6 +43,7 @@ import { imageActions } from "~/store/image" import { useEntriesState } from "../context/EntriesContext" import { batchMarkRead } from "../hooks/useEntryMarkReadHandler" import { useScrollMarkReadEndPadding } from "../hooks/useScrollMarkReadEndPadding" +import { shouldApplyScrollResetSignal } from "../scroll-reset" 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 @@ -53,7 +54,7 @@ const FirstScreenReadyContext = createContext(false) const gutter = 24 export const PictureMasonry: FC = (props) => { - const { data } = props + const { appliedResetScrollSignal, data, onResetScrollSignalConsumed, resetScrollSignal } = props const entriesState = useEntriesState() const pauseScrollMarkRead = useScrollMarkReadGracePeriod( entriesState.isFetching && !entriesState.isFetchingNextPage, @@ -146,6 +147,27 @@ export const PictureMasonry: FC = (props) => { hasNextPage: props.hasNextPage, }) const endSpacerHeight = useScrollMarkReadEndPadding(scrollElement, hasEndSpacer) + const isResetScrollPending = shouldApplyScrollResetSignal({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }) + useLayoutEffect(() => { + if (!scrollElement) return + if (!isInitDim || !deferIsInitLayout) return + if (!isResetScrollPending) return + if (resetScrollSignal === undefined) return + + scrollElement.scrollTop = 0 + scrollElement.scrollLeft = 0 + onResetScrollSignalConsumed?.(resetScrollSignal) + }, [ + onResetScrollSignalConsumed, + deferIsInitLayout, + isInitDim, + isResetScrollPending, + resetScrollSignal, + scrollElement, + ]) const handleRender = useCallback( (startIndex: number, stopIndex: number, items: any[]) => { currentRange.current = { start: startIndex, end: stopIndex } @@ -161,6 +183,7 @@ export const PictureMasonry: FC = (props) => { const dataRef = useRefValue(data) useEffect(() => { if (!renderMarkRead && !scrollMarkRead) return + if (props.suspendMarkRead) return if (!scrollElement) return const observer = new IntersectionObserver( @@ -224,7 +247,14 @@ export const PictureMasonry: FC = (props) => { return () => { observer.disconnect() } - }, [dataRef, pauseScrollMarkRead, renderMarkRead, scrollElement, scrollMarkRead]) + }, [ + dataRef, + pauseScrollMarkRead, + props.suspendMarkRead, + renderMarkRead, + scrollElement, + scrollMarkRead, + ]) const [firstScreenReady, setFirstScreenReady] = useState(false) useEffect(() => { @@ -328,6 +358,10 @@ interface MasonryProps { endReached: () => any hasNextPage: boolean Footer?: FC | ReactNode + appliedResetScrollSignal?: number + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean } const LoadingSkeletonItem = () => { 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 053b7ec84..56b8ade7d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx @@ -25,6 +25,7 @@ import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding import { EntryItem } from "./item" import { PictureMasonry } from "./Items/picture-masonry" import type { EntryListProps } from "./list" +import { getInitialScrollOffset, shouldApplyScrollResetSignal } from "./scroll-reset" export const EntryColumnGrid: FC = (props) => { const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props @@ -40,6 +41,10 @@ export const EntryColumnGrid: FC = (props) => { endReached={fetchNextPage} data={entriesIds} Footer={props.Footer} + appliedResetScrollSignal={props.appliedResetScrollSignal} + onResetScrollSignalConsumed={props.onResetScrollSignalConsumed} + resetScrollSignal={props.resetScrollSignal} + suspendMarkRead={props.suspendMarkRead} /> ) } @@ -102,6 +107,9 @@ const VirtualGridImpl: FC< listRef, measureRef, containerWidth, + appliedResetScrollSignal, + onResetScrollSignalConsumed, + resetScrollSignal, } = props const scrollRef = useScrollViewElement() @@ -136,6 +144,10 @@ const VirtualGridImpl: FC< const rowCacheKey = `${feedId}-row` const columnCacheKey = `${feedId}-column` + const isResetScrollPending = shouldApplyScrollResetSignal({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }) const footerRowIndex = rows.length + (hasNextPage ? 1 : 0) const rowCount = footerRowIndex + (Footer ? 1 : 0) const estimatedRowHeight = columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0) @@ -146,7 +158,11 @@ const VirtualGridImpl: FC< getScrollElement: () => scrollRef, estimateSize: (i) => columns[i]!, overscan: 5, - initialOffset: offsetCache.get(columnCacheKey) ?? 0, + initialOffset: getInitialScrollOffset({ + cachedOffset: offsetCache.get(columnCacheKey), + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }), initialMeasurementsCache: measurementsCache.get(columnCacheKey) ?? [], onChange: useTypeScriptHappyCallback( (virtualizer: Virtualizer) => { @@ -165,7 +181,11 @@ const VirtualGridImpl: FC< overscan: 5, gap: 8, getScrollElement: () => scrollRef, - initialOffset: offsetCache.get(rowCacheKey) ?? 0, + initialOffset: getInitialScrollOffset({ + cachedOffset: offsetCache.get(rowCacheKey), + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }), initialMeasurementsCache: measurementsCache.get(rowCacheKey) ?? [], paddingEnd: 32, onChange: useTypeScriptHappyCallback( @@ -194,6 +214,29 @@ const VirtualGridImpl: FC< listRef.current = rowVirtualizer }, [rowVirtualizer, listRef]) + useLayoutEffect(() => { + if (!scrollRef) return + if (!isResetScrollPending) return + if (resetScrollSignal === undefined) return + + rowVirtualizer.scrollToOffset(0) + columnVirtualizer.scrollToOffset(0) + scrollRef.scrollTop = 0 + scrollRef.scrollLeft = 0 + offsetCache.put(rowCacheKey, 0) + offsetCache.put(columnCacheKey, 0) + onResetScrollSignalConsumed?.(resetScrollSignal) + }, [ + columnCacheKey, + columnVirtualizer, + isResetScrollPending, + onResetScrollSignalConsumed, + resetScrollSignal, + rowCacheKey, + rowVirtualizer, + scrollRef, + ]) + useLayoutEffect(() => { measureRef.current = () => { rowVirtualizer.measure() diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index d6fb07aaf..e3e386021 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -9,7 +9,7 @@ import { useIsLoggedIn } from "@follow/store/user/hooks" import { isBizId } from "@follow/utils/utils" import type { Range, Virtualizer } from "@tanstack/react-virtual" import { atom, useAtomValue } from "jotai" -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react" +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingKey } from "~/atoms/settings/general" @@ -35,6 +35,7 @@ import { useNavigateFirstEntry } from "./hooks/useNavigateFirstEntry" import { EntryListHeader } from "./layouts/EntryListHeader" import { EntryEmptyList, EntryList } from "./list" import { shouldScrollTimelineToTopOnRefreshStateChange } from "./refresh-reset" +import { shouldSuspendMarkReadForScrollReset } from "./scroll-reset" import { EntryRootStateContext } from "./store/EntryColumnContext" function EntryColumnContent() { @@ -53,8 +54,20 @@ function EntryColumnContent() { }, []) const actions = useEntriesActions() + const [resetScrollSignal, setResetScrollSignal] = useState() + const [appliedResetScrollSignal, setAppliedResetScrollSignal] = useState() + const isScrollResetPending = shouldSuspendMarkReadForScrollReset({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }) + const handleResetScrollSignalConsumed = useCallback((signal: number) => { + setAppliedResetScrollSignal((currentSignal) => + currentSignal === signal ? currentSignal : signal, + ) + }, []) const scrollTimelineToTop = useCallback(() => { resetScrollInteractionState() + setResetScrollSignal((signal) => (signal ?? 0) + 1) const runScrollToTop = () => { listRef.current?.scrollToOffset(0) @@ -155,6 +168,10 @@ function EntryColumnContent() { ) const handleScroll = useCallback(() => { + if (isScrollResetPending) { + return + } + if (!isInteracted.current) { isInteracted.current = true } @@ -162,7 +179,7 @@ function EntryColumnContent() { if (latestRangeStartIndexRef.current !== null) { flushScrollMarkRead(latestRangeStartIndexRef.current) } - }, [flushScrollMarkRead]) + }, [flushScrollMarkRead, isScrollResetPending]) const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond() const handleCombinedScroll = useCallback( @@ -185,6 +202,10 @@ function EntryColumnContent() { } latestRangeStartIndexRef.current = e.startIndex + if (isScrollResetPending) { + return + } + if (scrollMarkReadAnchorIndexRef.current === null) { scrollMarkReadAnchorIndexRef.current = e.startIndex } else if (isInteracted.current) { @@ -198,7 +219,7 @@ function EntryColumnContent() { // For gird, render as mark read logic handleRenderMarkRead?.(e, isInteracted.current) }, - [flushScrollMarkRead, handleRenderMarkRead, renderAsRead, view], + [flushScrollMarkRead, handleRenderMarkRead, isScrollResetPending, renderAsRead, view], ) const fetchNextPage = useCallback(() => { @@ -257,6 +278,10 @@ function EntryColumnContent() { fetchNextPage={fetchNextPage} refetch={actions.refetch} groupCounts={groupedCounts} + appliedResetScrollSignal={appliedResetScrollSignal} + onResetScrollSignalConsumed={handleResetScrollSignalConsumed} + resetScrollSignal={resetScrollSignal} + suspendMarkRead={isScrollResetPending} syncType={state.type} Footer={ isCollection ? void 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 a6748179a..705eceb42 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/list.tsx @@ -8,7 +8,7 @@ import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual" import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual" import type { HTMLMotionProps } from "motion/react" import type { FC, MutableRefObject, ReactNode } from "react" -import { memo, startTransition, useEffect, useMemo, useRef, useState } from "react" +import { memo, startTransition, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useEventCallback } from "usehooks-ts" @@ -20,6 +20,7 @@ import { VirtualRowItem } from "./components/VirtualRowItem" import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler" import { EntryItemSkeleton } from "./EntryItemSkeleton" import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding" +import { getInitialScrollOffset, shouldApplyScrollResetSignal } from "./scroll-reset" export const EntryEmptyList = ({ ref, @@ -66,6 +67,10 @@ export type EntryListProps = { onRangeChange?: (range: Range) => void listRef?: MutableRefObject | undefined> + appliedResetScrollSignal?: number + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean } const capacity = 3 @@ -91,6 +96,9 @@ export const EntryList: FC = memo( onRangeChange, gap, syncType, + appliedResetScrollSignal, + onResetScrollSignalConsumed, + resetScrollSignal, }) => { const scrollRef = useScrollViewElement() const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({ @@ -114,13 +122,21 @@ export const EntryList: FC = memo( ) const cacheKey = `${view}-${feedId}` + const isResetScrollPending = shouldApplyScrollResetSignal({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }) const rowVirtualizer = useVirtualizer({ count: entriesIds.length + 1, estimateSize: () => 112, overscan: 5, gap, getScrollElement: () => scrollRef, - initialOffset: offsetCache.get(cacheKey) ?? 0, + initialOffset: getInitialScrollOffset({ + cachedOffset: offsetCache.get(cacheKey), + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }), initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [], onChange: useTypeScriptHappyCallback( (virtualizer: Virtualizer) => { @@ -151,6 +167,25 @@ export const EntryList: FC = memo( listRef.current = rowVirtualizer }, [rowVirtualizer, listRef]) + useLayoutEffect(() => { + if (!scrollRef) return + if (!isResetScrollPending) return + if (resetScrollSignal === undefined) return + + rowVirtualizer.scrollToOffset(0) + scrollRef.scrollTop = 0 + scrollRef.scrollLeft = 0 + offsetCache.put(cacheKey, 0) + onResetScrollSignalConsumed?.(resetScrollSignal) + }, [ + cacheKey, + isResetScrollPending, + onResetScrollSignalConsumed, + resetScrollSignal, + rowVirtualizer, + scrollRef, + ]) + const handleScrollTo = useEventCallback((index: number) => { rowVirtualizer.scrollToIndex(index) }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts new file mode 100644 index 000000000..20161efe1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest" + +import { + getInitialScrollOffset, + shouldApplyScrollResetSignal, + shouldSuspendMarkReadForScrollReset, +} from "./scroll-reset" + +describe("shouldApplyScrollResetSignal", () => { + test("applies a new reset signal that has not been flushed yet", () => { + expect(shouldApplyScrollResetSignal({ resetSignal: 1, appliedResetSignal: undefined })).toBe( + true, + ) + expect(shouldApplyScrollResetSignal({ resetSignal: 2, appliedResetSignal: 1 })).toBe(true) + }) + + test("does not apply missing or already flushed reset signals", () => { + expect( + shouldApplyScrollResetSignal({ + resetSignal: undefined, + appliedResetSignal: undefined, + }), + ).toBe(false) + expect(shouldApplyScrollResetSignal({ resetSignal: 1, appliedResetSignal: 1 })).toBe(false) + }) + + test("uses top offset while reset is pending", () => { + expect( + getInitialScrollOffset({ + cachedOffset: 320, + resetSignal: 1, + appliedResetSignal: undefined, + }), + ).toBe(0) + + expect( + getInitialScrollOffset({ + cachedOffset: 320, + resetSignal: 1, + appliedResetSignal: 1, + }), + ).toBe(320) + }) + + test("suspends mark-read while reset is pending", () => { + expect( + shouldSuspendMarkReadForScrollReset({ + resetSignal: 1, + appliedResetSignal: undefined, + }), + ).toBe(true) + expect( + shouldSuspendMarkReadForScrollReset({ + resetSignal: 1, + appliedResetSignal: 1, + }), + ).toBe(false) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts new file mode 100644 index 000000000..a75d9ae1a --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts @@ -0,0 +1,25 @@ +type ScrollResetSignalState = { + resetSignal?: number + appliedResetSignal?: number +} + +export const shouldApplyScrollResetSignal = ({ + resetSignal, + appliedResetSignal, +}: ScrollResetSignalState) => resetSignal !== undefined && resetSignal !== appliedResetSignal + +export const shouldSuspendMarkReadForScrollReset = shouldApplyScrollResetSignal + +export const getInitialScrollOffset = ({ + cachedOffset, + resetSignal, + appliedResetSignal, +}: ScrollResetSignalState & { + cachedOffset: number | undefined +}) => + shouldApplyScrollResetSignal({ + resetSignal, + appliedResetSignal, + }) + ? 0 + : (cachedOffset ?? 0) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx index fef47f284..ed19fccf1 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx @@ -36,8 +36,14 @@ export const EntryListContentArticle = ({ entryIds, active, view, + onResetScrollSignalConsumed, + resetScrollSignal, + suspendMarkRead, }: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & { ref?: React.Ref | null> + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean }) => { const extraData: EntryExtraData = useMemo(() => ({ entryIds }), [entryIds]) const readableItemStyle = useReadableContainerStyle(860, 16) @@ -88,7 +94,7 @@ export const EntryListContentArticle = ({ const ref = useRef>(null) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ - disabled: active === false || isFetching, + disabled: active === false || isFetching || suspendMarkRead, refreshing: isFetching && !isFetchingNextPage, }) @@ -129,6 +135,8 @@ export const EntryListContentArticle = ({ ref={ref} onRefresh={refetch} isRefetching={isRefetching} + onResetScrollSignalConsumed={onResetScrollSignalConsumed} + resetScrollSignal={resetScrollSignal} data={entryIds} extraData={extraData} keyExtractor={defaultKeyExtractor} diff --git a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx index 85adc9efa..602123ea9 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx @@ -35,11 +35,19 @@ export const EntryListContentPicture = ({ entryIds, active, view, + onResetScrollSignalConsumed, + resetScrollSignal, + suspendMarkRead, ...rest }: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit< FlashListProps, "data" | "renderItem" -> & { ref?: React.Ref | null> }) => { +> & { + ref?: React.Ref | null> + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean + }) => { const ref = useRef>(null) const isTablet = useIsTabletLayout() @@ -57,7 +65,7 @@ export const EntryListContentPicture = ({ active, }) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ - disabled: active === false || isFetching, + disabled: active === false || isFetching || suspendMarkRead, refreshing: isFetching && !isFetchingNextPage, }) const translation = useGeneralSettingKey("translation") @@ -114,6 +122,8 @@ export const EntryListContentPicture = ({ | null> + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean }) => { const { fetchNextPage, @@ -69,7 +75,7 @@ export const EntryListContentSocial = ({ ) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ - disabled: active === false || isFetching, + disabled: active === false || isFetching || suspendMarkRead, refreshing: isFetching && !isFetchingNextPage, }) @@ -92,6 +98,8 @@ export const EntryListContentSocial = ({ {}} isRefetching={false} + onResetScrollSignalConsumed={onResetScrollSignalConsumed} + resetScrollSignal={resetScrollSignal} data={Array.from({ length: 5 }).map((_, index) => `skeleton-${index}`)} keyExtractor={(id) => id} renderItem={EntryItemSkeleton} @@ -107,6 +115,8 @@ export const EntryListContentSocial = ({ refetch() }} isRefetching={isRefetching} + onResetScrollSignalConsumed={onResetScrollSignalConsumed} + resetScrollSignal={resetScrollSignal} data={entryIds} extraData={extraData} keyExtractor={(id) => id} diff --git a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx index b6ddc7fa2..b66181aaf 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx @@ -28,11 +28,19 @@ export const EntryListContentVideo = ({ entryIds, active, view, + onResetScrollSignalConsumed, + resetScrollSignal, + suspendMarkRead, ...rest }: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit< FlashListProps, "data" | "renderItem" -> & { ref?: React.Ref | null> }) => { +> & { + ref?: React.Ref | null> + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number + suspendMarkRead?: boolean + }) => { const ref = useRef>(null) useImperativeHandle(forwardRef, () => ref.current!) const isTablet = useIsTabletLayout() @@ -49,7 +57,7 @@ export const EntryListContentVideo = ({ active, }) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ - disabled: active === false || isFetching, + disabled: active === false || isFetching || suspendMarkRead, refreshing: isFetching && !isFetchingNextPage, }) @@ -120,6 +128,8 @@ export const EntryListContentVideo = ({ >(active) + const [resetScrollSignal, setResetScrollSignal] = useState() + const [appliedResetScrollSignal, setAppliedResetScrollSignal] = useState() + const isScrollResetPending = shouldSuspendMarkReadForScrollReset({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignal, + }) + const requestScrollToTop = useCallback(() => { + setResetScrollSignal((signal) => (signal ?? 0) + 1) + }, []) + const handleResetScrollSignalConsumed = useCallback((signal: number) => { + setAppliedResetScrollSignal((currentSignal) => + currentSignal === signal ? currentSignal : signal, + ) + }, []) let ContentComponent: | typeof EntryListContentSocial @@ -65,11 +80,8 @@ function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSel const unreadOnly = useGeneralSettingKey("unreadOnly") useEffect(() => { - ref?.current?.scrollToOffset({ - offset: 0, - animated: false, - }) - }, [unreadOnly, ref]) + requestScrollToTop() + }, [requestScrollToTop, unreadOnly]) const { isFetching, isFetchingNextPage, isReady } = useEntries({ viewId, active }) const isRefreshing = isFetching && !isFetchingNextPage @@ -89,11 +101,8 @@ function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSel return } - ref?.current?.scrollToOffset({ - offset: 0, - animated: false, - }) - }, [active, isRefreshing, ref]) + requestScrollToTop() + }, [active, isRefreshing, requestScrollToTop]) const hasResetAfterReadyRef = useRef(false) useEffect(() => { @@ -105,37 +114,29 @@ function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSel if (!entryIds?.length) return if (hasResetAfterReadyRef.current) return - const frameId = requestAnimationFrame(() => { - ref?.current?.scrollToOffset({ - offset: 0, - animated: false, - }) - }) + requestScrollToTop() hasResetAfterReadyRef.current = true - - return () => { - cancelAnimationFrame(frameId) - } - }, [active, entryIds, isReady, ref, viewId]) + }, [active, entryIds, isReady, requestScrollToTop, viewId]) useEffect(() => { if (!active) return - const frameId = requestAnimationFrame(() => { - ref?.current?.scrollToOffset({ - offset: 0, - animated: false, - }) - }) - - return () => { - cancelAnimationFrame(frameId) - } - }, [active, ref, viewId]) + requestScrollToTop() + }, [active, requestScrollToTop, viewId]) useAutoScrollToEntryAfterPullUpToNext(ref, entryIds || []) - return + return ( + + ) } export const EntryListSelector = withErrorBoundary( diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index a6f5851a8..06188176c 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -8,6 +8,8 @@ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" import { useGeneralSettingKey } from "@/src/atoms/settings/general" +import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read" + const defaultIdExtractor = (item: ViewToken) => item.key export function useOnViewableItemsChanged({ disabled, @@ -42,9 +44,21 @@ export function useOnViewableItemsChanged({ debouncedFetchEntryContentByStream(viewableItems.map((item) => stableIdExtractor(item))) const removed = changed.filter((item) => !item.isViewable) + if (disabled) { + setLastRemovedItems(null) + setLastViewableItems(null) + return + } + // Only when the scroll direction is down and the current offset is a positive number, is it marked as read. // This can avoid misjudgment during the rebound of the pull-to-refresh (because the offset will change from negative to zero during the rebound). - if (orientation.current === "down" && lastOffset.current > 0) { + if ( + shouldCollectViewableItemsForMarkRead({ + disabled, + isScrollingDown: orientation.current === "down", + offset: lastOffset.current, + }) + ) { setLastViewableItems(viewableItems) if (pauseScrollMarkRead) { setLastRemovedItems(null) diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts new file mode 100644 index 000000000..cf6f40600 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest" + +import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read" + +describe("shouldCollectViewableItemsForMarkRead", () => { + test("does not collect viewable items while disabled", () => { + expect( + shouldCollectViewableItemsForMarkRead({ + disabled: true, + isScrollingDown: true, + offset: 120, + }), + ).toBe(false) + }) + + test("collects viewable items only when scrolling down beyond the top", () => { + expect( + shouldCollectViewableItemsForMarkRead({ + disabled: false, + isScrollingDown: true, + offset: 120, + }), + ).toBe(true) + expect( + shouldCollectViewableItemsForMarkRead({ + disabled: false, + isScrollingDown: false, + offset: 120, + }), + ).toBe(false) + expect( + shouldCollectViewableItemsForMarkRead({ + disabled: false, + isScrollingDown: true, + offset: 0, + }), + ).toBe(false) + }) +}) diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts new file mode 100644 index 000000000..7be7642ac --- /dev/null +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts @@ -0,0 +1,9 @@ +export const shouldCollectViewableItemsForMarkRead = ({ + disabled, + isScrollingDown, + offset, +}: { + disabled?: boolean + isScrollingDown: boolean + offset: number +}) => !disabled && isScrollingDown && offset > 0 diff --git a/apps/mobile/src/modules/screen/TimelineSelectorList.tsx b/apps/mobile/src/modules/screen/TimelineSelectorList.tsx index affac8f9e..11133427b 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorList.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorList.tsx @@ -5,7 +5,7 @@ import { nextFrame } from "@follow/utils" import type { FlashListProps, FlashListRef } from "@shopify/flash-list" import { FlashList } from "@shopify/flash-list" import * as Haptics from "expo-haptics" -import { use, useCallback, useImperativeHandle, useRef } from "react" +import { use, useCallback, useEffect, useImperativeHandle, useRef } from "react" import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" import { RefreshControl, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -16,16 +16,59 @@ import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" import { EntryListEmpty } from "../entry-list/EntryListEmpty" +import { shouldApplyScrollResetSignal } from "./scroll-reset" type Props = { onRefresh: () => void isRefetching: boolean + onResetScrollSignalConsumed?: (signal: number) => void + resetScrollSignal?: number +} + +const usePendingScrollReset = ( + resetScrollSignal: number | undefined, + scrollToTop: () => boolean, + onResetScrollSignalConsumed?: (signal: number) => void, +) => { + const appliedResetScrollSignalRef = useRef(undefined) + const canApplyScrollResetRef = useRef(false) + const flushPendingScrollReset = useCallback(() => { + if (!canApplyScrollResetRef.current) return + if ( + !shouldApplyScrollResetSignal({ + resetSignal: resetScrollSignal, + appliedResetSignal: appliedResetScrollSignalRef.current, + }) + ) { + return + } + + requestAnimationFrame(() => { + if (scrollToTop()) { + appliedResetScrollSignalRef.current = resetScrollSignal + if (resetScrollSignal !== undefined) { + onResetScrollSignalConsumed?.(resetScrollSignal) + } + } + }) + }, [onResetScrollSignalConsumed, resetScrollSignal, scrollToTop]) + + useEffect(() => { + flushPendingScrollReset() + }, [flushPendingScrollReset]) + + return useCallback(() => { + canApplyScrollResetRef.current = true + flushPendingScrollReset() + }, [flushPendingScrollReset]) } export const TimelineSelectorList = ({ ref: forwardedRef, onRefresh, isRefetching, + onResetScrollSignalConsumed, + resetScrollSignal, ...props }: Props & Omit, "onRefresh"> & { ref?: React.Ref | null> }) => { @@ -38,6 +81,23 @@ export const TimelineSelectorList = ({ const { scrollViewHeight, scrollViewContentHeight, reAnimatedScrollY } = use(ScreenItemContext)! const tabBarHeight = useBottomTabBarHeight() + const scrollToTop = useCallback(() => { + const scroller = ref.current + if (!scroller) return false + + scroller.scrollToOffset({ + offset: 0, + animated: false, + }) + reAnimatedScrollY.value = 0 + return true + }, [reAnimatedScrollY]) + const markScrollResetReady = usePendingScrollReset( + resetScrollSignal, + scrollToTop, + onResetScrollSignalConsumed, + ) + const onScroll = useCallback( (e: NativeSyntheticEvent) => { props.onScroll?.(e) @@ -50,17 +110,31 @@ export const TimelineSelectorList = ({ const onLayout = useTypeScriptHappyCallback( (e) => { + props.onLayout?.(e) scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight - tabBarHeight }, - [scrollViewHeight], + [headerHeight, props, scrollViewHeight, tabBarHeight], ) as FlashListProps["onLayout"] const onContentSizeChange = useTypeScriptHappyCallback( (w, h) => { + props.onContentSizeChange?.(w, h) scrollViewContentHeight.value = h + markScrollResetReady() }, - [scrollViewContentHeight], + [markScrollResetReady, props, scrollViewContentHeight], ) as FlashListProps["onContentSizeChange"] + const onLoad = useTypeScriptHappyCallback( + (info) => { + props.onLoad?.(info) + markScrollResetReady() + }, + [markScrollResetReady, props], + ) as FlashListProps["onLoad"] + const onCommitLayoutEffect = useTypeScriptHappyCallback(() => { + props.onCommitLayoutEffect?.() + markScrollResetReady() + }, [markScrollResetReady, props]) as FlashListProps["onCommitLayoutEffect"] if (props.data?.length === 0) { return @@ -72,8 +146,6 @@ export const TimelineSelectorList = ({ automaticallyAdjustsScrollIndicatorInsets={false} automaticallyAdjustContentInsets={false} ref={ref} - onLayout={onLayout} - onContentSizeChange={onContentSizeChange} refreshControl={ { nextFrame(() => { @@ -109,9 +185,11 @@ export const TimelineSelectorList = ({ } export const TimelineSelectorMasonryList = ({ - ref, + ref: forwardedRef, onRefresh, isRefetching, + onResetScrollSignalConsumed, + resetScrollSignal, ...props }: Props & Omit, "onRefresh"> & { @@ -119,12 +197,30 @@ export const TimelineSelectorMasonryList = ({ }) => { const { refetch: unreadRefetch } = usePrefetchUnread() const { refetch: subscriptionRefetch } = usePrefetchSubscription() + const ref = useRef>(null) + useImperativeHandle(forwardedRef, () => ref.current!) const insets = useSafeAreaInsets() const headerHeight = useHeaderHeight() const { reAnimatedScrollY } = use(ScreenItemContext)! + const scrollToTop = useCallback(() => { + const scroller = ref.current + if (!scroller) return false + + scroller.scrollToOffset({ + offset: 0, + animated: false, + }) + reAnimatedScrollY.value = 0 + return true + }, [reAnimatedScrollY]) + const markScrollResetReady = usePendingScrollReset( + resetScrollSignal, + scrollToTop, + onResetScrollSignalConsumed, + ) const onScroll = useCallback( (e: NativeSyntheticEvent) => { @@ -135,6 +231,24 @@ export const TimelineSelectorMasonryList = ({ ) const tabBarHeight = useBottomTabBarHeight() + const onContentSizeChange = useTypeScriptHappyCallback( + (w, h) => { + props.onContentSizeChange?.(w, h) + markScrollResetReady() + }, + [markScrollResetReady, props], + ) as FlashListProps["onContentSizeChange"] + const onLoad = useTypeScriptHappyCallback( + (info) => { + props.onLoad?.(info) + markScrollResetReady() + }, + [markScrollResetReady, props], + ) as FlashListProps["onLoad"] + const onCommitLayoutEffect = useTypeScriptHappyCallback(() => { + props.onCommitLayoutEffect?.() + markScrollResetReady() + }, [markScrollResetReady, props]) as FlashListProps["onCommitLayoutEffect"] const systemFill = useColor("secondaryLabel") @@ -160,6 +274,9 @@ export const TimelineSelectorMasonryList = ({ /> } {...props} + onLoad={onLoad} + onCommitLayoutEffect={onCommitLayoutEffect} + onContentSizeChange={onContentSizeChange} contentContainerStyle={[ { paddingTop: headerHeight, diff --git a/apps/mobile/src/modules/screen/scroll-reset.test.ts b/apps/mobile/src/modules/screen/scroll-reset.test.ts new file mode 100644 index 000000000..164fef019 --- /dev/null +++ b/apps/mobile/src/modules/screen/scroll-reset.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest" + +import { shouldApplyScrollResetSignal, shouldSuspendMarkReadForScrollReset } from "./scroll-reset" + +describe("shouldApplyScrollResetSignal", () => { + test("applies a new reset signal that has not been flushed yet", () => { + expect(shouldApplyScrollResetSignal({ resetSignal: 1, appliedResetSignal: undefined })).toBe( + true, + ) + expect(shouldApplyScrollResetSignal({ resetSignal: 2, appliedResetSignal: 1 })).toBe(true) + }) + + test("does not apply missing or already flushed reset signals", () => { + expect( + shouldApplyScrollResetSignal({ + resetSignal: undefined, + appliedResetSignal: undefined, + }), + ).toBe(false) + expect(shouldApplyScrollResetSignal({ resetSignal: 1, appliedResetSignal: 1 })).toBe(false) + }) + + test("suspends mark-read while reset is pending", () => { + expect( + shouldSuspendMarkReadForScrollReset({ + resetSignal: 1, + appliedResetSignal: undefined, + }), + ).toBe(true) + expect( + shouldSuspendMarkReadForScrollReset({ + resetSignal: 1, + appliedResetSignal: 1, + }), + ).toBe(false) + }) +}) diff --git a/apps/mobile/src/modules/screen/scroll-reset.ts b/apps/mobile/src/modules/screen/scroll-reset.ts new file mode 100644 index 000000000..92a6c32f1 --- /dev/null +++ b/apps/mobile/src/modules/screen/scroll-reset.ts @@ -0,0 +1,11 @@ +type ScrollResetSignalState = { + resetSignal?: number + appliedResetSignal?: number +} + +export const shouldApplyScrollResetSignal = ({ + resetSignal, + appliedResetSignal, +}: ScrollResetSignalState) => resetSignal !== undefined && resetSignal !== appliedResetSignal + +export const shouldSuspendMarkReadForScrollReset = shouldApplyScrollResetSignal From 31c10ea63561c423d04a2a2bc10c1cff4b96a859 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 2 Jun 2026 10:38:16 +0800 Subject: [PATCH 05/17] fix(desktop): hide empty recent reader spacer --- .../EntryReadHistory.test.tsx | 113 ++++++++++++++++++ .../entry-read-history/EntryReadHistory.tsx | 7 +- 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.test.tsx new file mode 100644 index 000000000..66385179e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.test.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest" + +import { EntryReadHistory } from "./EntryReadHistory" + +const { useEntryReadHistoryMock, useWhoamiMock } = vi.hoisted(() => ({ + useEntryReadHistoryMock: vi.fn(), + useWhoamiMock: vi.fn(), +})) + +vi.mock("@follow/components/ui/avatar-group/index.js", () => ({ + AvatarGroup: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock("@follow/store/entry/hooks", () => ({ + useEntryReadHistory: useEntryReadHistoryMock, +})) + +vi.mock("@follow/store/user/hooks", () => ({ + useWhoami: useWhoamiMock, +})) + +vi.mock("~/hooks/biz/useRouteParams", () => ({ + getRouteParams: vi.fn(() => ({ view: 0 })), +})) + +vi.mock("~/providers/app-grid-layout-container-provider", () => ({ + useAppLayoutGridContainerWidth: vi.fn(() => 800), +})) + +vi.mock("./EntryUser", () => ({ + EntryUser: ({ userId }: { userId: string }) => {userId}, +})) + +const renderComponent = async (element: React.ReactNode) => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + + await act(async () => { + root.render(element) + }) + + return { container, root } +} + +describe("EntryReadHistory", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + root = null + container = null + vi.clearAllMocks() + }) + + test("renders nothing when read history has no displayable users", async () => { + useWhoamiMock.mockReturnValue({ id: "me" }) + useEntryReadHistoryMock.mockReturnValue({ + entryReadHistories: { + userIds: ["me"], + }, + total: 1, + }) + ;({ container, root } = await renderComponent()) + + expect(container?.innerHTML).toBe("") + }) + + test("renders nothing when read history is unavailable", async () => { + useWhoamiMock.mockReturnValue({ id: "me" }) + useEntryReadHistoryMock.mockReturnValue({ + total: 0, + }) + ;({ container, root } = await renderComponent()) + + expect(container?.innerHTML).toBe("") + }) + + test("renders users when read history has other readers", async () => { + useWhoamiMock.mockReturnValue({ id: "me" }) + useEntryReadHistoryMock.mockReturnValue({ + entryReadHistories: { + userIds: ["me", "reader-1"], + }, + total: 2, + }) + ;({ container, root } = await renderComponent()) + + expect(container?.querySelector('[data-testid="avatar-group"]')).not.toBeNull() + expect(container?.querySelector('[data-testid="entry-user"]')?.textContent).toBe("reader-1") + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.tsx index ee6e2211b..0a3a5135f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-read-history/EntryReadHistory.tsx @@ -37,13 +37,12 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => const LIMIT = getLimit(appGirdContainerWidth) - const placeholder =
- if (!entryHistory) return placeholder - if (!me) return placeholder + if (!entryHistory) return null + if (!me) return null const displayUsers = entryHistory.userIds.filter((id) => id !== me?.id).slice(0, LIMIT) - if (displayUsers.length === 0) return placeholder + if (displayUsers.length === 0) return null return (
Date: Fri, 5 Jun 2026 10:26:09 +0800 Subject: [PATCH 06/17] fix(mobile): preserve social timeline scroll reset --- .../entry-list/EntryListContentSocial.tsx | 12 +++++++-- .../src/modules/screen/scroll-reset.test.ts | 26 ++++++++++++++++++- .../mobile/src/modules/screen/scroll-reset.ts | 12 +++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx index c3617e879..2c4080ec1 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx @@ -11,6 +11,7 @@ import { View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useEntries } from "../screen/atoms" +import { getResetScrollSignalForContent } from "../screen/scroll-reset" import { TimelineSelectorList } from "../screen/TimelineSelectorList" import { EntryListEndScrollSpacer } from "./EntryListEndScrollSpacer" import { EntryListFooter } from "./EntryListFooter" @@ -92,6 +93,13 @@ export const EntryListContentSocial = ({ mode: translationMode, }) + const contentResetScrollSignal = getResetScrollSignalForContent({ + entryCount: entryIds?.length ?? 0, + hasScrollableSkeleton: true, + isReady, + resetScrollSignal, + }) + // Show loading skeleton when entries are not ready and no data yet if (!isReady && (!entryIds || entryIds.length === 0)) { return ( @@ -99,7 +107,7 @@ export const EntryListContentSocial = ({ onRefresh={() => {}} isRefetching={false} onResetScrollSignalConsumed={onResetScrollSignalConsumed} - resetScrollSignal={resetScrollSignal} + resetScrollSignal={contentResetScrollSignal} data={Array.from({ length: 5 }).map((_, index) => `skeleton-${index}`)} keyExtractor={(id) => id} renderItem={EntryItemSkeleton} @@ -116,7 +124,7 @@ export const EntryListContentSocial = ({ }} isRefetching={isRefetching} onResetScrollSignalConsumed={onResetScrollSignalConsumed} - resetScrollSignal={resetScrollSignal} + resetScrollSignal={contentResetScrollSignal} data={entryIds} extraData={extraData} keyExtractor={(id) => id} diff --git a/apps/mobile/src/modules/screen/scroll-reset.test.ts b/apps/mobile/src/modules/screen/scroll-reset.test.ts index 164fef019..2f58ca8bc 100644 --- a/apps/mobile/src/modules/screen/scroll-reset.test.ts +++ b/apps/mobile/src/modules/screen/scroll-reset.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest" -import { shouldApplyScrollResetSignal, shouldSuspendMarkReadForScrollReset } from "./scroll-reset" +import { + getResetScrollSignalForContent, + shouldApplyScrollResetSignal, + shouldSuspendMarkReadForScrollReset, +} from "./scroll-reset" describe("shouldApplyScrollResetSignal", () => { test("applies a new reset signal that has not been flushed yet", () => { @@ -34,4 +38,24 @@ describe("shouldApplyScrollResetSignal", () => { }), ).toBe(false) }) + + test("does not forward reset signal to scrollable loading skeletons", () => { + expect( + getResetScrollSignalForContent({ + entryCount: 0, + hasScrollableSkeleton: true, + isReady: false, + resetScrollSignal: 1, + }), + ).toBeUndefined() + + expect( + getResetScrollSignalForContent({ + entryCount: 1, + hasScrollableSkeleton: true, + isReady: true, + resetScrollSignal: 1, + }), + ).toBe(1) + }) }) diff --git a/apps/mobile/src/modules/screen/scroll-reset.ts b/apps/mobile/src/modules/screen/scroll-reset.ts index 92a6c32f1..4234e29eb 100644 --- a/apps/mobile/src/modules/screen/scroll-reset.ts +++ b/apps/mobile/src/modules/screen/scroll-reset.ts @@ -9,3 +9,15 @@ export const shouldApplyScrollResetSignal = ({ }: ScrollResetSignalState) => resetSignal !== undefined && resetSignal !== appliedResetSignal export const shouldSuspendMarkReadForScrollReset = shouldApplyScrollResetSignal + +export const getResetScrollSignalForContent = ({ + entryCount, + hasScrollableSkeleton, + isReady, + resetScrollSignal, +}: { + entryCount: number + hasScrollableSkeleton: boolean + isReady: boolean + resetScrollSignal?: number +}) => (!isReady && entryCount === 0 && hasScrollableSkeleton ? undefined : resetScrollSignal) From 73870a8170f969d17f5bf5494aa9949fafc3cb76 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 5 Jun 2026 11:42:37 +0800 Subject: [PATCH 07/17] fix(desktop): reset social timeline on view change --- .../src/modules/entry-column/index.tsx | 20 +++++++++-- .../modules/entry-column/scroll-reset.test.ts | 35 +++++++++++++++++++ .../src/modules/entry-column/scroll-reset.ts | 11 ++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index e3e386021..33f9dc1f0 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -35,7 +35,10 @@ import { useNavigateFirstEntry } from "./hooks/useNavigateFirstEntry" import { EntryListHeader } from "./layouts/EntryListHeader" import { EntryEmptyList, EntryList } from "./list" import { shouldScrollTimelineToTopOnRefreshStateChange } from "./refresh-reset" -import { shouldSuspendMarkReadForScrollReset } from "./scroll-reset" +import { + shouldResetScrollOnTimelineIdentityChange, + shouldSuspendMarkReadForScrollReset, +} from "./scroll-reset" import { EntryRootStateContext } from "./store/EntryColumnContext" function EntryColumnContent() { @@ -126,9 +129,22 @@ function EntryColumnContent() { timelineIdentity, ) + const previousTimelineIdentityRef = useRef(undefined) useLayoutEffect(() => { + const previousTimelineIdentity = previousTimelineIdentityRef.current + previousTimelineIdentityRef.current = timelineIdentity + resetScrollInteractionState() - }, [resetScrollInteractionState, timelineIdentity]) + if ( + shouldResetScrollOnTimelineIdentityChange({ + enabled: view === FeedViewType.SocialMedia, + previousTimelineIdentity, + timelineIdentity, + }) + ) { + scrollTimelineToTop() + } + }, [resetScrollInteractionState, scrollTimelineToTop, timelineIdentity, view]) const wasRefreshingRef = useRef(isRefreshing) useEffect(() => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts index 20161efe1..017a34bfb 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest" import { getInitialScrollOffset, shouldApplyScrollResetSignal, + shouldResetScrollOnTimelineIdentityChange, shouldSuspendMarkReadForScrollReset, } from "./scroll-reset" @@ -56,4 +57,38 @@ describe("shouldApplyScrollResetSignal", () => { }), ).toBe(false) }) + + test("resets scroll for enabled timeline identity changes after initial mount", () => { + expect( + shouldResetScrollOnTimelineIdentityChange({ + enabled: true, + previousTimelineIdentity: undefined, + timelineIdentity: "6:", + }), + ).toBe(false) + + expect( + shouldResetScrollOnTimelineIdentityChange({ + enabled: true, + previousTimelineIdentity: "0:", + timelineIdentity: "6:", + }), + ).toBe(true) + + expect( + shouldResetScrollOnTimelineIdentityChange({ + enabled: false, + previousTimelineIdentity: "0:", + timelineIdentity: "6:", + }), + ).toBe(false) + + expect( + shouldResetScrollOnTimelineIdentityChange({ + enabled: true, + previousTimelineIdentity: "6:", + timelineIdentity: "6:", + }), + ).toBe(false) + }) }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts index a75d9ae1a..28f9f333e 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts @@ -23,3 +23,14 @@ export const getInitialScrollOffset = ({ }) ? 0 : (cachedOffset ?? 0) + +export const shouldResetScrollOnTimelineIdentityChange = ({ + enabled, + previousTimelineIdentity, + timelineIdentity, +}: { + enabled: boolean + previousTimelineIdentity?: string + timelineIdentity: string +}) => + enabled && previousTimelineIdentity !== undefined && previousTimelineIdentity !== timelineIdentity From c98f572cf89a659c319bca598ffddf0654a7453a Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 5 Jun 2026 19:44:07 +0800 Subject: [PATCH 08/17] fix: dedupe code blocks with nested line divs fix: dedupe code blocks with nested line divs --- apps/desktop/changelog/next.md | 2 ++ .../renderer/src/lib/__tests__/parse-html.test.ts | 14 ++++++++++++++ apps/desktop/layer/renderer/src/lib/parse-html.ts | 4 +++- apps/mobile/changelog/next.md | 2 ++ apps/mobile/web-app/html-renderer/src/parser.tsx | 4 +++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449..f9b6605c3 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -6,6 +6,8 @@ ## No longer broken +- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) + ## Thanks Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts b/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts index 4835d9798..12930aeed 100644 --- a/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts +++ b/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts @@ -365,6 +365,20 @@ describe("extractCodeFromHtml", () => { `) }) + // https://developers.cloudflare.com/changelog/rss/index.xml + it("should not duplicate code blocks from cloudflare changelog", () => { + const htmlString = `
{
"$schema": "./node_modules/wrangler/config-schema.json",
"pipelines": [
}
` + const result = extractCodeFromHtml(htmlString) + + expect(result).toMatchInlineSnapshot(` + "{ + "$schema": "./node_modules/wrangler/config-schema.json", + "pipelines": [ + } + " + `) + }) + it("no ", () => { const htmlString = `if theme.twikoo.enable == true
#tcomment
script(src='https://registry.npmmirror.com/twikoo/1.6.39/files/dist/twikoo.all.min.js')
script.
twikoo.init({
envId: '#{theme.twikoo.envId}',
el: '#tcomment',
region: '#{theme.twikoo.region}',
path: '#{theme.twikoo.path}',
onCommentLoaded: function () {
const commentCountElement = document.querySelector('.tk-comments-count');
const targetElement = document.querySelector('.waline-comment-count');
if (commentCountElement) {
const countSpan = commentCountElement.querySelector('span:first-child');
const commentCount = parseInt(countSpan.textContent);
targetElement.textContent = commentCount;
} else {
console.log('未找到评论数量元素');
}
}
})

` const result = extractCodeFromHtml(htmlString) diff --git a/apps/desktop/layer/renderer/src/lib/parse-html.ts b/apps/desktop/layer/renderer/src/lib/parse-html.ts index 3edb5325a..14ddf1ff5 100644 --- a/apps/desktop/layer/renderer/src/lib/parse-html.ts +++ b/apps/desktop/layer/renderer/src/lib/parse-html.ts @@ -269,7 +269,9 @@ export function extractCodeFromHtml(htmlString: string) { if (divElements.length > 0) { divElements.forEach((div) => { - code += `${div.textContent}\n` + if (!div.querySelector("div")) { + code += `${div.textContent}\n` + } }) return code } diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 000f858e3..85989e2f7 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -6,6 +6,8 @@ ## No longer broken +- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) + ## Thanks Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/web-app/html-renderer/src/parser.tsx b/apps/mobile/web-app/html-renderer/src/parser.tsx index 6178290ca..740e02054 100644 --- a/apps/mobile/web-app/html-renderer/src/parser.tsx +++ b/apps/mobile/web-app/html-renderer/src/parser.tsx @@ -197,7 +197,9 @@ function extractCodeFromHtml(htmlString: string) { if (divElements.length > 0) { divElements.forEach((div) => { - code += `${div.textContent}\n` + if (!div.querySelector("div")) { + code += `${div.textContent}\n` + } }) return code } From bde516f600ab9b682bac9441c6993eaac5e8733e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:32:26 +0800 Subject: [PATCH 09/17] build(deps): bump actions/checkout from 6 to 7 (#5025) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-android.yml | 2 +- .github/workflows/build-desktop.yml | 6 +++--- .github/workflows/build-ios-development.yml | 6 +++--- .github/workflows/build-ios.yml | 4 ++-- .github/workflows/build-web.yml | 2 +- .github/workflows/deploy-cloudflare-desktop.yml | 2 +- .github/workflows/deploy-cloudflare-landing.yml | 2 +- .github/workflows/deploy-cloudflare-ssr.yml | 2 +- .github/workflows/issue-labeler.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-ota.yml | 2 +- .github/workflows/similar-issues.yml | 2 +- .github/workflows/tag.yml | 6 +++--- .github/workflows/translator.yml | 2 +- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index f75778135..a451d6b0b 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -51,7 +51,7 @@ jobs: df -h / - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 📦 Setup pnpm uses: pnpm/action-setup@v6 diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 57a936113..fe1417385 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -67,13 +67,13 @@ jobs: steps: - name: Check out Git repository Fully - uses: actions/checkout@v6 + uses: actions/checkout@v7 if: env.PROD == 'true' with: fetch-depth: 0 lfs: true - name: Check out Git repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 if: env.PROD == 'false' with: fetch-depth: 1 @@ -398,7 +398,7 @@ jobs: steps: - name: Check out Git repository Fully - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 lfs: true diff --git a/.github/workflows/build-ios-development.yml b/.github/workflows/build-ios-development.yml index 4c3957e3f..4835cbee0 100644 --- a/.github/workflows/build-ios-development.yml +++ b/.github/workflows/build-ios-development.yml @@ -40,7 +40,7 @@ jobs: steps: - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 📱 Setup EAS uses: expo/expo-github-action@v8 @@ -85,7 +85,7 @@ jobs: steps: - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode @@ -136,7 +136,7 @@ jobs: steps: - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 85ad6f586..d3fea84b3 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -56,7 +56,7 @@ jobs: steps: - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 📱 Setup EAS uses: expo/expo-github-action@v8 @@ -106,7 +106,7 @@ jobs: steps: - name: 📦 Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 🔧 Setup Xcode uses: ./.github/actions/setup-xcode diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 8230ebf4e..f936b103b 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -17,7 +17,7 @@ jobs: node-version: [lts/*] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: lfs: true - name: Cache turbo build setup diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml index 7bd62d19c..d91e262bd 100644 --- a/.github/workflows/deploy-cloudflare-desktop.yml +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -17,7 +17,7 @@ jobs: VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: lfs: true diff --git a/.github/workflows/deploy-cloudflare-landing.yml b/.github/workflows/deploy-cloudflare-landing.yml index 4f3b52906..b7894079d 100644 --- a/.github/workflows/deploy-cloudflare-landing.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: lfs: true diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml index 9d04515cd..d053331e4 100644 --- a/.github/workflows/deploy-cloudflare-ssr.yml +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -24,7 +24,7 @@ jobs: VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: lfs: true diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index ceaf0d184..b24546746 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -19,7 +19,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Parse issue form uses: stefanbuck/github-issue-parser@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dc33bdde3..6f4016fa5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,7 +22,7 @@ jobs: node-version: [lts/*] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: lfs: true - name: Cache turbo build setup diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml index 7bdad2935..c09ad4733 100644 --- a/.github/workflows/publish-ota.yml +++ b/.github/workflows/publish-ota.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/similar-issues.yml b/.github/workflows/similar-issues.yml index 14bee2492..17512de29 100644 --- a/.github/workflows/similar-issues.yml +++ b/.github/workflows/similar-issues.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Prepare prompt variables id: prepare_input diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index e3ba74897..a9d6abf63 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 @@ -75,7 +75,7 @@ jobs: steps: - name: Checkout repository if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Resolve Mobile Release Config id: release_mode @@ -87,7 +87,7 @@ jobs: - name: Checkout repository if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main' - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/translator.yml b/.github/workflows/translator.yml index dea91e207..6c7975546 100644 --- a/.github/workflows/translator.yml +++ b/.github/workflows/translator.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 497d872a5c9687f0278b0b35844d09f953781227 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:19:59 +0800 Subject: [PATCH 10/17] build(deps): bump expo/expo-github-action from 8 to 9 (#5019) Bumps [expo/expo-github-action](https://github.com/expo/expo-github-action) from 8 to 9. - [Release notes](https://github.com/expo/expo-github-action/releases) - [Changelog](https://github.com/expo/expo-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/expo/expo-github-action/compare/v8...v9) --- updated-dependencies: - dependency-name: expo/expo-github-action dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-android.yml | 2 +- .github/workflows/build-ios-development.yml | 6 +++--- .github/workflows/build-ios.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index a451d6b0b..9ab5b3225 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -72,7 +72,7 @@ jobs: uses: android-actions/setup-android@v4 - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} diff --git a/.github/workflows/build-ios-development.yml b/.github/workflows/build-ios-development.yml index 4835cbee0..02d08c84a 100644 --- a/.github/workflows/build-ios-development.yml +++ b/.github/workflows/build-ios-development.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v7 - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -100,7 +100,7 @@ jobs: cache: "pnpm" - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -151,7 +151,7 @@ jobs: cache: "pnpm" - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index d3fea84b3..7b5a2a39a 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v7 - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -121,7 +121,7 @@ jobs: cache: "pnpm" - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@v9 with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} From 4ab06f42b4fbffc7d8774ecef724ba5e131d9931 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 10:41:54 +0800 Subject: [PATCH 11/17] fix(desktop): stabilize streaming tts scheduling --- .../src/modules/player/entry-tts.test.ts | 118 +++++++++++++++++- .../renderer/src/modules/player/entry-tts.ts | 7 +- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts b/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts index ee3dd2fef..8b84a7bcb 100644 --- a/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts +++ b/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts @@ -4,18 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { playEntryTts } from "./entry-tts" const { + audioElementMock, getEntryMock, getGeneralSettingsMock, + getAudioPlayerAtomValueMock, getReadabilityStatusMock, legacyTtsMock, mountMock, + setAudioPlayerAtomValueMock, toastFetchErrorMock, } = vi.hoisted(() => ({ + audioElementMock: { + addEventListener: vi.fn(), + currentTime: 0, + load: vi.fn(), + pause: vi.fn(), + play: vi.fn(() => Promise.resolve()), + removeEventListener: vi.fn(), + src: "", + srcObject: null as MediaStream | null, + }, getEntryMock: vi.fn(), getGeneralSettingsMock: vi.fn(), + getAudioPlayerAtomValueMock: vi.fn(() => ({})), getReadabilityStatusMock: vi.fn(), legacyTtsMock: vi.fn(), mountMock: vi.fn(), + setAudioPlayerAtomValueMock: vi.fn(), toastFetchErrorMock: vi.fn(), })) @@ -39,10 +54,11 @@ vi.mock("~/atoms/settings/general", () => ({ vi.mock("~/atoms/player", () => ({ AudioPlayer: { + audio: audioElementMock, mount: mountMock, }, - getAudioPlayerAtomValue: vi.fn(() => ({})), - setAudioPlayerAtomValue: vi.fn(), + getAudioPlayerAtomValue: getAudioPlayerAtomValueMock, + setAudioPlayerAtomValue: setAudioPlayerAtomValueMock, })) vi.mock("~/lib/api-client", () => ({ @@ -64,6 +80,51 @@ describe("entry tts", () => { const createObjectURLMock = vi.fn(() => "blob:tts-audio") const revokeObjectURLMock = vi.fn() + const createAudioBufferMock = (duration: number, sampleRate = 1000) => + ({ + copyFromChannel: (destination: Float32Array) => { + destination.fill(0) + }, + copyToChannel: vi.fn(), + duration, + length: Math.round(duration * sampleRate), + numberOfChannels: 1, + sampleRate, + }) as unknown as AudioBuffer + + const waitForExpectation = async (assertion: () => void) => { + const deadline = Date.now() + 1000 + let lastError: unknown + + while (Date.now() < deadline) { + try { + assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + + throw lastError + } + + const createStreamingResponse = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + }, + }), + { + headers: { + "content-type": "audio/mpeg", + }, + status: 200, + }, + ) + beforeEach(() => { vi.stubGlobal("fetch", fetchMock) vi.stubGlobal( @@ -84,6 +145,7 @@ describe("entry tts", () => { getGeneralSettingsMock.mockReturnValue({ voice: "en-US-AvaMultilingualNeural", }) + getAudioPlayerAtomValueMock.mockReturnValue({}) getReadabilityStatusMock.mockReturnValue({}) fetchMock.mockResolvedValue( new Response(new Blob(["audio"], { type: "audio/mpeg" }), { @@ -135,4 +197,56 @@ describe("entry tts", () => { it("uses the new default voice", () => { expect(defaultGeneralSettings.voice).toBe("en-US-AvaMultilingualNeural") }) + + it("schedules decoded stream chunks from the current audio context time", async () => { + const sourceStartTimes: number[] = [] + const audioContexts: Array<{ currentTime: number }> = [] + + class FakeAudioContext { + currentTime = 0 + + constructor() { + audioContexts.push(this) + } + + close = vi.fn(() => Promise.resolve()) + createBuffer = (_channels: number, frameCount: number, sampleRate: number) => + createAudioBufferMock(frameCount / sampleRate, sampleRate) + createBufferSource = () => ({ + buffer: null as AudioBuffer | null, + connect: vi.fn(), + start: vi.fn((time: number) => { + sourceStartTimes.push(time) + }), + }) + createMediaStreamDestination = () => ({ + stream: {} as MediaStream, + }) + decodeAudioData = vi.fn(async () => { + this.currentTime = 3 + return createAudioBufferMock(1) + }) + resume = vi.fn(() => Promise.resolve()) + suspend = vi.fn(() => Promise.resolve()) + } + + vi.stubGlobal("window", { + ...window, + AudioContext: FakeAudioContext, + clearInterval: globalThis.clearInterval, + setInterval: globalThis.setInterval, + setTimeout: (handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + audioContexts[0]!.currentTime = 10 + return globalThis.setTimeout(handler, timeout, ...args) + }, + }) + fetchMock.mockResolvedValue(createStreamingResponse()) + + await playEntryTts("entry-1", { toastTitle: "Play TTS" }) + + await waitForExpectation(() => { + expect(sourceStartTimes).toHaveLength(1) + }) + expect(sourceStartTimes[0]).toBeGreaterThanOrEqual(3) + }) }) diff --git a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts index bc65f0eaa..911be17b1 100644 --- a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts +++ b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts @@ -198,16 +198,17 @@ const createAudioContextStreamingHandle = ( const source = audioContext.createBufferSource() source.buffer = segmentBuffer source.connect(destination) - source.start(scheduledTime) + const segmentStartTime = Math.max(scheduledTime, audioContext.currentTime) + source.start(segmentStartTime) if (decodedDuration === 0) { - playbackStartTime = scheduledTime + playbackStartTime = segmentStartTime if (progressTimer === null) { progressTimer = window.setInterval(updateProgress, 250) } } - scheduledTime += frameCount / sampleRate + scheduledTime = segmentStartTime + frameCount / sampleRate decodedDuration = totalDuration const playerState = getAudioPlayerAtomValue() From 84f692ad354588fd3707190b2f09477f75657299 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 10:42:45 +0800 Subject: [PATCH 12/17] fix(desktop): keep reading mode content when translated Refs RSSNext/Folo#5023. --- .../components/layouts/ArticleLayout.tsx | 3 ++- .../components/layouts/content-selection.test.ts | 14 ++++++++++++++ .../components/layouts/content-selection.ts | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.test.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx index 1f64c588c..68c63b578 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx @@ -26,6 +26,7 @@ import { EntryRenderError } from "../entry-content/EntryRenderError" import { ReadabilityNotice } from "../entry-content/ReadabilityNotice" import { EntryAttachments } from "../EntryAttachments" import { EntryTitle } from "../EntryTitle" +import { getArticleRendererContent } from "./content-selection" import { MediaTranscript, TranscriptToggle, useTranscription } from "./shared" import { ArticleAudioPlayer } from "./shared/AudioPlayer" import type { EntryLayoutProps } from "./types" @@ -151,7 +152,7 @@ const Renderer: React.FC<{ style={stableRenderStyle} renderInlineStyle={readerRenderInlineStyle} > - {translation?.content || content} + {getArticleRendererContent({ content, translationContent: translation?.content })} ) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.test.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.test.ts new file mode 100644 index 000000000..0e55a4e0d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest" + +import { getArticleRendererContent } from "./content-selection" + +describe("getArticleRendererContent", () => { + test("keeps resolved readability content instead of overriding it with entry content translation", () => { + expect( + getArticleRendererContent({ + content: "
readability content
", + translationContent: "

entry content translation

", + }), + ).toBe("
readability content
") + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.ts new file mode 100644 index 000000000..e352d0961 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/content-selection.ts @@ -0,0 +1,6 @@ +export const getArticleRendererContent = ({ + content, +}: { + content?: Nullable + translationContent?: string +}) => content From 43fb8f23a7e4711156e229193de04b54d25e8b90 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 11:38:31 +0800 Subject: [PATCH 13/17] fix(desktop): avoid idle Spline AI indicator render --- .../app-layout/ai/AISplineButton.test.tsx | 184 ++++++++++++++++++ .../modules/app-layout/ai/AISplineButton.tsx | 4 +- 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.test.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.test.tsx new file mode 100644 index 000000000..b4d102eed --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.test.tsx @@ -0,0 +1,184 @@ +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest" + +import { AIIndicator } from "./AISplineButton" + +const { setAIPanelVisibilityMock, splineRenderMock, aiState } = vi.hoisted(() => ({ + setAIPanelVisibilityMock: vi.fn(), + splineRenderMock: vi.fn(), + aiState: { + isVisible: false, + showSplineButton: true, + }, +})) + +vi.mock("~/atoms/settings/ai", () => ({ + setAIPanelVisibility: setAIPanelVisibilityMock, + ["useAIPanelVisibility"]: () => aiState.isVisible, + ["useAISettingKey"]: (key: string) => { + if (key === "showSplineButton") { + return aiState.showSplineButton + } + return + }, +})) + +vi.mock("~/modules/ai-chat/components/3d-models/AISpline", async () => { + const React = await import("react") + + return { + AISpline: () => { + splineRenderMock() + return React.createElement("div", { "data-testid": "ai-spline" }) + }, + } +}) + +vi.mock("~/modules/ai-chat/components/layouts/AISmartSidebar", () => ({ + AISmartSidebar: () => null, +})) + +vi.mock("./AIChatFloatingPanel", () => ({ + AIChatFloatingPanel: () => null, +})) + +vi.mock("motion/react", async () => { + const React = await import("react") + + type MotionElementProps = React.HTMLAttributes & { + animate?: unknown + exit?: unknown + initial?: unknown + transition?: unknown + whileHover?: unknown + whileTap?: unknown + } + + const createMotionElement = + (tag: string) => + ({ + ref, + animate, + exit, + initial, + transition, + whileHover, + whileTap, + ...props + }: MotionElementProps & { ref?: React.RefObject }) => + React.createElement(tag, { ...props, ref }) + + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + m: new Proxy( + {}, + { + get: (_target, tag) => createMotionElement(String(tag)), + }, + ), + } +}) + +const renderComponent = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render() + }) + + return { container, root } +} + +describe("AIIndicator", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + aiState.isVisible = false + aiState.showSplineButton = true + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + root = null + container = null + vi.clearAllMocks() + }) + + test("renders the Folo bot icon without mounting the Spline scene", async () => { + ;({ container, root } = await renderComponent()) + + expect(container.querySelector("[data-testid='ai-spline']")).toBeNull() + expect(splineRenderMock).not.toHaveBeenCalled() + + const button = container.querySelector("button[title='Open AI Chat']") + expect(button).not.toBeNull() + expect(button?.querySelector("i")?.className).toContain("i-mgc-folo-bot-original") + expect(button?.querySelector("i")?.className).toContain("size-16") + + await act(async () => { + button?.click() + }) + + expect(setAIPanelVisibilityMock).toHaveBeenCalledWith(true) + }) + + test("keeps the Spline scene unmounted when the user interacts with the AI button", async () => { + ;({ container, root } = await renderComponent()) + + const button = container.querySelector("button[title='Open AI Chat']") + expect(button).not.toBeNull() + + await act(async () => { + button?.focus() + }) + await act(async () => { + button?.dispatchEvent(new PointerEvent("pointerenter", { bubbles: true })) + }) + + expect(container.querySelector("[data-testid='ai-spline']")).toBeNull() + expect(splineRenderMock).not.toHaveBeenCalled() + }) + + test("returns to the static idle button after the AI panel closes", async () => { + ;({ container, root } = await renderComponent()) + + const button = container.querySelector("button[title='Open AI Chat']") + await act(async () => { + button?.focus() + }) + expect(container.querySelector("[data-testid='ai-spline']")).toBeNull() + + aiState.isVisible = true + await act(async () => { + root?.render() + }) + expect(container.querySelector("button[title='Open AI Chat']")).toBeNull() + + aiState.isVisible = false + await act(async () => { + root?.render() + }) + + expect(container.querySelector("[data-testid='ai-spline']")).toBeNull() + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.tsx index eb1788870..c5b91ae24 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AISplineButton.tsx @@ -4,7 +4,6 @@ import { AnimatePresence, m } from "motion/react" import type { FC } from "react" import { setAIPanelVisibility, useAIPanelVisibility, useAISettingKey } from "~/atoms/settings/ai" -import { AISpline } from "~/modules/ai-chat/components/3d-models/AISpline" import { AISmartSidebar } from "~/modules/ai-chat/components/layouts/AISmartSidebar" import { AIChatFloatingPanel } from "./AIChatFloatingPanel" @@ -44,6 +43,7 @@ export const AIIndicator: FC = () => { className={clsx( "fixed bottom-8 right-8 z-40", "rounded-2xl", + "size-16", "hover:scale-105", "active:scale-95", "flex items-center justify-center", @@ -51,7 +51,7 @@ export const AIIndicator: FC = () => { )} title="Open AI Chat" > - + )} From 7cb35c4d7ccb9fcf11e463b03ef551c84a47e9fa Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:48:30 +0800 Subject: [PATCH 14/17] docs(mobile): prepare release metadata --- apps/mobile/changelog/next.md | 9 ++++----- apps/mobile/release-plan.json | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 85989e2f7..4165126b6 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,13 +1,12 @@ # What's New in vNEXT_VERSION -## Shiny new things - -## Improvements - ## No longer broken - Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) +- Fixed timeline refreshes returning to the top before new content is rendered +- Fixed mark-read state changes while the timeline is being reset +- Fixed social timeline scroll reset preservation across mobile list layouts ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributor @TonyRL for the nested code block fix diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 0a4e0fb6f..74637e871 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "store", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "0.5.0", + "channel": "production" } From 8305a5caf495c76a92603fa6d70b07c29793ddcf Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:48:43 +0800 Subject: [PATCH 15/17] docs(desktop): prepare release inputs --- apps/desktop/changelog/next.md | 13 ++++++++----- apps/desktop/release-plan.json | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index f9b6605c3..743f6b5f0 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,13 +1,16 @@ # What's new in vNEXT_VERSION -## Shiny new things - -## Improvements - ## No longer broken - Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) +- Fixed social timeline scroll reset on view changes +- Fixed timeline refreshes returning to the top before new content is rendered +- Fixed mark-read state changes while the timeline is being reset +- Fixed the recent reader spacer appearing when there are no recent entries +- Fixed reading mode content disappearing after translation updates +- Fixed streaming TTS scheduling stability +- Fixed the Spline AI indicator rendering while idle ## Thanks -Special thanks to volunteer contributors @ for their valuable contributions +Special thanks to volunteer contributor @TonyRL for the nested code block fix diff --git a/apps/desktop/release-plan.json b/apps/desktop/release-plan.json index da0a2c1c6..e340e3bfd 100644 --- a/apps/desktop/release-plan.json +++ b/apps/desktop/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "build", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "1.9.0", + "channel": "stable" } From 5d6b5ed40fa059bf42d41ac7340f45e61d42a9e5 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:58:32 +0800 Subject: [PATCH 16/17] release(mobile): release v0.5.5 --- apps/mobile/changelog/0.5.5.md | 12 ++++++++++++ apps/mobile/changelog/next.md | 11 +++++------ apps/mobile/ios/Folo/Info.plist | 4 ++-- apps/mobile/package.json | 2 +- apps/mobile/release-plan.json | 6 +++--- apps/mobile/release.json | 4 ++-- 6 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/changelog/0.5.5.md diff --git a/apps/mobile/changelog/0.5.5.md b/apps/mobile/changelog/0.5.5.md new file mode 100644 index 000000000..3c99ff2ec --- /dev/null +++ b/apps/mobile/changelog/0.5.5.md @@ -0,0 +1,12 @@ +# What's New in v0.5.5 + +## No longer broken + +- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) +- Fixed timeline refreshes returning to the top before new content is rendered +- Fixed mark-read state changes while the timeline is being reset +- Fixed social timeline scroll reset preservation across mobile list layouts + +## Thanks + +Special thanks to volunteer contributor @TonyRL for the nested code block fix diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 4165126b6..000f858e3 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,12 +1,11 @@ # What's New in vNEXT_VERSION +## Shiny new things + +## Improvements + ## No longer broken -- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) -- Fixed timeline refreshes returning to the top before new content is rendered -- Fixed mark-read state changes while the timeline is being reset -- Fixed social timeline scroll reset preservation across mobile list layouts - ## Thanks -Special thanks to volunteer contributor @TonyRL for the nested code block fix +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index 7afb39911..6680d0043 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.4 + 0.5.5 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 7 + 8 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6716a87cd..500564b5c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.4", + "version": "0.5.5", "private": true, "main": "src/main.tsx", "scripts": { diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 74637e871..0a4e0fb6f 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "0.5.0", - "channel": "production" + "mode": "store", + "runtimeVersion": null, + "channel": null } diff --git a/apps/mobile/release.json b/apps/mobile/release.json index 4bdf69bbb..e8eb0ec2e 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,6 +1,6 @@ { - "version": "0.5.4", + "version": "0.5.5", "mode": "ota", - "runtimeVersion": "0.5.3", + "runtimeVersion": "0.5.0", "channel": "production" } From aa91b25bffee0fed13f413476d64884821f6bd60 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:59:02 +0800 Subject: [PATCH 17/17] docs(mobile): restore desktop release inputs --- apps/desktop/changelog/next.md | 15 +++++---------- apps/desktop/release-plan.json | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 743f6b5f0..8f5eac449 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,16 +1,11 @@ # What's new in vNEXT_VERSION +## Shiny new things + +## Improvements + ## No longer broken -- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) -- Fixed social timeline scroll reset on view changes -- Fixed timeline refreshes returning to the top before new content is rendered -- Fixed mark-read state changes while the timeline is being reset -- Fixed the recent reader spacer appearing when there are no recent entries -- Fixed reading mode content disappearing after translation updates -- Fixed streaming TTS scheduling stability -- Fixed the Spline AI indicator rendering while idle - ## Thanks -Special thanks to volunteer contributor @TonyRL for the nested code block fix +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/release-plan.json b/apps/desktop/release-plan.json index e340e3bfd..da0a2c1c6 100644 --- a/apps/desktop/release-plan.json +++ b/apps/desktop/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "1.9.0", - "channel": "stable" + "mode": "build", + "runtimeVersion": null, + "channel": null }