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`
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/build-android.yml b/.github/workflows/build-android.yml
index f75778135..9ab5b3225 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
@@ -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-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..02d08c84a 100644
--- a/.github/workflows/build-ios-development.yml
+++ b/.github/workflows/build-ios-development.yml
@@ -40,10 +40,10 @@ jobs:
steps:
- name: 📦 Checkout code
- uses: actions/checkout@v6
+ 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 }}
@@ -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
@@ -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 }}
@@ -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
@@ -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 85ad6f586..7b5a2a39a 100644
--- a/.github/workflows/build-ios.yml
+++ b/.github/workflows/build-ios.yml
@@ -56,10 +56,10 @@ jobs:
steps:
- name: 📦 Checkout code
- uses: actions/checkout@v6
+ 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 }}
@@ -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
@@ -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 }}
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 689cf32d4..c09ad4733 100644
--- a/.github/workflows/publish-ota.yml
+++ b/.github/workflows/publish-ota.yml
@@ -35,10 +35,15 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
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"
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 }}
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",
`
+ 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/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"
>
-
+
)}
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 b6ee943a2..33f9dc1f0 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"
@@ -34,6 +34,11 @@ 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 {
+ shouldResetScrollOnTimelineIdentityChange,
+ shouldSuspendMarkReadForScrollReset,
+} from "./scroll-reset"
import { EntryRootStateContext } from "./store/EntryColumnContext"
function EntryColumnContent() {
@@ -52,8 +57,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)
@@ -112,16 +129,36 @@ 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(() => {
const wasRefreshing = wasRefreshingRef.current
wasRefreshingRef.current = isRefreshing
- if (!wasRefreshing || isRefreshing) return
+ if (
+ !shouldScrollTimelineToTopOnRefreshStateChange({
+ wasRefreshing,
+ isRefreshing,
+ })
+ ) {
+ return
+ }
scrollTimelineToTop()
}, [isRefreshing, scrollTimelineToTop])
@@ -147,6 +184,10 @@ function EntryColumnContent() {
)
const handleScroll = useCallback(() => {
+ if (isScrollResetPending) {
+ return
+ }
+
if (!isInteracted.current) {
isInteracted.current = true
}
@@ -154,7 +195,7 @@ function EntryColumnContent() {
if (latestRangeStartIndexRef.current !== null) {
flushScrollMarkRead(latestRangeStartIndexRef.current)
}
- }, [flushScrollMarkRead])
+ }, [flushScrollMarkRead, isScrollResetPending])
const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond()
const handleCombinedScroll = useCallback(
@@ -177,6 +218,10 @@ function EntryColumnContent() {
}
latestRangeStartIndexRef.current = e.startIndex
+ if (isScrollResetPending) {
+ return
+ }
+
if (scrollMarkReadAnchorIndexRef.current === null) {
scrollMarkReadAnchorIndexRef.current = e.startIndex
} else if (isInteracted.current) {
@@ -190,7 +235,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(() => {
@@ -249,6 +294,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/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/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..017a34bfb
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "vitest"
+
+import {
+ getInitialScrollOffset,
+ shouldApplyScrollResetSignal,
+ shouldResetScrollOnTimelineIdentityChange,
+ 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)
+ })
+
+ 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
new file mode 100644
index 000000000..28f9f333e
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/scroll-reset.ts
@@ -0,0 +1,36 @@
+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)
+
+export const shouldResetScrollOnTimelineIdentityChange = ({
+ enabled,
+ previousTimelineIdentity,
+ timelineIdentity,
+}: {
+ enabled: boolean
+ previousTimelineIdentity?: string
+ timelineIdentity: string
+}) =>
+ enabled && previousTimelineIdentity !== undefined && previousTimelineIdentity !== timelineIdentity
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 (
- {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
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()
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/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.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"
}
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 +76,7 @@ export const EntryListContentSocial = ({
)
const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({
- disabled: active === false || isFetching,
+ disabled: active === false || isFetching || suspendMarkRead,
refreshing: isFetching && !isFetchingNextPage,
})
@@ -86,12 +93,21 @@ 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 (
{}}
isRefetching={false}
+ onResetScrollSignalConsumed={onResetScrollSignalConsumed}
+ resetScrollSignal={contentResetScrollSignal}
data={Array.from({ length: 5 }).map((_, index) => `skeleton-${index}`)}
keyExtractor={(id) => id}
renderItem={EntryItemSkeleton}
@@ -107,6 +123,8 @@ export const EntryListContentSocial = ({
refetch()
}}
isRefetching={isRefetching}
+ onResetScrollSignalConsumed={onResetScrollSignalConsumed}
+ resetScrollSignal={contentResetScrollSignal}
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 = ({
{
const whoami = useWhoami()
@@ -37,6 +39,20 @@ type EntryListSelectorProps = {
function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSelectorProps) {
const ref = useRegisterNavigationScrollView>(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
@@ -64,13 +80,30 @@ 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
+ const wasRefreshingRef = useRef(isRefreshing)
+ useEffect(() => {
+ if (!active) return
+
+ const wasRefreshing = wasRefreshingRef.current
+ wasRefreshingRef.current = isRefreshing
+
+ if (
+ !shouldScrollEntryListToTopOnRefreshStateChange({
+ wasRefreshing,
+ isRefreshing,
+ })
+ ) {
+ return
+ }
+
+ requestScrollToTop()
+ }, [active, isRefreshing, requestScrollToTop])
- const { isReady } = useEntries({ viewId, active })
const hasResetAfterReadyRef = useRef(false)
useEffect(() => {
if (!active) return
@@ -81,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/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
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..2f58ca8bc
--- /dev/null
+++ b/apps/mobile/src/modules/screen/scroll-reset.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "vitest"
+
+import {
+ getResetScrollSignalForContent,
+ 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)
+ })
+
+ 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
new file mode 100644
index 000000000..4234e29eb
--- /dev/null
+++ b/apps/mobile/src/modules/screen/scroll-reset.ts
@@ -0,0 +1,23 @@
+type ScrollResetSignalState = {
+ resetSignal?: number
+ appliedResetSignal?: number
+}
+
+export const shouldApplyScrollResetSignal = ({
+ resetSignal,
+ appliedResetSignal,
+}: 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)
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
}