release(mobile): Release v0.5.5 (#5030)

* fix(release): extend OTA sync trigger timeout

* docs(release): clarify mobile OTA runtime selection

* fix(timeline): scroll to top before refresh

* fix(timeline): guard mark read during scroll reset

* fix(desktop): hide empty recent reader spacer

* fix(mobile): preserve social timeline scroll reset

* fix(desktop): reset social timeline on view change

* fix: dedupe code blocks with nested line divs

fix: dedupe code blocks with nested line divs

* 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(desktop): stabilize streaming tts scheduling

* fix(desktop): keep reading mode content when translated

Refs RSSNext/Folo#5023.

* fix(desktop): avoid idle Spline AI indicator render

* docs(mobile): prepare release metadata

* docs(desktop): prepare release inputs

* release(mobile): release v0.5.5

* docs(mobile): restore desktop release inputs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tony <TonyRL@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
DIYgod 2026-06-22 15:07:05 +08:00 committed by GitHub
commit e4483e9052
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1315 additions and 103 deletions

View File

@ -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`

View File

@ -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")

View File

@ -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"

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: true

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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 = `<div><div><span>{</span></div></div><div><div><span> </span><span>"</span><span>$schema</span><span>"</span><span>:</span><span> </span><span>"./node_modules/wrangler/config-schema.json"</span><span>,</span></div></div><div><div><span> </span><span>"</span><span>pipelines</span><span>"</span><span>:</span><span> </span><span>[</span></div></div><div><div><span>}</span></div></div>`
const result = extractCodeFromHtml(htmlString)
expect(result).toMatchInlineSnapshot(`
"{
"$schema": "./node_modules/wrangler/config-schema.json",
"pipelines": [
}
"
`)
})
it("no <code />", () => {
const htmlString = `<span class="line"><span class="keyword">if</span> theme.<span class="property">twikoo</span>.<span class="property">enable</span> == <span class="literal">true</span></span><br><span class="line"> #tcomment</span><br><span class="line"> <span class="title function_">script</span>(src=<span class="string">'https://registry.npmmirror.com/twikoo/1.6.39/files/dist/twikoo.all.min.js'</span>)</span><br><span class="line"> script.</span><br><span class="line"> twikoo.<span class="title function_">init</span>({</span><br><span class="line"> <span class="attr">envId</span>: <span class="string">'#{theme.twikoo.envId}'</span>,</span><br><span class="line"> <span class="attr">el</span>: <span class="string">'#tcomment'</span>,</span><br><span class="line"> <span class="attr">region</span>: <span class="string">'#{theme.twikoo.region}'</span>,</span><br><span class="line"> <span class="attr">path</span>: <span class="string">'#{theme.twikoo.path}'</span>,</span><br><span class="line"> <span class="attr">onCommentLoaded</span>: <span class="keyword">function</span> (<span class="params"></span>) {</span><br><span class="line"> <span class="keyword">const</span> commentCountElement = <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">'.tk-comments-count'</span>);</span><br><span class="line"> <span class="keyword">const</span> targetElement = <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">'.waline-comment-count'</span>);</span><br><span class="line"> <span class="keyword">if</span> (commentCountElement) {</span><br><span class="line"> <span class="keyword">const</span> countSpan = commentCountElement.<span class="title function_">querySelector</span>(<span class="string">'span:first-child'</span>);</span><br><span class="line"> <span class="keyword">const</span> commentCount = <span class="built_in">parseInt</span>(countSpan.<span class="property">textContent</span>);</span><br><span class="line"> targetElement.<span class="property">textContent</span> = commentCount;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">'未找到评论数量元素'</span>);</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> })</span><br><span class="line"></span><br>`
const result = extractCodeFromHtml(htmlString)

View File

@ -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
}

View File

@ -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<HTMLElement> & {
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<HTMLElement | null> }) =>
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(<AIIndicator />)
})
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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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(<AIIndicator />)
})
expect(container.querySelector("button[title='Open AI Chat']")).toBeNull()
aiState.isVisible = false
await act(async () => {
root?.render(<AIIndicator />)
})
expect(container.querySelector("[data-testid='ai-spline']")).toBeNull()
})
})

View File

@ -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"
>
<AISpline />
<i className="i-mgc-folo-bot-original size-16 text-folo" aria-hidden />
</m.button>
)}
</AnimatePresence>

View File

@ -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<MasonryProps> = (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<MasonryProps> = (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<MasonryProps> = (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<MasonryProps> = (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 = () => {

View File

@ -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<EntryListProps> = (props) => {
const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props
@ -40,6 +41,10 @@ export const EntryColumnGrid: FC<EntryListProps> = (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<HTMLElement, Element>) => {
@ -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()

View File

@ -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<number>()
const [appliedResetScrollSignal, setAppliedResetScrollSignal] = useState<number>()
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<string>(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 : <FooterMarkItem view={view} fetchedTime={state.fetchedTime} />

View File

@ -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<Virtualizer<HTMLElement, Element> | undefined>
appliedResetScrollSignal?: number
onResetScrollSignalConsumed?: (signal: number) => void
resetScrollSignal?: number
suspendMarkRead?: boolean
}
const capacity = 3
@ -91,6 +96,9 @@ export const EntryList: FC<EntryListProps> = memo(
onRangeChange,
gap,
syncType,
appliedResetScrollSignal,
onResetScrollSignalConsumed,
resetScrollSignal,
}) => {
const scrollRef = useScrollViewElement()
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
@ -114,13 +122,21 @@ export const EntryList: FC<EntryListProps> = 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<HTMLElement, Element>) => {
@ -151,6 +167,25 @@ export const EntryList: FC<EntryListProps> = 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)
})

View File

@ -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)
})
})

View File

@ -0,0 +1,7 @@
export const shouldScrollTimelineToTopOnRefreshStateChange = ({
wasRefreshing,
isRefreshing,
}: {
wasRefreshing: boolean
isRefreshing: boolean
}) => !wasRefreshing && isRefreshing

View File

@ -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)
})
})

View File

@ -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

View File

@ -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 }) => (
<div data-testid="avatar-group">{children}</div>
),
}))
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 }) => <span data-testid="entry-user">{userId}</span>,
}))
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(<EntryReadHistory entryId="entry-1" />))
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(<EntryReadHistory entryId="entry-1" />))
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(<EntryReadHistory entryId="entry-1" />))
expect(container?.querySelector('[data-testid="avatar-group"]')).not.toBeNull()
expect(container?.querySelector('[data-testid="entry-user"]')?.textContent).toBe("reader-1")
})
})

View File

@ -37,13 +37,12 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
const LIMIT = getLimit(appGirdContainerWidth)
const placeholder = <div className="-mb-3 h-10" />
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 (
<div

View File

@ -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 })}
</ContentRenderer>
)
}

View File

@ -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: "<article>readability content</article>",
translationContent: "<p>entry content translation</p>",
}),
).toBe("<article>readability content</article>")
})
})

View File

@ -0,0 +1,6 @@
export const getArticleRendererContent = ({
content,
}: {
content?: Nullable<string>
translationContent?: string
}) => content

View File

@ -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<Uint8Array>({
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)
})
})

View File

@ -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()

View File

@ -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

View File

@ -33,7 +33,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.5.4</string>
<string>0.5.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>

View File

@ -1,6 +1,6 @@
{
"name": "@follow/mobile",
"version": "0.5.4",
"version": "0.5.5",
"private": true,
"main": "src/main.tsx",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"version": "0.5.4",
"version": "0.5.5",
"mode": "ota",
"runtimeVersion": "0.5.3",
"runtimeVersion": "0.5.0",
"channel": "production"
}

View File

@ -36,8 +36,14 @@ export const EntryListContentArticle = ({
entryIds,
active,
view,
onResetScrollSignalConsumed,
resetScrollSignal,
suspendMarkRead,
}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & {
ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | 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<FlashListRef<any>>(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}

View File

@ -35,11 +35,19 @@ export const EntryListContentPicture = ({
entryIds,
active,
view,
onResetScrollSignalConsumed,
resetScrollSignal,
suspendMarkRead,
...rest
}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit<
FlashListProps<string>,
"data" | "renderItem"
> & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => {
> & {
ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null>
onResetScrollSignalConsumed?: (signal: number) => void
resetScrollSignal?: number
suspendMarkRead?: boolean
}) => {
const ref = useRef<FlashListRef<any>>(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 = ({
<TimelineSelectorMasonryList
ref={ref}
isRefetching={isRefetching}
onResetScrollSignalConsumed={onResetScrollSignalConsumed}
resetScrollSignal={resetScrollSignal}
data={entryIds}
renderItem={renderItem}
keyExtractor={defaultKeyExtractor}

View File

@ -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"
@ -24,8 +25,14 @@ export const EntryListContentSocial = ({
entryIds,
active,
view,
onResetScrollSignalConsumed,
resetScrollSignal,
suspendMarkRead,
}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & {
ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | 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 (
<TimelineSelectorList
onRefresh={() => {}}
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}

View File

@ -28,11 +28,19 @@ export const EntryListContentVideo = ({
entryIds,
active,
view,
onResetScrollSignalConsumed,
resetScrollSignal,
suspendMarkRead,
...rest
}: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & Omit<
FlashListProps<string>,
"data" | "renderItem"
> & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => {
> & {
ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null>
onResetScrollSignalConsumed?: (signal: number) => void
resetScrollSignal?: number
suspendMarkRead?: boolean
}) => {
const ref = useRef<FlashListRef<any>>(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 = ({
<TimelineSelectorMasonryList
ref={ref}
isRefetching={isRefetching}
onResetScrollSignalConsumed={onResetScrollSignalConsumed}
resetScrollSignal={resetScrollSignal}
data={entryIds}
renderItem={renderItem}
keyExtractor={defaultKeyExtractor}

View File

@ -2,7 +2,7 @@ import { FeedViewType } from "@follow/constants"
import { useWhoami } from "@follow/store/user/hooks"
import type { FlashListRef } from "@shopify/flash-list"
import type { RefObject } from "react"
import { useEffect, useRef } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { withErrorBoundary } from "@/src/components/common/ErrorBoundary"
@ -14,9 +14,11 @@ import { EntryListContentPicture } from "@/src/modules/entry-list/EntryListConte
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen"
import { useEntries, useEntryListContext } from "../screen/atoms"
import { shouldSuspendMarkReadForScrollReset } from "../screen/scroll-reset"
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()
@ -37,6 +39,20 @@ type EntryListSelectorProps = {
function EntryListSelectorImpl({ entryIds, viewId, active = true }: EntryListSelectorProps) {
const ref = useRegisterNavigationScrollView<FlashListRef<any>>(active)
const [resetScrollSignal, setResetScrollSignal] = useState<number>()
const [appliedResetScrollSignal, setAppliedResetScrollSignal] = useState<number>()
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 <ContentComponent ref={ref} entryIds={entryIds} active={active} view={viewId} />
return (
<ContentComponent
ref={ref}
entryIds={entryIds}
active={active}
view={viewId}
onResetScrollSignalConsumed={handleResetScrollSignalConsumed}
resetScrollSignal={resetScrollSignal}
suspendMarkRead={isScrollResetPending}
/>
)
}
export const EntryListSelector = withErrorBoundary(

View File

@ -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<string>) => 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)

View File

@ -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)
})
})

View File

@ -0,0 +1,7 @@
export const shouldScrollEntryListToTopOnRefreshStateChange = ({
wasRefreshing,
isRefreshing,
}: {
wasRefreshing: boolean
isRefreshing: boolean
}) => !wasRefreshing && isRefreshing

View File

@ -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)
})
})

View File

@ -0,0 +1,9 @@
export const shouldCollectViewableItemsForMarkRead = ({
disabled,
isScrollingDown,
offset,
}: {
disabled?: boolean
isScrollingDown: boolean
offset: number
}) => !disabled && isScrollingDown && offset > 0

View File

@ -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<number | undefined>(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<FlashListProps<any>, "onRefresh"> & { ref?: React.Ref<FlashListRef<any> | 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<NativeScrollEvent>) => {
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<any>["onLayout"]
const onContentSizeChange = useTypeScriptHappyCallback(
(w, h) => {
props.onContentSizeChange?.(w, h)
scrollViewContentHeight.value = h
markScrollResetReady()
},
[scrollViewContentHeight],
[markScrollResetReady, props, scrollViewContentHeight],
) as FlashListProps<any>["onContentSizeChange"]
const onLoad = useTypeScriptHappyCallback(
(info) => {
props.onLoad?.(info)
markScrollResetReady()
},
[markScrollResetReady, props],
) as FlashListProps<any>["onLoad"]
const onCommitLayoutEffect = useTypeScriptHappyCallback(() => {
props.onCommitLayoutEffect?.()
markScrollResetReady()
}, [markScrollResetReady, props]) as FlashListProps<any>["onCommitLayoutEffect"]
if (props.data?.length === 0) {
return <EntryListEmpty />
@ -72,8 +146,6 @@ export const TimelineSelectorList = ({
automaticallyAdjustsScrollIndicatorInsets={false}
automaticallyAdjustContentInsets={false}
ref={ref}
onLayout={onLayout}
onContentSizeChange={onContentSizeChange}
refreshControl={
<RefreshControl
progressViewOffset={headerHeight}
@ -97,6 +169,10 @@ export const TimelineSelectorList = ({
paddingBottom: tabBarHeight,
}}
{...props}
onLayout={onLayout}
onLoad={onLoad}
onCommitLayoutEffect={onCommitLayoutEffect}
onContentSizeChange={onContentSizeChange}
onScroll={onScroll}
onEndReached={() => {
nextFrame(() => {
@ -109,9 +185,11 @@ export const TimelineSelectorList = ({
}
export const TimelineSelectorMasonryList = ({
ref,
ref: forwardedRef,
onRefresh,
isRefetching,
onResetScrollSignalConsumed,
resetScrollSignal,
...props
}: Props &
Omit<FlashListProps<any>, "onRefresh"> & {
@ -119,12 +197,30 @@ export const TimelineSelectorMasonryList = ({
}) => {
const { refetch: unreadRefetch } = usePrefetchUnread()
const { refetch: subscriptionRefetch } = usePrefetchSubscription()
const ref = useRef<FlashListRef<any>>(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<NativeScrollEvent>) => {
@ -135,6 +231,24 @@ export const TimelineSelectorMasonryList = ({
)
const tabBarHeight = useBottomTabBarHeight()
const onContentSizeChange = useTypeScriptHappyCallback(
(w, h) => {
props.onContentSizeChange?.(w, h)
markScrollResetReady()
},
[markScrollResetReady, props],
) as FlashListProps<any>["onContentSizeChange"]
const onLoad = useTypeScriptHappyCallback(
(info) => {
props.onLoad?.(info)
markScrollResetReady()
},
[markScrollResetReady, props],
) as FlashListProps<any>["onLoad"]
const onCommitLayoutEffect = useTypeScriptHappyCallback(() => {
props.onCommitLayoutEffect?.()
markScrollResetReady()
}, [markScrollResetReady, props]) as FlashListProps<any>["onCommitLayoutEffect"]
const systemFill = useColor("secondaryLabel")
@ -160,6 +274,9 @@ export const TimelineSelectorMasonryList = ({
/>
}
{...props}
onLoad={onLoad}
onCommitLayoutEffect={onCommitLayoutEffect}
onContentSizeChange={onContentSizeChange}
contentContainerStyle={[
{
paddingTop: headerHeight,

View File

@ -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)
})
})

View File

@ -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)

View File

@ -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
}