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:
commit
e4483e9052
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const shouldScrollTimelineToTopOnRefreshStateChange = ({
|
||||
wasRefreshing,
|
||||
isRefreshing,
|
||||
}: {
|
||||
wasRefreshing: boolean
|
||||
isRefreshing: boolean
|
||||
}) => !wasRefreshing && isRefreshing
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export const getArticleRendererContent = ({
|
||||
content,
|
||||
}: {
|
||||
content?: Nullable<string>
|
||||
translationContent?: string
|
||||
}) => content
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@follow/mobile",
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"main": "src/main.tsx",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"mode": "ota",
|
||||
"runtimeVersion": "0.5.3",
|
||||
"runtimeVersion": "0.5.0",
|
||||
"channel": "production"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const shouldScrollEntryListToTopOnRefreshStateChange = ({
|
||||
wasRefreshing,
|
||||
isRefreshing,
|
||||
}: {
|
||||
wasRefreshing: boolean
|
||||
isRefreshing: boolean
|
||||
}) => !wasRefreshing && isRefreshing
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export const shouldCollectViewableItemsForMarkRead = ({
|
||||
disabled,
|
||||
isScrollingDown,
|
||||
offset,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
isScrollingDown: boolean
|
||||
offset: number
|
||||
}) => !disabled && isScrollingDown && offset > 0
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue