From 5e098f8d191a4edb11bd038fe55c2ca1aa30d1d0 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 12 May 2026 17:30:43 +0800 Subject: [PATCH 01/29] docs(mobile): prepare release metadata --- apps/desktop/changelog/next.md | 10 ++++------ apps/mobile/changelog/next.md | 10 +++------- apps/mobile/release-plan.json | 6 +++--- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 5e470c50af4..8f5eac449a4 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,12 +2,10 @@ ## Shiny new things -- Added an Eagle action to image context menus +## Improvements ## No longer broken -- Fixed tray menu refreshes losing the active tray instance -- Fixed desktop auth cookies missing max-age persistence -- Fixed unavailable AI summaries breaking summary state -- Fixed final entries and mark-read footers near the end of entry lists -- Delayed feed error indicators to avoid premature error states +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 000f858e3d2..46a48ce9a03 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,11 +1,7 @@ # What's New in vNEXT_VERSION -## Shiny new things - -## Improvements - ## No longer broken -## Thanks - -Special thanks to volunteer contributors @ for their valuable contributions +- Fixed sign out leaving mobile auth storage behind +- Fixed unavailable AI summaries breaking summary state +- Fixed final entries and mark-read footers near the end of entry lists diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 0a4e0fb6f6f..74637e87102 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "store", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "0.5.0", + "channel": "production" } From 3b336b926d5acf3a55bf9a4624e283cb35aa29ae Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 12 May 2026 17:31:29 +0800 Subject: [PATCH 02/29] release(mobile): release v0.5.2 --- apps/mobile/changelog/0.5.2.md | 7 +++++++ apps/mobile/changelog/next.md | 10 +++++++--- apps/mobile/ios/Folo/Info.plist | 4 ++-- apps/mobile/package.json | 2 +- apps/mobile/release-plan.json | 6 +++--- apps/mobile/release.json | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/changelog/0.5.2.md diff --git a/apps/mobile/changelog/0.5.2.md b/apps/mobile/changelog/0.5.2.md new file mode 100644 index 00000000000..711d01096af --- /dev/null +++ b/apps/mobile/changelog/0.5.2.md @@ -0,0 +1,7 @@ +# What's New in v0.5.2 + +## No longer broken + +- Fixed sign out leaving mobile auth storage behind +- Fixed unavailable AI summaries breaking summary state +- Fixed final entries and mark-read footers near the end of entry lists diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 46a48ce9a03..000f858e3d2 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,7 +1,11 @@ # What's New in vNEXT_VERSION +## Shiny new things + +## Improvements + ## No longer broken -- Fixed sign out leaving mobile auth storage behind -- Fixed unavailable AI summaries breaking summary state -- Fixed final entries and mark-read footers near the end of entry lists +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index adbe405d6de..f2d0c36e961 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.1 + 0.5.2 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 4 + 5 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index afdc09e0183..d3a3aac8389 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.1", + "version": "0.5.2", "private": true, "main": "src/main.tsx", "scripts": { diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 74637e87102..0a4e0fb6f6f 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "0.5.0", - "channel": "production" + "mode": "store", + "runtimeVersion": null, + "channel": null } diff --git a/apps/mobile/release.json b/apps/mobile/release.json index 1c949a3d234..2e6f3e8e2fa 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,5 +1,5 @@ { - "version": "0.5.1", + "version": "0.5.2", "mode": "ota", "runtimeVersion": "0.5.0", "channel": "production" From a577dee7dce4ca290ad6b47743d9060e11639b17 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 13 May 2026 11:27:23 +0800 Subject: [PATCH 03/29] fix(ota): include Expo config in manifests --- apps/ota/src/__tests__/manifest.test.ts | 35 ++++++++++ apps/ota/src/lib/manifest.ts | 92 ++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/apps/ota/src/__tests__/manifest.test.ts b/apps/ota/src/__tests__/manifest.test.ts index 2653162f377..e6307c9582a 100644 --- a/apps/ota/src/__tests__/manifest.test.ts +++ b/apps/ota/src/__tests__/manifest.test.ts @@ -85,6 +85,41 @@ describe("buildManifest", () => { expect(first.id).toBe(second.id) }) + + it("includes the embedded Expo config needed by expo-constants after an OTA launch", () => { + const manifest = buildManifest(createRelease(), { + origin: "https://ota.folo.is", + platform: "ios", + }) + + expect(manifest.extra).toMatchObject({ + expoClient: { + name: "Folo", + slug: "follow", + owner: "follow", + scheme: ["follow", "folo"], + version: "0.4.2", + runtimeVersion: "0.4.1", + updates: { + url: "https://ota.folo.is/manifest", + requestHeaders: { + "expo-channel-name": "production", + }, + }, + ios: { + bundleIdentifier: "is.follow", + }, + android: { + package: "is.follow", + }, + extra: { + eas: { + projectId: "a6335b14-fb84-45aa-ba80-6f6ab8926920", + }, + }, + }, + }) + }) }) describe("/manifest", () => { diff --git a/apps/ota/src/lib/manifest.ts b/apps/ota/src/lib/manifest.ts index 866201017fe..1b47972d781 100644 --- a/apps/ota/src/lib/manifest.ts +++ b/apps/ota/src/lib/manifest.ts @@ -5,6 +5,43 @@ import type { OtaPlatform, OtaPlatformPayload, OtaProjectedPlatforms, OtaRelease type PlatformPayload = OtaPlatformPayload type PlatformAsset = PlatformPayload["launchAsset"] | PlatformPayload["assets"][number] +type MobileExpoClientConfig = { + name: string + slug: string + owner: string + version: string + runtimeVersion: string + orientation: "portrait" + scheme: string[] + userInterfaceStyle: "automatic" + updates: { + url: string + requestHeaders: { + "expo-channel-name": string + } + codeSigningMetadata: { + keyid: string + alg: string + } + checkAutomatically: "NEVER" + } + ios: { + bundleIdentifier: string + supportsTablet: boolean + usesAppleSignIn: boolean + } + android: { + package: string + } + extra: { + eas: { + projectId: string + } + } +} + +const MOBILE_UPDATE_URL = "https://ota.folo.is/manifest" +const MOBILE_EAS_PROJECT_ID = "a6335b14-fb84-45aa-ba80-6f6ab8926920" export interface ManifestAsset { key: string @@ -26,6 +63,7 @@ export interface OtaManifest { } extra: { product: OtaRelease["product"] + expoClient?: MobileExpoClientConfig } } @@ -62,9 +100,7 @@ export function buildManifest( channel: release.channel, releaseVersion: release.releaseVersion, }, - extra: { - product: release.product, - }, + extra: buildManifestExtra(release), } } @@ -142,6 +178,56 @@ function toExpoAssetIdentity(path: string, contentType: string) { } } +function buildManifestExtra(release: OtaRelease): OtaManifest["extra"] { + const extra: OtaManifest["extra"] = { + product: release.product, + } + + if (release.product === "mobile") { + // Expo Updates hydrates Constants.expoConfig from extra.expoClient after an OTA launch. + extra.expoClient = buildMobileExpoClientConfig(release) + } + + return extra +} + +function buildMobileExpoClientConfig(release: Extract) { + return { + name: "Folo", + slug: "follow", + owner: "follow", + version: release.releaseVersion, + runtimeVersion: release.runtimeVersion, + orientation: "portrait", + scheme: ["follow", "folo"], + userInterfaceStyle: "automatic", + updates: { + url: MOBILE_UPDATE_URL, + requestHeaders: { + "expo-channel-name": release.channel, + }, + codeSigningMetadata: { + keyid: "main", + alg: "rsa-v1_5-sha256", + }, + checkAutomatically: "NEVER", + }, + ios: { + bundleIdentifier: "is.follow", + supportsTablet: true, + usesAppleSignIn: true, + }, + android: { + package: "is.follow", + }, + extra: { + eas: { + projectId: MOBILE_EAS_PROJECT_ID, + }, + }, + } satisfies MobileExpoClientConfig +} + function inferExtensionFromContentType(contentType: string) { switch (contentType) { case "application/javascript": { From be3d4c58db2a23a692c1134197c8c2ebeba7774a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 13 May 2026 12:01:36 +0800 Subject: [PATCH 04/29] docs(mobile): prepare release metadata --- apps/mobile/release-plan.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 0a4e0fb6f6f..74637e87102 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "store", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "0.5.0", + "channel": "production" } From 79d2684cf867c54abf548dcc8a83ce82b3251419 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 13 May 2026 12:02:11 +0800 Subject: [PATCH 05/29] release(mobile): release v0.5.3 --- apps/mobile/changelog/0.5.3.md | 11 +++++++++++ apps/mobile/ios/Folo/Info.plist | 4 ++-- apps/mobile/package.json | 2 +- apps/mobile/release-plan.json | 6 +++--- apps/mobile/release.json | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/changelog/0.5.3.md diff --git a/apps/mobile/changelog/0.5.3.md b/apps/mobile/changelog/0.5.3.md new file mode 100644 index 00000000000..c8e24f30d1f --- /dev/null +++ b/apps/mobile/changelog/0.5.3.md @@ -0,0 +1,11 @@ +# What's New in v0.5.3 + +## Shiny new things + +## Improvements + +## No longer broken + +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index f2d0c36e961..9c849eaa509 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.2 + 0.5.3 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 5 + 6 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d3a3aac8389..d439ab9e3f4 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.2", + "version": "0.5.3", "private": true, "main": "src/main.tsx", "scripts": { diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 74637e87102..0a4e0fb6f6f 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "0.5.0", - "channel": "production" + "mode": "store", + "runtimeVersion": null, + "channel": null } diff --git a/apps/mobile/release.json b/apps/mobile/release.json index 2e6f3e8e2fa..60ae1b255be 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,5 +1,5 @@ { - "version": "0.5.2", + "version": "0.5.3", "mode": "ota", "runtimeVersion": "0.5.0", "channel": "production" From 0d0fd9fd4943bb5610215cdf39932b298d138478 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 13 May 2026 12:05:03 +0800 Subject: [PATCH 06/29] chore(mobile): keep release pr mobile-only --- apps/desktop/changelog/1.8.0.md | 13 ------------- apps/desktop/package.json | 6 +++--- apps/desktop/release.json | 2 +- 3 files changed, 4 insertions(+), 17 deletions(-) delete mode 100644 apps/desktop/changelog/1.8.0.md diff --git a/apps/desktop/changelog/1.8.0.md b/apps/desktop/changelog/1.8.0.md deleted file mode 100644 index 210bba08f0f..00000000000 --- a/apps/desktop/changelog/1.8.0.md +++ /dev/null @@ -1,13 +0,0 @@ -# What's new in v1.8.0 - -## Shiny new things - -- Added an Eagle action to image context menus - -## No longer broken - -- Fixed tray menu refreshes losing the active tray instance -- Fixed desktop auth cookies missing max-age persistence -- Fixed unavailable AI summaries breaking summary state -- Fixed final entries and mark-read footers near the end of entry lists -- Delayed feed error indicators to avoid premature error states diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64bee6f9e7b..4aca618c112 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.8.0", + "version": "1.7.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -96,7 +96,7 @@ "vite-plugin-route-builder": "0.4.1", "vite-tsconfig-paths": "6.1.1" }, - "runtimeVersion": "1.8.0", + "runtimeVersion": "1.7.0", "productName": "Folo", - "mainHash": "a8b359bbef0d2c49abfae0524d5f13ac5aee26a4b60d0691cca9e9ccc3caddcf" + "mainHash": "524fdb3b17478aa5705bc86d0a1550b322c58e1605f1cd28acbf88dbdbb18629" } diff --git a/apps/desktop/release.json b/apps/desktop/release.json index baaa57b92c9..f758894c7ad 100644 --- a/apps/desktop/release.json +++ b/apps/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.8.0", + "version": "1.7.0", "mode": "build", "runtimeVersion": null, "channel": null From d3c1b8bfa16893051c50605cd8eff606a5f0f04f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 14:11:06 +0800 Subject: [PATCH 07/29] build(deps): bump cloudflare/wrangler-action from 3 to 4 (#5007) Bumps [cloudflare/wrangler-action](https://github.com/cloudflare/wrangler-action) from 3 to 4. - [Release notes](https://github.com/cloudflare/wrangler-action/releases) - [Changelog](https://github.com/cloudflare/wrangler-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/cloudflare/wrangler-action/compare/v3...v4) --- updated-dependencies: - dependency-name: cloudflare/wrangler-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-cloudflare-desktop.yml | 4 ++-- .github/workflows/deploy-cloudflare-landing.yml | 4 ++-- .github/workflows/deploy-cloudflare-ssr.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml index db86f8a0e77..22f55c045fc 100644 --- a/.github/workflows/deploy-cloudflare-desktop.yml +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -52,7 +52,7 @@ jobs: - name: Deploy desktop web to Cloudflare (dev) if: github.ref == 'refs/heads/dev' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -61,7 +61,7 @@ jobs: - name: Deploy desktop web to Cloudflare (prod) if: github.ref == 'refs/heads/main' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/deploy-cloudflare-landing.yml b/.github/workflows/deploy-cloudflare-landing.yml index 90077f14c5f..3d5dc23fa28 100644 --- a/.github/workflows/deploy-cloudflare-landing.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -72,7 +72,7 @@ jobs: - name: Deploy Landing to Cloudflare (dev) if: steps.target.outputs.environment == 'dev' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -81,7 +81,7 @@ jobs: - name: Deploy Landing to Cloudflare (prod) if: steps.target.outputs.environment == 'prod' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml index 78e2fdbffd1..a6c9abab049 100644 --- a/.github/workflows/deploy-cloudflare-ssr.yml +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -92,7 +92,7 @@ jobs: - name: Deploy SSR Worker to Cloudflare (dev) if: steps.target.outputs.environment == 'dev' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -101,7 +101,7 @@ jobs: - name: Deploy SSR Worker to Cloudflare (prod) if: steps.target.outputs.environment == 'prod' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} From a48123476dadc4460c9fe925b434a11349fddaa5 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 13:58:30 +0800 Subject: [PATCH 08/29] fix(desktop): improve Obsidian integration metadata --- .../main/src/ipc/services/integration.ts | 32 +++++++-- .../ipc/services/obsidian-frontmatter.test.ts | 66 +++++++++++++++++++ .../src/ipc/services/obsidian-frontmatter.ts | 60 +++++++++++++++++ .../modules/command/commands/integration.tsx | 23 ++----- .../custom-integration-manager.test.ts | 25 +++++++ .../integration/custom-integration-manager.ts | 17 +---- .../integration/entry-content-markdown.ts | 30 +++++++++ 7 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts create mode 100644 apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.ts create mode 100644 apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.test.ts create mode 100644 apps/desktop/layer/renderer/src/modules/integration/entry-content-markdown.ts diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index 5fe256aad55..d0164fe04f4 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -9,6 +9,8 @@ import path from "pathe" import { store } from "~/lib/store" import { logger } from "~/logger" +import { createObsidianFrontmatter } from "./obsidian-frontmatter" + // Taken from https://github.com/rollup/rollup/blob/4f69d33af3b2ec9320c43c9e6c65ea23a02bdde3/src/utils/sanitizeFileName.ts // https://datatracker.ietf.org/doc/html/rfc2396 // eslint-disable-next-line no-control-regex @@ -67,10 +69,22 @@ export class IntegrationService extends IpcService { publishedAt: string vaultPath: string description?: string + feedTitle?: string + feedUrl?: string }, ) { try { - const { url, title, content, author, publishedAt, vaultPath, description } = input + const { + url, + title, + content, + author, + publishedAt, + vaultPath, + description, + feedTitle, + feedUrl, + } = input const fileName = `${sanitizeFileName(title || publishedAt) .trim() @@ -83,13 +97,17 @@ export class IntegrationService extends IpcService { await fsp.mkdir(path.dirname(filePath), { recursive: true }) - const yamlEscape = (s: string) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + const frontmatter = createObsidianFrontmatter({ + url, + author, + publishedAt, + description, + tags: ["folo"], + feedTitle, + feedUrl, + }) - const markdown = `--- -url: ${yamlEscape(url)} -author: ${yamlEscape(author)} -publishedAt: ${yamlEscape(publishedAt)}${description ? `\ndescription: ${yamlEscape(description)}` : ""} ---- + const markdown = `${frontmatter} # ${title} diff --git a/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts b/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts new file mode 100644 index 00000000000..90f5bb19052 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts @@ -0,0 +1,66 @@ +import { load } from "js-yaml" +import { describe, expect, it } from "vitest" + +import { createObsidianFrontmatter } from "./obsidian-frontmatter" + +describe("createObsidianFrontmatter", () => { + it("serializes multiline descriptions as block scalars and includes integration metadata", () => { + const frontmatter = createObsidianFrontmatter({ + url: "https://www.techflowpost.com/zh-CN/article/31569", + author: "Deep Tide TechFlow", + publishedAt: "2026-05-14T04:20:44.405Z", + description: [ + "Author: David, Deep Tide TechFlow.", + "", + "On May 10, PJ Ace shared an AI short film.", + ].join("\n"), + tags: ["folo"], + feedTitle: "TechFlow", + feedUrl: "https://www.techflowpost.com/feed", + }) + + expect(frontmatter).toBe(`--- +url: "https://www.techflowpost.com/zh-CN/article/31569" +author: "Deep Tide TechFlow" +publishedAt: 2026-05-14T04:20:44 +description: |- + Author: David, Deep Tide TechFlow. + + On May 10, PJ Ace shared an AI short film. +tags: + - folo +feedTitle: "TechFlow" +feedUrl: "https://www.techflowpost.com/feed" +---`) + expect(load(frontmatter.replace(/^---\n/, "").replace(/\n---$/, ""))).toEqual({ + url: "https://www.techflowpost.com/zh-CN/article/31569", + author: "Deep Tide TechFlow", + publishedAt: new Date("2026-05-14T04:20:44.000Z"), + description: [ + "Author: David, Deep Tide TechFlow.", + "", + "On May 10, PJ Ace shared an AI short film.", + ].join("\n"), + tags: ["folo"], + feedTitle: "TechFlow", + feedUrl: "https://www.techflowpost.com/feed", + }) + }) + + it("omits optional empty feed metadata", () => { + expect( + createObsidianFrontmatter({ + url: "", + author: "", + publishedAt: "2026-05-14T04:20:44.405Z", + tags: ["folo"], + }), + ).toBe(`--- +url: "" +author: "" +publishedAt: 2026-05-14T04:20:44 +tags: + - folo +---`) + }) +}) diff --git a/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.ts b/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.ts new file mode 100644 index 00000000000..267c208476d --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.ts @@ -0,0 +1,60 @@ +type ObsidianFrontmatterValue = string | readonly string[] + +export interface ObsidianFrontmatter { + url: string + author: string + publishedAt: string + description?: string + tags: readonly string[] + feedTitle?: string + feedUrl?: string +} + +const normalizeLineEndings = (value: string) => value.replaceAll(/\r\n?/g, "\n") + +const isPlainYamlScalar = (value: string) => /^[\w-]+$/.test(value) + +const formatYamlString = (value: string) => { + const normalizedValue = normalizeLineEndings(value) + if (!normalizedValue.includes("\n")) { + return JSON.stringify(normalizedValue) + } + + const lines = normalizedValue.replaceAll(/\n+$/g, "").split("\n") + return ["|-", ...lines.map((line) => (line.length > 0 ? ` ${line}` : ""))].join("\n") +} + +const formatYamlArrayItem = (value: string) => { + const normalizedValue = normalizeLineEndings(value) + return isPlainYamlScalar(normalizedValue) ? normalizedValue : JSON.stringify(normalizedValue) +} + +const serializeYamlField = (key: string, value: ObsidianFrontmatterValue) => { + if (typeof value !== "string") { + return [`${key}:`, ...value.map((item) => ` - ${formatYamlArrayItem(item)}`)] + } + + const [firstLine, ...restLines] = formatYamlString(value).split("\n") + return [`${key}: ${firstLine}`, ...restLines] +} + +const serializeOptionalStringField = (key: string, value: string | undefined) => + value ? serializeYamlField(key, value) : [] + +const formatPublishedAt = (value: string) => value.replace(/\.\d{3}Z$/, "").replace(/Z$/, "") + +const serializeYamlRawField = (key: string, value: string) => [`${key}: ${value}`] + +export const createObsidianFrontmatter = (metadata: ObsidianFrontmatter) => { + const fields = [ + ...serializeYamlField("url", metadata.url), + ...serializeYamlField("author", metadata.author), + ...serializeYamlRawField("publishedAt", formatPublishedAt(metadata.publishedAt)), + ...serializeOptionalStringField("description", metadata.description), + ...serializeYamlField("tags", metadata.tags), + ...serializeOptionalStringField("feedTitle", metadata.feedTitle), + ...serializeOptionalStringField("feedUrl", metadata.feedUrl), + ] + + return ["---", ...fields, "---"].join("\n") +} diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 500e57acae2..68da40f0f50 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -21,13 +21,12 @@ import { useMemo } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability" import { getActionLanguage } from "~/atoms/settings/general" import { getIntegrationSettings, useIntegrationSettingKey } from "~/atoms/settings/integration" import { useRouteParams } from "~/hooks/biz/useRouteParams" import { ipcServices } from "~/lib/client" -import { parseHtml } from "~/lib/parse-html" import { CustomIntegrationManager } from "~/modules/integration/custom-integration-manager" +import { getEntryContentAsMarkdown } from "~/modules/integration/entry-content-markdown" import { useRegisterCommandEffect } from "../hooks/use-register-command" import { defineFollowCommand } from "../registry/command" @@ -254,19 +253,6 @@ const useRegisterInstapaperCommands = () => { ) } -const getEntryContentAsMarkdown = async (entry: EntryModel) => { - const isReadabilityReady = getReadabilityStatus()[entry.id] === ReadabilityStatus.SUCCESS - const content = (isReadabilityReady ? entry.readabilityContent || "" : entry.content) || "" - const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([ - import("mdast-util-to-markdown").then((m) => m.toMarkdown), - import("hast-util-to-mdast").then((m) => m.toMdast), - import("mdast-util-gfm-table").then((m) => m.gfmTableToMarkdown), - ]) - return toMarkdown(toMdast(parseHtml(content).hastTree), { - extensions: [gfmTableToMarkdown()], - }) -} - const useRegisterObsidianCommands = () => { const { t } = useTranslation() @@ -284,6 +270,8 @@ const useRegisterObsidianCommands = () => { publishedAt: string vaultPath: string description?: string + feedTitle?: string + feedUrl?: string }) => { return await ipcServices?.integration.saveToObsidian(data) }, @@ -315,6 +303,7 @@ const useRegisterObsidianCommands = () => { return } const markdownContent = await getEntryContentAsMarkdown(entry) + const feed = getFeedById(entry.feedId) tracker.integration({ type: "obsidian", event: "save", @@ -323,10 +312,12 @@ const useRegisterObsidianCommands = () => { url: entry.url || "", title: entry.title || "", content: markdownContent, - author: entry.author || getFeedById(entry.feedId)?.title || "", + author: entry.author || feed?.title || "", publishedAt: entry.publishedAt.toISOString() || "", vaultPath: obsidianVaultPath, description: getDescription(entry), + feedTitle: feed?.title || "", + feedUrl: feed?.url || "", }) }, }), diff --git a/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.test.ts b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.test.ts new file mode 100644 index 00000000000..de3b95d1bec --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.test.ts @@ -0,0 +1,25 @@ +import type { EntryModel } from "@follow/store/entry/types" +import { describe, expect, it } from "vitest" + +import { CustomIntegrationManager } from "./custom-integration-manager" + +describe("CustomIntegrationManager", () => { + it("serializes HTML line breaks without markdown hard-break backslashes", async () => { + const context = await CustomIntegrationManager.buildPlaceholderContext({ + id: "entry-id", + title: "MegaHouse G.E. Figure", + url: "https://example.com/detail", + content: + 'MegaHouse G.E. figure
¥990

APP 内打开', + description: "", + author: null, + publishedAt: new Date("2026-04-30T03:08:59.874Z"), + } as EntryModel) + + expect(context.contentMarkdown).toBe(`MegaHouse G.E. figure +¥990 +![](https://example.com/image.png) +[APP 内打开]() +`) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts index 86c54abf207..2d09081d7a0 100644 --- a/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts +++ b/apps/desktop/layer/renderer/src/modules/integration/custom-integration-manager.ts @@ -10,8 +10,8 @@ import { toast } from "sonner" import { getActionLanguage } from "~/atoms/settings/general" import { getIntegrationSettings } from "~/atoms/settings/integration" -import { parseHtml } from "~/lib/parse-html" +import { convertHtmlToIntegrationMarkdown } from "./entry-content-markdown" import { getFetchAdapter } from "./fetch-adapter" import { URLSchemeHandler } from "./url-scheme-handler" @@ -39,20 +39,7 @@ export class CustomIntegrationManager { */ private static async getEntryContentAsMarkdown(entry: EntryModel): Promise { const content = entry.content || "" - if (!content) return "" - - try { - const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([ - import("mdast-util-to-markdown").then((m) => m.toMarkdown), - import("hast-util-to-mdast").then((m) => m.toMdast), - import("mdast-util-gfm-table").then((m) => m.gfmTableToMarkdown), - ]) - return toMarkdown(toMdast(parseHtml(content).hastTree), { - extensions: [gfmTableToMarkdown()], - }) - } catch { - return content - } + return convertHtmlToIntegrationMarkdown(content) } /** diff --git a/apps/desktop/layer/renderer/src/modules/integration/entry-content-markdown.ts b/apps/desktop/layer/renderer/src/modules/integration/entry-content-markdown.ts new file mode 100644 index 00000000000..7380d14f922 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/integration/entry-content-markdown.ts @@ -0,0 +1,30 @@ +import type { EntryModel } from "@follow/store/entry/types" + +import { getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability" +import { parseHtml } from "~/lib/parse-html" + +export const convertHtmlToIntegrationMarkdown = async (content: string) => { + if (!content) return "" + + try { + const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([ + import("mdast-util-to-markdown").then((m) => m.toMarkdown), + import("hast-util-to-mdast").then((m) => m.toMdast), + import("mdast-util-gfm-table").then((m) => m.gfmTableToMarkdown), + ]) + return toMarkdown(toMdast(parseHtml(content).hastTree), { + extensions: [gfmTableToMarkdown()], + handlers: { + break: () => "\n", + }, + }) + } catch { + return content + } +} + +export const getEntryContentAsMarkdown = async (entry: EntryModel) => { + const isReadabilityReady = getReadabilityStatus()[entry.id] === ReadabilityStatus.SUCCESS + const content = (isReadabilityReady ? entry.readabilityContent || "" : entry.content) || "" + return convertHtmlToIntegrationMarkdown(content) +} From ed1cc36af67580f299552d5e13062eed82ced228 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 14:27:11 +0800 Subject: [PATCH 09/29] chore(release): update version to 1.8.0 in package.json and release.json --- apps/desktop/package.json | 6 +++--- apps/desktop/release.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4aca618c112..64bee6f9e7b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.7.0", + "version": "1.8.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -96,7 +96,7 @@ "vite-plugin-route-builder": "0.4.1", "vite-tsconfig-paths": "6.1.1" }, - "runtimeVersion": "1.7.0", + "runtimeVersion": "1.8.0", "productName": "Folo", - "mainHash": "524fdb3b17478aa5705bc86d0a1550b322c58e1605f1cd28acbf88dbdbb18629" + "mainHash": "a8b359bbef0d2c49abfae0524d5f13ac5aee26a4b60d0691cca9e9ccc3caddcf" } diff --git a/apps/desktop/release.json b/apps/desktop/release.json index f758894c7ad..baaa57b92c9 100644 --- a/apps/desktop/release.json +++ b/apps/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.7.0", + "version": "1.8.0", "mode": "build", "runtimeVersion": null, "channel": null From 9bfcfc805b10efb6b73e319ea2646b99a696f094 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 14:45:44 +0800 Subject: [PATCH 10/29] fix(desktop): sanitize Obsidian file path separators --- .../main/src/ipc/services/integration.test.ts | 74 +++++++++++++++++++ .../main/src/ipc/services/integration.ts | 2 +- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/layer/main/src/ipc/services/integration.test.ts diff --git a/apps/desktop/layer/main/src/ipc/services/integration.test.ts b/apps/desktop/layer/main/src/ipc/services/integration.test.ts new file mode 100644 index 00000000000..75af50f4cc8 --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/integration.test.ts @@ -0,0 +1,74 @@ +import fsp from "node:fs/promises" +import os from "node:os" + +import type { IpcContext } from "electron-ipc-decorator" +import path from "pathe" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { IntegrationService } from "./integration" + +vi.mock("electron", () => ({ + ipcMain: { + handle: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + }, +})) + +vi.mock("electron-ipc-decorator", () => ({ + IpcMethod: () => (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) => + descriptor, + IpcService: class {}, +})) + +vi.mock("~/lib/store", () => ({ + store: { + get: vi.fn(), + set: vi.fn(), + }, +})) + +vi.mock("~/logger", () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +describe("IntegrationService", () => { + let vaultPath: string | undefined + + afterEach(async () => { + if (!vaultPath) return + + await fsp.rm(vaultPath, { force: true, recursive: true }) + vaultPath = undefined + }) + + it("saves Obsidian titles with path separators as one markdown file", async () => { + vaultPath = await fsp.mkdtemp(path.join(os.tmpdir(), "folo-obsidian-")) + const service = new IntegrationService() + const context = {} as IpcContext + + await expect( + service.saveToObsidian(context, { + url: "https://example.com", + title: "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1/6比例手办", + content: "content", + author: "Folo", + publishedAt: "2026-05-14T04:20:44.405Z", + vaultPath, + }), + ).resolves.toEqual({ success: true }) + + await expect(fsp.readdir(vaultPath)).resolves.toEqual([ + "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1_6比例手办.md", + ]) + await expect( + fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")), + ).rejects.toThrow() + }) +}) diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index d0164fe04f4..a44a52aec00 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -14,7 +14,7 @@ import { createObsidianFrontmatter } from "./obsidian-frontmatter" // Taken from https://github.com/rollup/rollup/blob/4f69d33af3b2ec9320c43c9e6c65ea23a02bdde3/src/utils/sanitizeFileName.ts // https://datatracker.ietf.org/doc/html/rfc2396 // eslint-disable-next-line no-control-regex -const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g +const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F/\\]/g const DRIVE_LETTER_REGEX = /^[a-z]:/i function sanitizeFileName(name: string): string { From 444f8e05a533c05997a08900605e30347e42fd4a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 15:51:32 +0800 Subject: [PATCH 11/29] fix(desktop): prevent duplicate timeline page requests --- .../hooks/useEntriesQuery.test.tsx | 143 ++++++++++++++++++ .../internal/store/src/modules/entry/hooks.ts | 9 +- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx new file mode 100644 index 00000000000..0c1eb71ba86 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx @@ -0,0 +1,143 @@ +import { FeedViewType } from "@follow/constants" +import { useEntriesQuery } from "@follow/store/entry/hooks" +import { entrySyncServices } from "@follow/store/entry/store" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +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" + +type EntriesResponse = Awaited> + +const createEntriesResponse = (entryId: string, publishedAt: string) => + ({ + data: [ + { + entries: { + id: entryId, + publishedAt, + }, + }, + ], + }) as unknown as EntriesResponse + +describe("useEntriesQuery", () => { + let root: Root | null = null + let container: HTMLElement | null = null + let queryClient: QueryClient | 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 + ;( + globalThis.window as typeof globalThis.window & { + removeEventListener?: typeof globalThis.window.removeEventListener + } + ).removeEventListener ||= () => {} + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + queryClient?.clear() + container?.remove() + root = null + container = null + queryClient = null + vi.restoreAllMocks() + }) + + test("coalesces repeated next-page requests while one is still in flight", async () => { + const pendingPageResolvers: Array<(value: EntriesResponse) => void> = [] + const fetchEntriesSpy = vi + .spyOn(entrySyncServices, "fetchEntries") + .mockImplementation(async (props) => { + if (props.pageParam) { + return new Promise((resolve) => { + pendingPageResolvers.push(resolve) + }) + } + + return createEntriesResponse("entry-1", "2026-05-14T00:00:00.000Z") + }) + + let entriesQuery: ReturnType | undefined + const EntriesQueryConsumer = () => { + entriesQuery = useEntriesQuery({ + feedId: "collections", + view: FeedViewType.Articles, + limit: 1, + }) + + return null + } + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + await act(async () => { + root?.render( + + + , + ) + }) + + await act(async () => { + await vi.waitFor(() => { + expect(entriesQuery?.isSuccess).toBe(true) + }) + }) + + let firstFetch: Promise | undefined + let secondFetch: Promise | undefined + await act(async () => { + firstFetch = entriesQuery?.fetchNextPage() + secondFetch = entriesQuery?.fetchNextPage() + await Promise.resolve() + }) + + expect(fetchEntriesSpy).toHaveBeenCalledTimes(2) + expect(pendingPageResolvers).toHaveLength(1) + + await act(async () => { + pendingPageResolvers[0]?.(createEntriesResponse("entry-2", "2026-05-13T00:00:00.000Z")) + await Promise.allSettled([firstFetch, secondFetch]) + }) + + await act(async () => { + await vi.waitFor(() => { + expect(entriesQuery?.entriesIds).toEqual(["entry-1", "entry-2"]) + }) + }) + + await act(async () => { + firstFetch = entriesQuery?.fetchNextPage() + secondFetch = entriesQuery?.fetchNextPage() + await Promise.resolve() + }) + + expect(fetchEntriesSpy).toHaveBeenCalledTimes(3) + expect(pendingPageResolvers).toHaveLength(2) + + await act(async () => { + pendingPageResolvers[1]?.(createEntriesResponse("entry-3", "2026-05-12T00:00:00.000Z")) + await Promise.allSettled([firstFetch, secondFetch]) + }) + }) +}) diff --git a/packages/internal/store/src/modules/entry/hooks.ts b/packages/internal/store/src/modules/entry/hooks.ts index e3e32d4a264..ed5665e223a 100644 --- a/packages/internal/store/src/modules/entry/hooks.ts +++ b/packages/internal/store/src/modules/entry/hooks.ts @@ -123,6 +123,12 @@ export const useEntriesQuery = ( isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime, enabled: !!props, }) + const { fetchNextPage: queryFetchNextPage } = query + const fetchNextPage = useCallback( + (options?: Parameters[0]) => + queryFetchNextPage({ cancelRefetch: false, ...options }), + [queryFetchNextPage], + ) const entriesIds = useMemo(() => { if (!query.data || query.isLoading || query.isError) { @@ -140,10 +146,11 @@ export const useEntriesQuery = ( return useMemo(() => { return { ...query, + fetchNextPage, entriesIds, queryKey, } - }, [entriesIds, query, queryKey]) + }, [entriesIds, fetchNextPage, query, queryKey]) } export const usePrefetchEntryDetail = (entryId: string | undefined, isInbox?: boolean) => { From 431ab0e0bec134884d1d33779b907d7e1b0e1ea3 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 18:55:17 +0800 Subject: [PATCH 12/29] fix(desktop): preserve native image context menu --- .../main/src/ipc/services/integration.ts | 53 ++++++++++++------- apps/desktop/layer/main/src/lib/store.ts | 1 + apps/desktop/layer/main/src/manager/app.ts | 14 +++++ .../modules/command/commands/integration.tsx | 10 +++- .../renderer/hooks/useImageContextMenu.tsx | 47 ++-------------- locales/native/en.json | 1 + locales/native/fr-FR.json | 1 + locales/native/ja.json | 1 + locales/native/zh-CN.json | 1 + locales/native/zh-TW.json | 1 + 10 files changed, 65 insertions(+), 65 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index a44a52aec00..539599f82c9 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -32,6 +32,10 @@ interface SaveToEagleInput { mediaUrls: string[] } +interface SetEagleContextMenuEnabledInput { + enabled: boolean +} + interface LoginToQBittorrentInput { host: string username: string @@ -55,6 +59,29 @@ interface CustomFetchInput { timeout?: number } +export async function saveMediaToEagle(input: SaveToEagleInput): Promise { + try { + const res = await fetch("http://localhost:41595/api/item/addFromURLs", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + items: input.mediaUrls?.map((media) => ({ + url: media, + website: input.url, + headers: { + referer: input.url, + }, + })), + }), + }) + return await res.json() + } catch { + return null + } +} + export class IntegrationService extends IpcService { static override readonly groupName = "integration" @@ -125,26 +152,12 @@ ${content} @IpcMethod() async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise { - try { - const res = await fetch("http://localhost:41595/api/item/addFromURLs", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - items: input.mediaUrls?.map((media) => ({ - url: media, - website: input.url, - headers: { - referer: input.url, - }, - })), - }), - }) - return await res.json() - } catch { - return null - } + return saveMediaToEagle(input) + } + + @IpcMethod() + setEagleContextMenuEnabled(context: IpcContext, input: SetEagleContextMenuEnabledInput): void { + store.set("eagleContextMenuEnabled", input.enabled) } @IpcMethod() diff --git a/apps/desktop/layer/main/src/lib/store.ts b/apps/desktop/layer/main/src/lib/store.ts index defb6d2d4e3..de4710595cf 100644 --- a/apps/desktop/layer/main/src/lib/store.ts +++ b/apps/desktop/layer/main/src/lib/store.ts @@ -7,6 +7,7 @@ type StoreData = { "notifications-persistent-ids"?: string[] | null appearance?: "light" | "dark" | "system" | null cacheSizeLimit?: number | null + eagleContextMenuEnabled?: boolean | null minimizeToTray?: boolean | null proxy?: string | null qbittorrentSID?: string | null diff --git a/apps/desktop/layer/main/src/manager/app.ts b/apps/desktop/layer/main/src/manager/app.ts index c9691e3db7e..8a99787e759 100644 --- a/apps/desktop/layer/main/src/manager/app.ts +++ b/apps/desktop/layer/main/src/manager/app.ts @@ -10,6 +10,7 @@ import { WindowManager } from "~/manager/window" import { getIconPath } from "../helper" import { initializeIpcServices } from "../ipc" +import { saveMediaToEagle } from "../ipc/services/integration" import { checkAndCleanCodeCache, clearCacheCronJob } from "../lib/cleaner" import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../lib/cli-session-sync" import { t } from "../lib/i18n" @@ -213,6 +214,19 @@ class AppManagerStatic { prepend: (_defaultActions, params) => { return [ + { + label: t("contextMenu.saveMediaToEagle"), + visible: + params.mediaType === "image" && + params.srcURL !== "" && + !!store.get("eagleContextMenuEnabled"), + click: () => { + void saveMediaToEagle({ + url: params.pageURL || params.srcURL, + mediaUrls: [params.srcURL], + }) + }, + }, { label: t("contextMenu.openImageInBrowser"), visible: params.mediaType === "image", diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 68da40f0f50..5879259f9a1 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -17,7 +17,7 @@ import { tracker } from "@follow/tracker" import { useMutation, useQuery } from "@tanstack/react-query" import type { FetchError } from "ofetch" import { ofetch } from "ofetch" -import { useMemo } from "react" +import { useEffect, useMemo } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -72,6 +72,14 @@ const useRegisterEagleCommands = () => { const isEagleAvailable = enableEagle && (checkEagle.isLoading ? false : !!checkEagle.data) + useEffect(() => { + if (!IN_ELECTRON) return + + void ipcServices?.integration.setEagleContextMenuEnabled({ + enabled: isEagleAvailable, + }) + }, [isEagleAvailable]) + useRegisterCommandEffect( !isEagleAvailable ? [] diff --git a/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx b/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx index 94d2e0ee257..c6803c83441 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx @@ -1,54 +1,13 @@ -import { SimpleIconsEagle } from "@follow/components/ui/platform-icon/icons.js" -import { IN_ELECTRON } from "@follow/shared/constants" -import { useTranslation } from "react-i18next" -import { toast } from "sonner" import { useEventCallback } from "usehooks-ts" -import { MenuItemText, useShowContextMenu } from "~/atoms/context-menu" -import { useIntegrationSettingKey } from "~/atoms/settings/integration" -import { ipcServices } from "~/lib/client" - import type { MarkdownRenderActions } from "../../../components/ui/markdown/types" export const useImageContextMenu = ( entryUrl?: Nullable, ): NonNullable => { - const { t } = useTranslation() - const showContextMenu = useShowContextMenu() - const enableEagle = useIntegrationSettingKey("enableEagle") - - return useEventCallback(async (event, imageUrl) => { - if (!IN_ELECTRON || !enableEagle || !ipcServices?.integration?.saveToEagle) { - return - } - - event.preventDefault() - event.stopPropagation() - - await showContextMenu( - [ - new MenuItemText({ - label: t("entry_actions.save_image_to_eagle"), - icon: , - click: async () => { - const response = await ipcServices?.integration.saveToEagle({ - url: entryUrl || imageUrl, - mediaUrls: [imageUrl], - }) + void entryUrl - if (response?.status === "success") { - toast.success(t("entry_actions.saved_to_eagle"), { - duration: 3000, - }) - } else { - toast.error(t("entry_actions.failed_to_save_to_eagle"), { - duration: 3000, - }) - } - }, - }), - ], - event, - ) + return useEventCallback(() => { + // Keep Chromium's native image context menu. Electron appends custom media actions in main. }) } diff --git a/locales/native/en.json b/locales/native/en.json index 184442b5659..6951d302b8e 100644 --- a/locales/native/en.json +++ b/locales/native/en.json @@ -14,6 +14,7 @@ "contextMenu.saveImage": "Save Image", "contextMenu.saveImageAs": "Save Image As...", "contextMenu.saveLinkAs": "Save Link As...", + "contextMenu.saveMediaToEagle": "Save Media to Eagle", "contextMenu.saveVideo": "Save Video", "contextMenu.saveVideoAs": "Save Video As...", "contextMenu.searchWithGoogle": "Search with Google", diff --git a/locales/native/fr-FR.json b/locales/native/fr-FR.json index d50882a05af..1a18c3540f7 100644 --- a/locales/native/fr-FR.json +++ b/locales/native/fr-FR.json @@ -14,6 +14,7 @@ "contextMenu.saveImage": "Enregistrer l'image", "contextMenu.saveImageAs": "Enregistrer l'image sous...", "contextMenu.saveLinkAs": "Enregistrer le lien sous...", + "contextMenu.saveMediaToEagle": "Enregistrer le média dans Eagle", "contextMenu.saveVideo": "Enregistrer la vidéo", "contextMenu.saveVideoAs": "Enregistrer la vidéo sous...", "contextMenu.searchWithGoogle": "Rechercher avec Google", diff --git a/locales/native/ja.json b/locales/native/ja.json index 0ba70029826..33425789905 100644 --- a/locales/native/ja.json +++ b/locales/native/ja.json @@ -14,6 +14,7 @@ "contextMenu.saveImage": "画像を保存", "contextMenu.saveImageAs": "画像を名前を付けて保存...", "contextMenu.saveLinkAs": "名前をつけてリンクを保存...", + "contextMenu.saveMediaToEagle": "メディアを Eagle に保存", "contextMenu.saveVideo": "動画を保存", "contextMenu.saveVideoAs": "動画を名前を付けて保存...", "contextMenu.searchWithGoogle": "Google で検索", diff --git a/locales/native/zh-CN.json b/locales/native/zh-CN.json index c6baaeccd8e..fe8db11ee3e 100644 --- a/locales/native/zh-CN.json +++ b/locales/native/zh-CN.json @@ -14,6 +14,7 @@ "contextMenu.saveImage": "保存图片", "contextMenu.saveImageAs": "图片另存为...", "contextMenu.saveLinkAs": "链接另存为...", + "contextMenu.saveMediaToEagle": "保存到 Eagle", "contextMenu.saveVideo": "保存视频", "contextMenu.saveVideoAs": "视频另存为...", "contextMenu.searchWithGoogle": "在谷歌中搜索", diff --git a/locales/native/zh-TW.json b/locales/native/zh-TW.json index cd711afabb2..0e83952daf4 100644 --- a/locales/native/zh-TW.json +++ b/locales/native/zh-TW.json @@ -14,6 +14,7 @@ "contextMenu.saveImage": "儲存圖片", "contextMenu.saveImageAs": "圖片另存為...", "contextMenu.saveLinkAs": "連結另存為...", + "contextMenu.saveMediaToEagle": "儲存媒體至 Eagle", "contextMenu.saveVideo": "儲存影片", "contextMenu.saveVideoAs": "影片另存為...", "contextMenu.searchWithGoogle": "使用 Google 搜尋", From 8557b9f158ec25007ed6eefeabb102338aaa6a1e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 21 May 2026 14:31:56 +0800 Subject: [PATCH 13/29] fix(desktop): scroll timeline to top before refresh --- .../context/EntriesContext.test.tsx | 111 ++++++++++++++++++ .../entry-column/context/EntriesContext.tsx | 5 +- .../src/modules/entry-column/index.tsx | 28 ++++- .../entry-column/layouts/EntryListHeader.tsx | 6 +- 4 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx new file mode 100644 index 00000000000..76d496f8b47 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.test.tsx @@ -0,0 +1,111 @@ +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 type { useEntriesByView as useEntriesByViewType } from "../hooks/useEntriesByView" +import { EntriesProvider, useEntriesActions } from "./EntriesContext" + +const mocks = vi.hoisted(() => ({ + useEntriesByView: vi.fn(), + routeParams: vi.fn(() => ({ view: 0 })), +})) + +vi.mock("../hooks/useEntriesByView", () => ({ + useEntriesByView: mocks.useEntriesByView, +})) + +vi.mock("~/hooks/biz/useRouteParams", () => ({ + useRouteParams: mocks.routeParams, +})) + +const useEntriesByViewMock = mocks.useEntriesByView as unknown as { + mockReturnValue: (value: ReturnType) => void +} + +describe("EntriesProvider", () => { + 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.restoreAllMocks() + }) + + test("runs the registered reset callback before refetching entries", async () => { + const events: string[] = [] + const refetch = vi.fn(async () => { + events.push("refetch") + }) + + useEntriesByViewMock.mockReturnValue({ + type: "remote", + entriesIds: [], + groupedCounts: undefined, + hasNextPage: false, + isFetchingNextPage: false, + isFetching: false, + isLoading: false, + error: null, + refetch, + fetchNextPage: vi.fn(), + hasNext: false, + isRefetching: false, + isReady: true, + }) + + let refetchEntries: (() => void | Promise) | undefined + const reset = vi.fn(() => { + events.push("reset") + }) + + const Consumer = () => { + const actions = useEntriesActions() + + React.useEffect(() => { + actions.setOnReset(reset) + refetchEntries = actions.refetch + + return () => { + actions.setOnReset(null) + } + }, [actions]) + + return null + } + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + + , + ) + }) + + await act(async () => { + await refetchEntries?.() + }) + + expect(events).toEqual(["reset", "refetch"]) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx index 3433b5ed3a3..5d26a0ccd4b 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/context/EntriesContext.tsx @@ -78,7 +78,10 @@ export const EntriesProvider: React.FC = ({ children }) // Stable actions that reference latest refs const fetchNextPageStable = useCallback(() => fetchNextPageRef.current?.(), []) - const refetchStable = useCallback(() => refetchRef.current?.(), []) + const refetchStable = useCallback(() => { + onResetRef.current?.() + return refetchRef.current?.() + }, []) const setOnResetStable = useCallback((cb: (() => void) | null) => { onResetRef.current = cb }, []) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 73dac23a8ce..137aea92fc9 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -37,17 +37,25 @@ import { EntryRootStateContext } from "./store/EntryColumnContext" function EntryColumnContent() { const listRef = useRef>(undefined) + const scrollAreaRef = useRef(null) const { t } = useTranslation() const state = useEntriesState() const actions = useEntriesActions() + const scrollTimelineToTop = useCallback(() => { + listRef.current?.scrollToOffset(0) + + const scrollArea = scrollAreaRef.current + if (!scrollArea) return + + scrollArea.scrollTop = 0 + scrollArea.scrollLeft = 0 + }, []) // Register reset handler to keep scroll behavior when data resets useEffect(() => { - actions.setOnReset(() => { - listRef.current?.scrollToIndex(0) - }) + actions.setOnReset(scrollTimelineToTop) return () => actions.setOnReset(null) - }, [actions]) + }, [actions, scrollTimelineToTop]) const { entriesIds, groupedCounts } = state useSnapEntryIdList(entriesIds) @@ -169,9 +177,17 @@ function EntryColumnContent() { !state.error && (!feed || feed?.type === "feed") && } - + - + {entriesIds.length === 0 ? ( state.isLoading ? ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx index 33e4b4ace1d..50faa04d11c 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx @@ -43,7 +43,8 @@ import { SwitchToMasonryButton } from "./buttons/SwitchToMasonryButton" export const EntryListHeader: FC<{ refetch: () => void isRefreshing: boolean -}> = ({ refetch, isRefreshing }) => { + onBeforeRefresh?: () => void +}> = ({ refetch, isRefreshing, onBeforeRefresh }) => { const routerParams = useRouteParams() const { t } = useTranslation() @@ -188,7 +189,8 @@ export const EntryListHeader: FC<{ { - refreshFeed() + onBeforeRefresh?.() + void refreshFeed() }} > From 27b30e0d476481baea959439869b2d05d7a17001 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 22 May 2026 14:15:49 +0800 Subject: [PATCH 14/29] fix(desktop): pause scroll read on timeline changes --- .../useScrollMarkReadGracePeriod.test.tsx | 68 +++++++++++++++++++ .../src/modules/entry-column/index.tsx | 50 +++++++++++--- .../hooks/src/useScrollMarkReadGracePeriod.ts | 54 +++++++++++---- 3 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadGracePeriod.test.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadGracePeriod.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadGracePeriod.test.tsx new file mode 100644 index 00000000000..7ba72f498fa --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useScrollMarkReadGracePeriod.test.tsx @@ -0,0 +1,68 @@ +import { useScrollMarkReadGracePeriod } from "@follow/hooks" +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" + +describe("useScrollMarkReadGracePeriod", () => { + 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() + }) + } + + vi.useRealTimers() + container?.remove() + root = null + container = null + }) + + test("pauses briefly when the pause key changes", async () => { + vi.useFakeTimers() + const values: boolean[] = [] + + const Consumer = ({ pauseKey }: { pauseKey: string }) => { + const paused = useScrollMarkReadGracePeriod(false, 1000, pauseKey) + + React.useEffect(() => { + values.push(paused) + }, [paused]) + + return null + } + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + expect(values.at(-1)).toBe(false) + + await act(async () => { + root?.render() + }) + + expect(values.at(-1)).toBe(true) + + await act(async () => { + vi.advanceTimersByTime(1000) + }) + + expect(values.at(-1)).toBe(false) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 137aea92fc9..a1a726137ff 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -8,7 +8,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, useMemo, useRef } from "react" +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingKey } from "~/atoms/settings/general" @@ -41,16 +41,30 @@ function EntryColumnContent() { const { t } = useTranslation() const state = useEntriesState() + const isInteracted = useRef(false) + const rangeQueueRef = useRef([]) + const resetScrollInteractionState = useCallback(() => { + isInteracted.current = false + rangeQueueRef.current = [] + }, []) + const actions = useEntriesActions() const scrollTimelineToTop = useCallback(() => { - listRef.current?.scrollToOffset(0) + resetScrollInteractionState() - const scrollArea = scrollAreaRef.current - if (!scrollArea) return + const runScrollToTop = () => { + listRef.current?.scrollToOffset(0) - scrollArea.scrollTop = 0 - scrollArea.scrollLeft = 0 - }, []) + const scrollArea = scrollAreaRef.current + if (!scrollArea) return + + scrollArea.scrollTop = 0 + scrollArea.scrollLeft = 0 + } + + runScrollToTop() + globalThis.requestAnimationFrame?.(runScrollToTop) + }, [resetScrollInteractionState]) // Register reset handler to keep scroll behavior when data resets useEffect(() => { actions.setOnReset(scrollTimelineToTop) @@ -76,6 +90,7 @@ function EntryColumnContent() { const title = useFeedHeaderTitle() useTitle(title) const isLoggedIn = useIsLoggedIn() + const timelineIdentity = `${view}:${routeFeedId ?? ""}` useEffect(() => { if (!activeEntryId) return @@ -87,9 +102,25 @@ function EntryColumnContent() { unreadSyncService.markEntryAsRead(activeEntryId) }, [activeEntryId, entry?.feedId, isCollection, isPendingEntry, isLoggedIn]) - const isInteracted = useRef(false) const isRefreshing = state.isFetching && !state.isFetchingNextPage - const pauseScrollMarkRead = useScrollMarkReadGracePeriod(isRefreshing) + const pauseScrollMarkRead = useScrollMarkReadGracePeriod( + isRefreshing, + undefined, + timelineIdentity, + ) + + useLayoutEffect(() => { + resetScrollInteractionState() + }, [resetScrollInteractionState, timelineIdentity]) + + const wasRefreshingRef = useRef(isRefreshing) + useEffect(() => { + const wasRefreshing = wasRefreshingRef.current + wasRefreshingRef.current = isRefreshing + + if (!wasRefreshing || isRefreshing) return + scrollTimelineToTop() + }, [isRefreshing, scrollTimelineToTop]) const { handleRenderMarkRead, handleScrollMarkRead } = useEntryMarkReadHandler(entriesIds, { pauseScrollMarkRead, @@ -125,7 +156,6 @@ function EntryColumnContent() { const navigate = useNavigateEntry() - const rangeQueueRef = useRef([]) const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom) const showAiTimelineLoading = aiTimelineEnabled && state.isLoading && !state.isFetchingNextPage const renderAsRead = useGeneralSettingKey("renderMarkUnread") diff --git a/packages/internal/hooks/src/useScrollMarkReadGracePeriod.ts b/packages/internal/hooks/src/useScrollMarkReadGracePeriod.ts index 63728b34e11..504bff3a081 100644 --- a/packages/internal/hooks/src/useScrollMarkReadGracePeriod.ts +++ b/packages/internal/hooks/src/useScrollMarkReadGracePeriod.ts @@ -1,23 +1,43 @@ -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" const DEFAULT_GRACE_PERIOD_MS = 1000 export const useScrollMarkReadGracePeriod = ( refreshing: boolean, gracePeriodMs = DEFAULT_GRACE_PERIOD_MS, + pauseKey?: unknown, ) => { const timeoutRef = useRef | null>(null) const wasRefreshingRef = useRef(refreshing) + const refreshingRef = useRef(refreshing) + const pauseKeyRef = useRef(pauseKey) const [isScrollMarkReadPaused, setIsScrollMarkReadPaused] = useState(refreshing) - useEffect(() => { - const wasRefreshing = wasRefreshingRef.current - wasRefreshingRef.current = refreshing + refreshingRef.current = refreshing + const clearPauseTimeout = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } + }, []) + + const pauseForGracePeriod = useCallback(() => { + clearPauseTimeout() + setIsScrollMarkReadPaused(true) + timeoutRef.current = setTimeout(() => { + if (!refreshingRef.current) { + setIsScrollMarkReadPaused(false) + } + timeoutRef.current = null + }, gracePeriodMs) + }, [clearPauseTimeout, gracePeriodMs]) + + useEffect(() => { + const wasRefreshing = wasRefreshingRef.current + wasRefreshingRef.current = refreshing + + clearPauseTimeout() if (refreshing) { setIsScrollMarkReadPaused(true) @@ -29,19 +49,25 @@ export const useScrollMarkReadGracePeriod = ( return } - setIsScrollMarkReadPaused(true) - timeoutRef.current = setTimeout(() => { - setIsScrollMarkReadPaused(false) - timeoutRef.current = null - }, gracePeriodMs) + pauseForGracePeriod() return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null - } + clearPauseTimeout() + } + }, [clearPauseTimeout, pauseForGracePeriod, refreshing]) + + useEffect(() => { + if (Object.is(pauseKeyRef.current, pauseKey)) { + return + } + + pauseKeyRef.current = pauseKey + pauseForGracePeriod() + + return () => { + clearPauseTimeout() } - }, [gracePeriodMs, refreshing]) + }, [clearPauseTimeout, pauseForGracePeriod, pauseKey]) return isScrollMarkReadPaused } From 07cbf35de77f1e06b21e4f73fb79f00ccb58d03e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 11:55:09 +0800 Subject: [PATCH 15/29] fix: batch scroll mark-read requests --- .../hooks/useEntryMarkReadHandler.tsx | 4 +- .../src/modules/entry-column/index.tsx | 58 +++++--- apps/mobile/src/modules/entry-list/hooks.ts | 21 ++- .../shared/src/scroll-mark-read.test.ts | 21 +++ .../internal/shared/src/scroll-mark-read.ts | 29 ++++ .../store/src/modules/unread/store.test.ts | 132 ++++++++++++++++++ .../store/src/modules/unread/store.ts | 118 ++++++++++++++++ 7 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 packages/internal/store/src/modules/unread/store.test.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx index 3edfb765044..727578fd561 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -84,8 +84,6 @@ export function batchMarkRead(ids: string[]) { } if (batchLikeIds.length > 0) { - for (const id of batchLikeIds) { - unreadSyncService.markEntryAsRead(id) - } + void unreadSyncService.queueEntriesAsRead(batchLikeIds) } } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index a1a726137ff..20cdd3ea9b8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -1,5 +1,6 @@ import { FeedViewType, getView } from "@follow/constants" import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks" +import { getScrollMarkReadRange } from "@follow/shared/scroll-mark-read" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" @@ -42,10 +43,12 @@ function EntryColumnContent() { const state = useEntriesState() const isInteracted = useRef(false) - const rangeQueueRef = useRef([]) + const scrollMarkReadEndIndexRef = useRef(null) + const latestRangeStartIndexRef = useRef(null) const resetScrollInteractionState = useCallback(() => { isInteracted.current = false - rangeQueueRef.current = [] + scrollMarkReadEndIndexRef.current = null + latestRangeStartIndexRef.current = null }, []) const actions = useEntriesActions() @@ -126,24 +129,37 @@ function EntryColumnContent() { pauseScrollMarkRead, }) + const flushScrollMarkRead = useCallback( + (currentStartIndex: number) => { + if (!routeFeedId) return + + const range = getScrollMarkReadRange({ + previousEndIndex: scrollMarkReadEndIndexRef.current, + currentStartIndex, + }) + + if (range) { + handleScrollMarkRead?.(range as Range, isInteracted.current) + scrollMarkReadEndIndexRef.current = currentStartIndex + return + } + + if (scrollMarkReadEndIndexRef.current === null) { + scrollMarkReadEndIndexRef.current = currentStartIndex + } + }, + [handleScrollMarkRead, routeFeedId], + ) + const handleScroll = useCallback(() => { if (!isInteracted.current) { isInteracted.current = true } - if (!routeFeedId) return - - const [first, second] = rangeQueueRef.current - if (first && second && second.startIndex - first.startIndex > 0) { - handleScrollMarkRead?.( - { - startIndex: first.startIndex, - endIndex: second.startIndex, - } as Range, - isInteracted.current, - ) + if (latestRangeStartIndexRef.current !== null) { + flushScrollMarkRead(latestRangeStartIndexRef.current) } - }, [handleScrollMarkRead, routeFeedId]) + }, [flushScrollMarkRead]) const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond() const handleCombinedScroll = useCallback( @@ -161,13 +177,15 @@ function EntryColumnContent() { const renderAsRead = useGeneralSettingKey("renderMarkUnread") const handleRangeChange = useCallback( (e: Range) => { - const [_, second] = rangeQueueRef.current - if (second?.startIndex === e.startIndex) { + if (latestRangeStartIndexRef.current === e.startIndex) { return } - rangeQueueRef.current.push(e) - if (rangeQueueRef.current.length > 2) { - rangeQueueRef.current.shift() + + latestRangeStartIndexRef.current = e.startIndex + if (scrollMarkReadEndIndexRef.current === null) { + scrollMarkReadEndIndexRef.current = e.startIndex + } else if (isInteracted.current) { + flushScrollMarkRead(e.startIndex) } if (!renderAsRead) return @@ -177,7 +195,7 @@ function EntryColumnContent() { // For gird, render as mark read logic handleRenderMarkRead?.(e, isInteracted.current) }, - [handleRenderMarkRead, renderAsRead, view], + [flushScrollMarkRead, handleRenderMarkRead, renderAsRead, view], ) const fetchNextPage = useCallback(() => { diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 89ab667e27b..a6f5851a844 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -78,23 +78,20 @@ export function useOnViewableItemsChanged({ if (disabled) return if (isLoggedIn && markAsReadWhenScrolling && !pauseScrollMarkRead && lastRemovedItems) { - lastRemovedItems.forEach((item) => { - unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => { - setLastRemovedItems((prev) => { - if (prev) { - return prev.filter((prevItem) => prevItem.key !== item.key) - } else { - return null - } - }) + const entryIds = lastRemovedItems.map((item) => stableIdExtractor(item)) + const entryIdSet = new Set(entryIds) + void unreadSyncService.queueEntriesAsRead(entryIds).then(() => { + setLastRemovedItems((prev) => { + if (!prev) return null + return prev.filter((prevItem) => !entryIdSet.has(stableIdExtractor(prevItem))) }) }) } if (isLoggedIn && markAsReadWhenRendering && lastViewableItems) { - lastViewableItems.forEach((item) => { - unreadSyncService.markEntryAsRead(stableIdExtractor(item)) - }) + void unreadSyncService.queueEntriesAsRead( + lastViewableItems.map((item) => stableIdExtractor(item)), + ) } }, [ disabled, diff --git a/packages/internal/shared/src/scroll-mark-read.test.ts b/packages/internal/shared/src/scroll-mark-read.test.ts index 4e35573f33b..a6304fec3eb 100644 --- a/packages/internal/shared/src/scroll-mark-read.test.ts +++ b/packages/internal/shared/src/scroll-mark-read.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import { getScrollMarkReadEndPadding, getScrollMarkReadExitedSliceEnd, + getScrollMarkReadRange, MIN_SCROLL_MARK_READ_END_PADDING, SCROLL_MARK_READ_END_INDICATOR_HEIGHT, shouldRenderScrollMarkReadEndSpacer, @@ -43,3 +44,23 @@ describe("scroll mark-read exited slice", () => { ).toBeNull() }) }) + +describe("scroll mark-read range", () => { + it("marks the full skipped range when scrolling jumps over intermediate entries", () => { + expect( + getScrollMarkReadRange({ + previousEndIndex: 4, + currentStartIndex: 12, + }), + ).toEqual({ startIndex: 4, endIndex: 12 }) + }) + + it("does not mark entries while scrolling upward or staying within the previous high-water mark", () => { + expect( + getScrollMarkReadRange({ + previousEndIndex: 12, + currentStartIndex: 8, + }), + ).toBeNull() + }) +}) diff --git a/packages/internal/shared/src/scroll-mark-read.ts b/packages/internal/shared/src/scroll-mark-read.ts index 2fb31dbd3b2..9b934209d77 100644 --- a/packages/internal/shared/src/scroll-mark-read.ts +++ b/packages/internal/shared/src/scroll-mark-read.ts @@ -17,6 +17,35 @@ export const shouldRenderScrollMarkReadEndSpacer = ({ hasNextPage: boolean }) => entryCount > 0 && !hasNextPage +export const getScrollMarkReadRange = ({ + previousEndIndex, + currentStartIndex, +}: { + previousEndIndex: number | null | undefined + currentStartIndex: number | null | undefined +}) => { + if ( + typeof previousEndIndex !== "number" || + !Number.isInteger(previousEndIndex) || + previousEndIndex < 0 + ) { + return null + } + + if ( + typeof currentStartIndex !== "number" || + !Number.isInteger(currentStartIndex) || + currentStartIndex <= previousEndIndex + ) { + return null + } + + return { + startIndex: previousEndIndex, + endIndex: currentStartIndex, + } +} + export const getScrollMarkReadExitedSliceEnd = ({ indexes, renderedEndIndex, diff --git a/packages/internal/store/src/modules/unread/store.test.ts b/packages/internal/store/src/modules/unread/store.test.ts new file mode 100644 index 00000000000..05212bb2afc --- /dev/null +++ b/packages/internal/store/src/modules/unread/store.test.ts @@ -0,0 +1,132 @@ +import { FeedViewType } from "@follow/constants" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { apiContext } from "../../context" +import type { FollowAPI } from "../../types" +import { useEntryStore } from "../entry/store" +import type { EntryModel } from "../entry/types" +import { unreadSyncService, useUnreadStore } from "./store" + +const { entryPatchManyMock, unreadUpsertManyMock } = vi.hoisted(() => ({ + entryPatchManyMock: vi.fn(), + unreadUpsertManyMock: vi.fn(), +})) + +vi.mock("@follow/database/services/entry", () => ({ + EntryService: { + patchMany: entryPatchManyMock, + }, +})) + +vi.mock("@follow/database/services/unread", () => ({ + UnreadService: { + getUnreadAll: vi.fn(), + reset: vi.fn(), + upsertMany: unreadUpsertManyMock, + }, +})) + +const createEntry = (id: string, feedId: string, read = false): EntryModel => ({ + id, + guid: `${id}-guid`, + insertedAt: new Date("2026-01-01T00:00:00.000Z"), + publishedAt: new Date("2026-01-01T00:00:00.000Z"), + feedId, + read, +}) + +describe("unreadSyncService", () => { + const markAsReadMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + useEntryStore.setState({ + data: {}, + entryIdByView: { + [FeedViewType.All]: new Set(), + [FeedViewType.Articles]: new Set(), + [FeedViewType.Audios]: new Set(), + [FeedViewType.Notifications]: new Set(), + [FeedViewType.Pictures]: new Set(), + [FeedViewType.SocialMedia]: new Set(), + [FeedViewType.Videos]: new Set(), + }, + entryIdByCategory: {}, + entryIdByFeed: {}, + entryIdByInbox: {}, + entryIdByList: {}, + entryIdSet: new Set(), + }) + useUnreadStore.setState({ data: {} }) + apiContext.provide({ + reads: { + markAsRead: markAsReadMock, + }, + } as unknown as FollowAPI) + }) + + it("marks multiple feed entries as read with one request and one local patch", async () => { + const entries = { + entry1: createEntry("entry1", "feed1"), + entry2: createEntry("entry2", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 2 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + await unreadSyncService.markEntriesAsRead(["entry1", "entry2"]) + + expect(markAsReadMock).toHaveBeenCalledTimes(1) + expect(markAsReadMock).toHaveBeenCalledWith({ + entryIds: ["entry1", "entry2"], + isInbox: false, + }) + expect(entryPatchManyMock).toHaveBeenCalledTimes(1) + expect(entryPatchManyMock).toHaveBeenCalledWith({ + entry: { read: true }, + entryIds: ["entry1", "entry2"], + }) + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + expect(useEntryStore.getState().data.entry2?.read).toBe(true) + expect(useUnreadStore.getState().data.feed1).toBe(0) + }) + + it("queues rapid read marks into one batched request", async () => { + vi.useFakeTimers() + + try { + const entries = { + entry1: createEntry("entry1", "feed1"), + entry2: createEntry("entry2", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 2 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + const firstFlush = unreadSyncService.queueEntriesAsRead(["entry1"]) + const secondFlush = unreadSyncService.queueEntriesAsRead(["entry2"]) + + expect(markAsReadMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + await Promise.all([firstFlush, secondFlush]) + + expect(markAsReadMock).toHaveBeenCalledTimes(1) + expect(markAsReadMock).toHaveBeenCalledWith({ + entryIds: ["entry1", "entry2"], + isInbox: false, + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/internal/store/src/modules/unread/store.ts b/packages/internal/store/src/modules/unread/store.ts index e7e7bcbd3c6..2d4bed1a207 100644 --- a/packages/internal/store/src/modules/unread/store.ts +++ b/packages/internal/store/src/modules/unread/store.ts @@ -26,11 +26,22 @@ const initialUnreadStore: UnreadState = { data: {}, } +const READ_MARK_BATCH_WINDOW = 100 + export const useUnreadStore = createZustandStore("unread")(() => initialUnreadStore) const get = useUnreadStore.getState const set = useUnreadStore.setState +type ReadEntryTarget = { + entryId: string + id: FeedIdOrInboxHandle + isInbox: boolean +} + class UnreadSyncService { + private queuedReadEntryIds = new Set() + private queuedReadFlushPromise: Promise | null = null + async resetFromRemote() { const res = await api().reads.get({}) @@ -192,7 +203,114 @@ class UnreadSyncService { }) } + private getReadEntryTargets(entryIds: string[]): ReadEntryTarget[] { + const seenEntryIds = new Set() + const targets: ReadEntryTarget[] = [] + + for (const entryId of entryIds) { + if (seenEntryIds.has(entryId)) continue + seenEntryIds.add(entryId) + + const entry = getEntry(entryId) + if (!entry || entry.read || (!entry.feedId && !entry.inboxHandle)) continue + + targets.push({ + entryId, + id: entry.inboxHandle || entry.feedId || "", + isInbox: !!entry.inboxHandle, + }) + } + + return targets + } + + async markEntriesAsRead(entryIds: string[]) { + const targets = this.getReadEntryTargets(entryIds) + if (targets.length === 0) return + + const targetEntryIds = targets.map((target) => target.entryId) + const unreadCountById = targets.reduce( + (acc, target) => { + acc[target.id] = (acc[target.id] || 0) + 1 + return acc + }, + {} as Record, + ) + + const feedEntryIds = targets.filter((target) => !target.isInbox).map((target) => target.entryId) + const inboxEntryIds = targets.filter((target) => target.isInbox).map((target) => target.entryId) + + const tx = createTransaction() + tx.store(() => { + entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: true }) + for (const [id, count] of Object.entries(unreadCountById)) { + unreadActions.removeUnread(id, count) + } + }) + + tx.request(async () => { + if (feedEntryIds.length > 0) { + await api().reads.markAsRead({ entryIds: feedEntryIds, isInbox: false }) + } + if (inboxEntryIds.length > 0) { + await api().reads.markAsRead({ entryIds: inboxEntryIds, isInbox: true }) + } + }) + + tx.rollback(() => { + entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: false }) + for (const [id, count] of Object.entries(unreadCountById)) { + unreadActions.addUnread(id, count) + } + }) + + tx.persist(() => { + return EntryService.patchMany({ + entry: { read: true }, + entryIds: targetEntryIds, + }) + }) + + Object.keys(unreadCountById).forEach((id) => { + if (id) { + setFeedUnreadDirty(id) + } + }) + + await tx.run() + } + + queueEntriesAsRead(entryIds: string[]) { + for (const entryId of entryIds) { + this.queuedReadEntryIds.add(entryId) + } + + if (this.queuedReadFlushPromise) { + return this.queuedReadFlushPromise + } + + this.queuedReadFlushPromise = new Promise((resolve) => { + setTimeout(() => { + const queuedEntryIds = Array.from(this.queuedReadEntryIds) + this.queuedReadEntryIds.clear() + this.queuedReadFlushPromise = null + + this.markEntriesAsRead(queuedEntryIds) + .catch((error) => { + console.error(error) + }) + .finally(resolve) + }, READ_MARK_BATCH_WINDOW) + }) + + return this.queuedReadFlushPromise + } + private async markEntryReadStatus({ entryId, read }: { entryId: string; read: boolean }) { + if (read) { + return this.markEntriesAsRead([entryId]) + } + const entry = getEntry(entryId) if (!entry || entry.read === read || (!entry.feedId && !entry.inboxHandle)) return From c6f942436d5c41d3047b52b98f24872493f8d679 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 12:34:24 +0800 Subject: [PATCH 16/29] ci: serialize pnpm dependency installs --- .github/workflows/build-android.yml | 2 +- .github/workflows/build-ios-development.yml | 6 +++--- .github/workflows/build-ios.yml | 4 ++-- .github/workflows/build-web.yml | 2 +- .github/workflows/deploy-cloudflare-desktop.yml | 2 +- .github/workflows/deploy-cloudflare-landing.yml | 2 +- .github/workflows/deploy-cloudflare-ssr.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-ota.yml | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 33a030f3994..1919001fcbf 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -71,7 +71,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer diff --git a/.github/workflows/build-ios-development.yml b/.github/workflows/build-ios-development.yml index 68249cd0ee2..4c3957e3f62 100644 --- a/.github/workflows/build-ios-development.yml +++ b/.github/workflows/build-ios-development.yml @@ -49,7 +49,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer @@ -106,7 +106,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer @@ -157,7 +157,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 5d112ed8918..6eaa7e7c018 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -57,7 +57,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer @@ -119,7 +119,7 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build mobile web assets run: pnpm --dir apps/mobile/web-app build --outDir ../../../out/rn-web/html-renderer diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 883ab3aff02..8230ebf4e82 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -39,7 +39,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: "pnpm" - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build web and SSR server run: | npm exec turbo run Folo#build:web @follow/ssr#build diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml index 22f55c045fc..7bd62d19c55 100644 --- a/.github/workflows/deploy-cloudflare-desktop.yml +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -41,7 +41,7 @@ jobs: cache: "pnpm" - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build desktop web (SPA) working-directory: apps/desktop diff --git a/.github/workflows/deploy-cloudflare-landing.yml b/.github/workflows/deploy-cloudflare-landing.yml index 3d5dc23fa28..4f3b52906bc 100644 --- a/.github/workflows/deploy-cloudflare-landing.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -45,7 +45,7 @@ jobs: cache: "pnpm" - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Resolve deployment target id: target diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml index a6c9abab049..9d04515cdc3 100644 --- a/.github/workflows/deploy-cloudflare-ssr.yml +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -48,7 +48,7 @@ jobs: cache: "pnpm" - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Resolve deployment target id: target diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 265abe3e844..dc33bdde3ac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,7 +44,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: "pnpm" - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Build web and SSR server run: | npm exec turbo run Folo#build:web @follow/ssr#build diff --git a/.github/workflows/publish-ota.yml b/.github/workflows/publish-ota.yml index 4cedcf02c35..689cf32d48a 100644 --- a/.github/workflows/publish-ota.yml +++ b/.github/workflows/publish-ota.yml @@ -61,7 +61,7 @@ jobs: cache: "pnpm" - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --child-concurrency=1 - name: Export OTA bundle assets working-directory: apps/mobile From 2b0b412dbe071ea77e76d2acb6bdacbb2f1bf44f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 14:11:11 +0800 Subject: [PATCH 17/29] fix(mobile): avoid duplicate shared links --- apps/mobile/src/lib/share.test.ts | 55 +++++++++++++++++++ apps/mobile/src/lib/share.ts | 29 ++++++++++ .../mobile/src/modules/context-menu/entry.tsx | 15 +++-- .../mobile/src/modules/context-menu/video.tsx | 16 ++++-- .../EntryContentHeaderRightActions.tsx | 11 ++-- apps/mobile/src/modules/screen/action.tsx | 16 ++++-- .../src/screens/(modal)/ProfileScreen.tsx | 15 +++-- 7 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/lib/share.test.ts create mode 100644 apps/mobile/src/lib/share.ts diff --git a/apps/mobile/src/lib/share.test.ts b/apps/mobile/src/lib/share.test.ts new file mode 100644 index 00000000000..d3a380b775d --- /dev/null +++ b/apps/mobile/src/lib/share.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { createLinkShareContent } from "./share" + +describe("createLinkShareContent", () => { + it("uses a single URL item on iOS", () => { + const url = "https://example.com/post/1" + + const content = createLinkShareContent({ + platform: "ios", + title: "Post title", + url, + message: url, + }) + + expect(content).toEqual({ + title: "Post title", + url, + }) + expect(content).not.toHaveProperty("message") + }) + + it("uses a single text item on Android", () => { + const url = "https://example.com/post/1" + const message = `Check out this post: ${url}` + + const content = createLinkShareContent({ + platform: "android", + title: "Post title", + url, + message, + }) + + expect(content).toEqual({ + title: "Post title", + message, + }) + expect(content).not.toHaveProperty("url") + }) + + it("falls back to the URL as Android share text", () => { + const url = "https://example.com/post/1" + + expect( + createLinkShareContent({ + platform: "android", + title: "Post title", + url, + }), + ).toEqual({ + title: "Post title", + message: url, + }) + }) +}) diff --git a/apps/mobile/src/lib/share.ts b/apps/mobile/src/lib/share.ts new file mode 100644 index 00000000000..c3c6451e08d --- /dev/null +++ b/apps/mobile/src/lib/share.ts @@ -0,0 +1,29 @@ +import type { ShareContent } from "react-native" + +type LinkSharePlatform = "android" | "ios" | "macos" | "native" | "web" | "windows" + +interface CreateLinkShareContentOptions { + platform: LinkSharePlatform + title?: string + url: string + message?: string +} + +export const createLinkShareContent = ({ + platform, + title, + url, + message, +}: CreateLinkShareContentOptions): ShareContent => { + if (platform === "ios") { + return { + title, + url, + } + } + + return { + title, + message: message || url, + } +} diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index a6c3c0a6865..c1bad50e31d 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -9,7 +9,7 @@ import { PortalProvider } from "@gorhom/portal" import type { PropsWithChildren } from "react" import { useCallback } from "react" import { useTranslation } from "react-i18next" -import { Share, View } from "react-native" +import { Platform, Share, View } from "react-native" import { getHideAllReadSubscriptions } from "@/src/atoms/settings/general" import { EntryContentWebView } from "@/src/components/native/webview/EntryContentWebView" @@ -17,6 +17,7 @@ import { WebViewManager } from "@/src/components/native/webview/webview-manager" import { ContextMenu } from "@/src/components/ui/context-menu" import { Text } from "@/src/components/ui/typography/Text" import { useNavigation } from "@/src/lib/navigation/hooks" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { playEntryTts } from "@/src/modules/player/entry-tts" import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen" @@ -204,11 +205,13 @@ export const EntryItemContextMenu = ({ key="Share" onSelect={async () => { if (!entry.url) return - await Share.share({ - message: entry.url, - url: entry.url, - title: entry.title || "Shared Link", - }) + await Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: entry.title || "Shared Link", + url: entry.url, + }), + ) }} > { if (!entry.url) return - await Share.share({ - message: [entry.title, entry.url].filter(Boolean).join("\n"), - url: entry.url, - title: entry.title || "Shared Video", - }) + await Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: entry.title || "Shared Video", + url: entry.url, + message: [entry.title, entry.url].filter(Boolean).join("\n"), + }), + ) return }} > diff --git a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx index 09567752363..854dc7a57c9 100644 --- a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx +++ b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx @@ -10,7 +10,7 @@ import { setStringAsync } from "expo-clipboard" import { useAtom } from "jotai" import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { Pressable, Share, View } from "react-native" +import { Platform, Pressable, Share, View } from "react-native" import type { SharedValue } from "react-native-reanimated" import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated" import { useColor } from "react-native-uikit-colors" @@ -27,6 +27,7 @@ import { StarCuteReIcon } from "@/src/icons/star_cute_re" import { Translate2CuteReIcon } from "@/src/icons/translate_2_cute_re" import { VoiceCuteReIcon } from "@/src/icons/voice_cute_re" import { hideIntelligenceGlowEffect, openLink } from "@/src/lib/native" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { playEntryTts } from "@/src/modules/player/entry-tts" @@ -100,11 +101,9 @@ const HeaderRightActionsImpl = ({ const handleShare = () => { if (!entry?.title || !entry?.url) return - Share.share({ - message: entry.url, - title: entry.title, - url: entry.url, - }) + Share.share( + createLinkShareContent({ platform: Platform.OS, title: entry.title, url: entry.url }), + ) } const toggleAITranslation = () => { diff --git a/apps/mobile/src/modules/screen/action.tsx b/apps/mobile/src/modules/screen/action.tsx index 60d13e56d25..a2a97889b17 100644 --- a/apps/mobile/src/modules/screen/action.tsx +++ b/apps/mobile/src/modules/screen/action.tsx @@ -5,7 +5,7 @@ import * as Haptics from "expo-haptics" import type { PropsWithChildren } from "react" import { useCallback } from "react" import { useTranslation } from "react-i18next" -import { Pressable, Share, View } from "react-native" +import { Platform, Pressable, Share, View } from "react-native" import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general" import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar" @@ -17,6 +17,7 @@ import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re" import { Dialog } from "@/src/lib/dialog" import { useNavigation } from "@/src/lib/navigation/hooks" import { proxyEnv } from "@/src/lib/proxy-env" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { LoginScreen } from "@/src/screens/(modal)/LoginScreen" import { ProfileScreen } from "@/src/screens/(modal)/ProfileScreen" @@ -124,11 +125,14 @@ export const FeedShareActionButton = ({ const feed = getFeedById(feedId) if (!feed) return const url = `${proxyEnv.WEB_URL}/share/feeds/${feedId}` - Share.share({ - message: `Check out ${feed.title} on Folo: ${url}`, - title: feed.title!, - url, - }) + Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: feed.title!, + url, + message: `Check out ${feed.title} on Folo: ${url}`, + }), + ) }} /> ) diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index 99dba97db18..9827711e1ce 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -7,7 +7,7 @@ import { usePrefetchUser, useUserById, useWhoami } from "@follow/store/user/hook import { Image as ExpoImage } from "expo-image" import { createContext, Fragment, use, useCallback, useEffect, useMemo } from "react" import { useTranslation } from "react-i18next" -import { Alert, FlatList, Pressable, Share, View } from "react-native" +import { Alert, FlatList, Platform, Pressable, Share, View } from "react-native" import Animated, { interpolate, useAnimatedScrollHandler, @@ -42,6 +42,7 @@ import { ShareForwardCuteReIcon } from "@/src/icons/share_forward_cute_re" import type { followClient } from "@/src/lib/api-client" import { Navigation } from "@/src/lib/navigation/Navigation" import type { NavigationControllerView } from "@/src/lib/navigation/types" +import { createLinkShareContent } from "@/src/lib/share" import { toast } from "@/src/lib/toast" import { useShareSubscription } from "@/src/modules/settings/hooks/useShareSubscription" import { UserHeaderBanner } from "@/src/modules/settings/UserHeaderBanner" @@ -95,11 +96,13 @@ function ProfileScreenImpl(props: { userId: string }) { const openShareUrl = useCallback(() => { if (!user?.id) return const shareUrl = `https://app.folo.is/share/users/${user.id}` - Share.share({ - message: shareUrl, - url: shareUrl, - title: `Folo | ${user.name}'s Profile`, - }) + Share.share( + createLinkShareContent({ + platform: Platform.OS, + title: `Folo | ${user.name}'s Profile`, + url: shareUrl, + }), + ) }, [user?.id, user?.name]) const whoami = useWhoami() From 9bce34a82d66e2b703da226e281d6577d389c990 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 14:21:11 +0800 Subject: [PATCH 18/29] fix: preserve local read state after scroll sync --- .../internal/store/src/modules/entry/store.ts | 78 +++++++++++++++--- .../store/src/modules/unread/store.test.ts | 81 ++++++++++++++++++- .../store/src/modules/unread/store.ts | 1 + 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/packages/internal/store/src/modules/entry/store.ts b/packages/internal/store/src/modules/entry/store.ts index 1095e5df9aa..267b45e307b 100644 --- a/packages/internal/store/src/modules/entry/store.ts +++ b/packages/internal/store/src/modules/entry/store.ts @@ -60,12 +60,56 @@ const defaultState: EntryState = { entryIdSet: new Set(), } +const LOCAL_READ_PROTECTION_WINDOW = 30 * 1000 + export const useEntryStore = createZustandStore("entry")(() => defaultState) const get = useEntryStore.getState const immerSet = createImmerSetter(useEntryStore) class EntryActions implements Hydratable, Resetable { + private localReadProtectionExpiresAt = new Map() + private nextLocalReadProtectionCleanupAt = 0 + + private protectLocalRead(entryId: EntryId) { + const now = Date.now() + this.pruneLocalReadProtection(now) + this.localReadProtectionExpiresAt.set(entryId, now + LOCAL_READ_PROTECTION_WINDOW) + } + + private clearLocalReadProtection(entryId: EntryId) { + this.localReadProtectionExpiresAt.delete(entryId) + } + + private isLocalReadProtected(entryId: EntryId) { + const expiresAt = this.localReadProtectionExpiresAt.get(entryId) + if (!expiresAt) return false + + if (expiresAt <= Date.now()) { + this.localReadProtectionExpiresAt.delete(entryId) + return false + } + + return true + } + + private pruneLocalReadProtection(now: number) { + if (this.nextLocalReadProtectionCleanupAt > now) return + + for (const [entryId, expiresAt] of this.localReadProtectionExpiresAt.entries()) { + if (expiresAt <= now) { + this.localReadProtectionExpiresAt.delete(entryId) + } + } + + this.nextLocalReadProtectionCleanupAt = now + LOCAL_READ_PROTECTION_WINDOW + } + + clearLocalReadProtectionInSession() { + this.localReadProtectionExpiresAt.clear() + this.nextLocalReadProtectionCleanupAt = 0 + } + async hydrate() { const entries = await EntryService.getEntriesToHydrate() entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e))) @@ -200,30 +244,33 @@ class EntryActions implements Hydratable, Resetable { immerSet((draft) => { for (const entry of entries) { - draft.entryIdSet.add(entry.id) - draft.data[entry.id] = entry + const nextEntry = + !entry.read && this.isLocalReadProtected(entry.id) ? { ...entry, read: true } : entry + + draft.entryIdSet.add(nextEntry.id) + draft.data[nextEntry.id] = nextEntry - const { feedId, inboxHandle, read, sources } = entry + const { feedId, inboxHandle, read, sources } = nextEntry if (unreadOnly && read) continue if (inboxHandle) { this.addEntryIdToInbox({ draft, inboxHandle, - entryId: entry.id, + entryId: nextEntry.id, }) } else { this.addEntryIdToFeed({ draft, feedId, - entryId: entry.id, + entryId: nextEntry.id, }) } this.addEntryIdToView({ draft, feedId, - entryId: entry.id, + entryId: nextEntry.id, sources, hidePrivateSubscriptionsInTimeline, }) @@ -231,16 +278,16 @@ class EntryActions implements Hydratable, Resetable { this.addEntryIdToCategory({ draft, feedId, - entryId: entry.id, + entryId: nextEntry.id, }) - entry.sources + nextEntry.sources ?.filter((s) => !!s && s !== "feed") .forEach((s) => { this.addEntryIdToList({ draft, listId: s, - entryId: entry.id, + entryId: nextEntry.id, }) }) } @@ -355,6 +402,12 @@ class EntryActions implements Hydratable, Resetable { continue } + if (read) { + this.protectLocalRead(entryId) + } else { + this.clearLocalReadProtection(entryId) + } + if (entry.read !== read) { entry.read = read affectedEntryIds.add(entryId) @@ -389,6 +442,12 @@ class EntryActions implements Hydratable, Resetable { continue } + if (read) { + this.protectLocalRead(entry.id) + } else { + this.clearLocalReadProtection(entry.id) + } + if (entry.read !== read) { entry.read = read affectedEntryIds.add(entry.id) @@ -450,6 +509,7 @@ class EntryActions implements Hydratable, Resetable { async reset() { const tx = createTransaction() tx.store(() => { + this.clearLocalReadProtectionInSession() immerSet(() => defaultState) }) diff --git a/packages/internal/store/src/modules/unread/store.test.ts b/packages/internal/store/src/modules/unread/store.test.ts index 05212bb2afc..8d6e2683e86 100644 --- a/packages/internal/store/src/modules/unread/store.test.ts +++ b/packages/internal/store/src/modules/unread/store.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { apiContext } from "../../context" import type { FollowAPI } from "../../types" -import { useEntryStore } from "../entry/store" +import { entryActions, useEntryStore } from "../entry/store" import type { EntryModel } from "../entry/types" import { unreadSyncService, useUnreadStore } from "./store" @@ -40,6 +40,7 @@ describe("unreadSyncService", () => { beforeEach(() => { vi.clearAllMocks() + entryActions.clearLocalReadProtectionInSession() useEntryStore.setState({ data: {}, @@ -129,4 +130,82 @@ describe("unreadSyncService", () => { vi.useRealTimers() } }) + + it("keeps entries locally read when a stale entry fetch returns during the request", async () => { + const entries = { + entry1: createEntry("entry1", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 1 } }) + + let resolveMarkAsRead!: () => void + markAsReadMock.mockReturnValue( + new Promise((resolve) => { + resolveMarkAsRead = () => resolve({ data: null }) + }), + ) + + const markAsRead = unreadSyncService.markEntriesAsRead(["entry1"]) + + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + entryActions.upsertManyInSession([createEntry("entry1", "feed1")]) + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + + resolveMarkAsRead() + await markAsRead + + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + }) + + it("keeps entries locally read when a stale entry fetch returns after the request", async () => { + const entries = { + entry1: createEntry("entry1", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 1 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + await unreadSyncService.markEntriesAsRead(["entry1"]) + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + + entryActions.upsertManyInSession([createEntry("entry1", "feed1")]) + + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + }) + + it("allows remote unread after the local read protection window expires", async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")) + + try { + const entries = { + entry1: createEntry("entry1", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 1 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + await unreadSyncService.markEntriesAsRead(["entry1"]) + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + + vi.advanceTimersByTime(31_000) + entryActions.upsertManyInSession([createEntry("entry1", "feed1")]) + + expect(useEntryStore.getState().data.entry1?.read).toBe(false) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/packages/internal/store/src/modules/unread/store.ts b/packages/internal/store/src/modules/unread/store.ts index 2d4bed1a207..b8bf44fe587 100644 --- a/packages/internal/store/src/modules/unread/store.ts +++ b/packages/internal/store/src/modules/unread/store.ts @@ -265,6 +265,7 @@ class UnreadSyncService { }) tx.persist(() => { + entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: true }) return EntryService.patchMany({ entry: { read: true }, entryIds: targetEntryIds, From a52ff66fb3c8a7b0fb73d418ea82adba8a028075 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 14:31:40 +0800 Subject: [PATCH 19/29] fix(desktop): clear stale web context menu --- .../providers/context-menu-provider.test.tsx | 148 ++++++++++++++++++ .../src/providers/context-menu-provider.tsx | 52 +++++- 2 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx diff --git a/apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx b/apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx new file mode 100644 index 00000000000..e8cd7814e47 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx @@ -0,0 +1,148 @@ +import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js" +import { Provider } from "jotai" +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest" + +import { contextMenuAtom, MenuItemText, useShowContextMenu } from "~/atoms/context-menu" +import { jotaiStore } from "~/lib/jotai" + +import { ContextMenuProvider } from "./context-menu-provider" + +const { requireLoginMock } = vi.hoisted(() => ({ + requireLoginMock: () => ({ + withLoginGuard: unknown>(action: T) => action, + }), +})) + +vi.mock("~/hooks/common/useRequireLogin", () => ({ + useRequireLogin: requireLoginMock, +})) + +const waitForContextMenuEffects = async () => { + for (let index = 0; index < 3; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const TestMenuTrigger = () => { + const showContextMenu = useShowContextMenu() + + return ( + + ) +} + +const renderProvider = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render( + + + + + + + , + ) + }) + + return { container, root } +} + +describe("ContextMenuProvider", () => { + 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 + vi.spyOn(console, "info").mockImplementation(() => {}) + + Object.assign(window, { + clearTimeout, + Element: document.defaultView?.Element ?? Element, + getComputedStyle: + document.defaultView?.getComputedStyle.bind(document.defaultView) ?? getComputedStyle, + HTMLElement: document.defaultView?.HTMLElement ?? HTMLElement, + innerHeight: 768, + innerWidth: 1024, + Node: document.defaultView?.Node ?? Node, + setTimeout, + }) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + afterEach(async () => { + await act(async () => { + jotaiStore.set(contextMenuAtom, { open: false }) + await waitForContextMenuEffects() + }) + + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + document.body.innerHTML = "" + root = null + container = null + vi.clearAllMocks() + }) + + test("removes the web menu shell when the app menu state closes", async () => { + ;({ container, root } = await renderProvider()) + + const trigger = container.querySelector("button") + expect(trigger).not.toBeNull() + + await act(async () => { + trigger?.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 120, + clientY: 80, + }), + ) + await waitForContextMenuEffects() + }) + + expect(document.querySelector('[role="menu"]')).not.toBeNull() + + await act(async () => { + jotaiStore.set(contextMenuAtom, { open: false }) + await waitForContextMenuEffects() + }) + + expect(document.querySelector('[role="menu"]')).toBeNull() + }) +}) diff --git a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx index ed07908f557..a4438e16126 100644 --- a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx @@ -18,7 +18,7 @@ import { import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js" import { nextFrame, preventDefault } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" -import { Fragment, memo, useCallback, useEffect, useRef } from "react" +import { Fragment, memo, useCallback, useEffect, useReducer, useRef } from "react" import { useHotkeys } from "react-hotkeys-hook" import type { FollowMenuItem } from "~/atoms/context-menu" @@ -40,6 +40,8 @@ export const ContextMenuProvider: Component = ({ children }) => ( const Handler = () => { const ref = useRef(null) const [contextMenuState, setContextMenuState] = useContextMenuState() + const wasOpenRef = useRef(false) + const [contextMenuKey, resetContextMenu] = useReducer((key) => key + 1, 0) useEffect(() => { if (!contextMenuState.open) return @@ -55,6 +57,18 @@ const Handler = () => { }), ) }, [contextMenuState]) + + useEffect(() => { + if (contextMenuState.open) { + wasOpenRef.current = true + return + } + + if (!wasOpenRef.current) return + wasOpenRef.current = false + resetContextMenu() + }, [contextMenuState.open]) + const setGlobalFocusableScope = useSetGlobalFocusableScope() const handleOpenChange = useCallback( @@ -69,11 +83,11 @@ const Handler = () => { ) return ( - + - - {contextMenuState.open && - contextMenuState.menuItems.map((item, index) => { + {contextMenuState.open && ( + + {contextMenuState.menuItems.map((item, index) => { const prevItem = contextMenuState.menuItems[index - 1] if (prevItem instanceof MenuItemSeparator && item instanceof MenuItemSeparator) { return null @@ -86,13 +100,35 @@ const Handler = () => { if (!nextItem && item instanceof MenuItemSeparator) { return null } - return + return ( + + ) })} - + + )} ) } +const getMenuItemKey = (item: FollowMenuItem, index: number, items: FollowMenuItem[]) => { + if (item instanceof MenuItemSeparator) { + const previousItem = items[index - 1] + const nextItem = items[index + 1] + const previousLabel = previousItem instanceof MenuItemText ? previousItem.label : "start" + const nextLabel = nextItem instanceof MenuItemText ? nextItem.label : "end" + + return `separator-${previousLabel}-${nextLabel}` + } + + return [ + item.label, + item.shortcut ?? "no-shortcut", + typeof item.checked === "boolean" ? item.checked.toString() : "unchecked", + item.disabled ? "disabled" : "enabled", + item.submenu.length.toString(), + ].join(":") +} + const Item = memo(({ item }: { item: FollowMenuItem }) => { const onClick = useCallback(() => { if ("click" in item) { @@ -155,7 +191,7 @@ const Item = memo(({ item }: { item: FollowMenuItem }) => { {item.submenu.map((subItem, index) => ( - + ))} From dc43e39711fc0ab1584e1f685a62a005c3ed8648 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 20:19:04 +0800 Subject: [PATCH 20/29] fix: retry scroll mark-read after revisiting entries --- .../hooks/useEntryMarkReadHandler.test.ts | 18 +++++++++ .../hooks/useEntryMarkReadHandler.tsx | 37 +++---------------- .../src/modules/entry-column/index.tsx | 21 ++++------- .../shared/src/scroll-mark-read.test.ts | 23 ++++++++++++ .../internal/shared/src/scroll-mark-read.ts | 30 +++++++++++++++ 5 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.test.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.test.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.test.ts new file mode 100644 index 00000000000..fa17b032402 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.test.ts @@ -0,0 +1,18 @@ +import { unreadSyncService } from "@follow/store/unread/store" +import { describe, expect, it, vi } from "vitest" + +import { batchMarkRead } from "./useEntryMarkReadHandler" + +vi.mock("@follow/store/unread/store", () => ({ + unreadSyncService: { + queueEntriesAsRead: vi.fn(), + }, +})) + +describe("batchMarkRead", () => { + it("queues ids without requiring entries to exist in the local store", () => { + batchMarkRead(["entry-1", "entry-2"]) + + expect(unreadSyncService.queueEntriesAsRead).toHaveBeenCalledWith(["entry-1", "entry-2"]) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx index 727578fd561..3545a17f5e7 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -1,8 +1,7 @@ import { getView } from "@follow/constants" -import { entryActions } from "@follow/store/entry/store" import { unreadSyncService } from "@follow/store/unread/store" import type { Range } from "@tanstack/react-virtual" -import { useEffect, useMemo, useRef } from "react" +import { useMemo } from "react" import { useEventCallback } from "usehooks-ts" import { useGeneralSettingKey } from "~/atoms/settings/general" @@ -18,26 +17,13 @@ export const useEntryMarkReadHandler = ( const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread") const feedView = useRouteParamsSelector((params) => params.view) - const processedEntryIds = useRef(new Set()) - - useEffect(() => { - processedEntryIds.current.clear() - }, [entriesIds]) - const handleRangeMarkRead = useEventCallback( ({ startIndex, endIndex }: Range, enabled?: boolean) => { if (!enabled) return const idSlice = entriesIds?.slice(startIndex, endIndex) - if (!idSlice) return - - // Filter out entries that have already been processed - const newEntries = idSlice.filter((id) => !processedEntryIds.current.has(id)) - if (newEntries.length === 0) return - - // Mark these entries as processed to avoid duplicate processing - newEntries.forEach((id) => processedEntryIds.current.add(id)) + if (!idSlice?.length) return - batchMarkRead(newEntries) + batchMarkRead(idSlice) }, ) @@ -71,19 +57,6 @@ export const useEntryMarkReadHandler = ( } export function batchMarkRead(ids: string[]) { - const batchLikeIds = [] as string[] - const entriesId2Map = entryActions.getFlattenMapEntries() - for (const id of ids) { - const entry = entriesId2Map[id] - - if (!entry) continue - const isRead = entry.read - if (!isRead && entry.feedId) { - batchLikeIds.push(id) - } - } - - if (batchLikeIds.length > 0) { - void unreadSyncService.queueEntriesAsRead(batchLikeIds) - } + if (ids.length === 0) return + void unreadSyncService.queueEntriesAsRead(ids) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 20cdd3ea9b8..b6ee943a24a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -1,6 +1,6 @@ import { FeedViewType, getView } from "@follow/constants" import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks" -import { getScrollMarkReadRange } from "@follow/shared/scroll-mark-read" +import { getScrollMarkReadRangeState } from "@follow/shared/scroll-mark-read" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" @@ -43,11 +43,11 @@ function EntryColumnContent() { const state = useEntriesState() const isInteracted = useRef(false) - const scrollMarkReadEndIndexRef = useRef(null) + const scrollMarkReadAnchorIndexRef = useRef(null) const latestRangeStartIndexRef = useRef(null) const resetScrollInteractionState = useCallback(() => { isInteracted.current = false - scrollMarkReadEndIndexRef.current = null + scrollMarkReadAnchorIndexRef.current = null latestRangeStartIndexRef.current = null }, []) @@ -133,19 +133,14 @@ function EntryColumnContent() { (currentStartIndex: number) => { if (!routeFeedId) return - const range = getScrollMarkReadRange({ - previousEndIndex: scrollMarkReadEndIndexRef.current, + const { nextAnchorIndex, range } = getScrollMarkReadRangeState({ + anchorIndex: scrollMarkReadAnchorIndexRef.current, currentStartIndex, }) + scrollMarkReadAnchorIndexRef.current = nextAnchorIndex if (range) { handleScrollMarkRead?.(range as Range, isInteracted.current) - scrollMarkReadEndIndexRef.current = currentStartIndex - return - } - - if (scrollMarkReadEndIndexRef.current === null) { - scrollMarkReadEndIndexRef.current = currentStartIndex } }, [handleScrollMarkRead, routeFeedId], @@ -182,8 +177,8 @@ function EntryColumnContent() { } latestRangeStartIndexRef.current = e.startIndex - if (scrollMarkReadEndIndexRef.current === null) { - scrollMarkReadEndIndexRef.current = e.startIndex + if (scrollMarkReadAnchorIndexRef.current === null) { + scrollMarkReadAnchorIndexRef.current = e.startIndex } else if (isInteracted.current) { flushScrollMarkRead(e.startIndex) } diff --git a/packages/internal/shared/src/scroll-mark-read.test.ts b/packages/internal/shared/src/scroll-mark-read.test.ts index a6304fec3eb..66d8ef9cf53 100644 --- a/packages/internal/shared/src/scroll-mark-read.test.ts +++ b/packages/internal/shared/src/scroll-mark-read.test.ts @@ -4,6 +4,7 @@ import { getScrollMarkReadEndPadding, getScrollMarkReadExitedSliceEnd, getScrollMarkReadRange, + getScrollMarkReadRangeState, MIN_SCROLL_MARK_READ_END_PADDING, SCROLL_MARK_READ_END_INDICATOR_HEIGHT, shouldRenderScrollMarkReadEndSpacer, @@ -63,4 +64,26 @@ describe("scroll mark-read range", () => { }), ).toBeNull() }) + + it("moves the anchor backward while scrolling up so entries can be retried", () => { + expect( + getScrollMarkReadRangeState({ + anchorIndex: 12, + currentStartIndex: 8, + }), + ).toEqual({ + nextAnchorIndex: 8, + range: null, + }) + + expect( + getScrollMarkReadRangeState({ + anchorIndex: 8, + currentStartIndex: 12, + }), + ).toEqual({ + nextAnchorIndex: 12, + range: { startIndex: 8, endIndex: 12 }, + }) + }) }) diff --git a/packages/internal/shared/src/scroll-mark-read.ts b/packages/internal/shared/src/scroll-mark-read.ts index 9b934209d77..f027d3cabae 100644 --- a/packages/internal/shared/src/scroll-mark-read.ts +++ b/packages/internal/shared/src/scroll-mark-read.ts @@ -46,6 +46,36 @@ export const getScrollMarkReadRange = ({ } } +export const getScrollMarkReadRangeState = ({ + anchorIndex, + currentStartIndex, +}: { + anchorIndex: number | null | undefined + currentStartIndex: number | null | undefined +}) => { + if ( + typeof currentStartIndex !== "number" || + !Number.isInteger(currentStartIndex) || + currentStartIndex < 0 + ) { + return { + nextAnchorIndex: + typeof anchorIndex === "number" && Number.isInteger(anchorIndex) && anchorIndex >= 0 + ? anchorIndex + : null, + range: null, + } + } + + return { + nextAnchorIndex: currentStartIndex, + range: getScrollMarkReadRange({ + previousEndIndex: anchorIndex, + currentStartIndex, + }), + } +} + export const getScrollMarkReadExitedSliceEnd = ({ indexes, renderedEndIndex, From 89e7c13f132456f7e9f228e207d37c4ed1683073 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 10:50:19 +0800 Subject: [PATCH 21/29] fix: update unread count for time-limited mark-read --- .../store/src/modules/unread/store.test.ts | 55 ++++++++++++++++++- .../store/src/modules/unread/store.ts | 33 ++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/internal/store/src/modules/unread/store.test.ts b/packages/internal/store/src/modules/unread/store.test.ts index 8d6e2683e86..8f88833de9e 100644 --- a/packages/internal/store/src/modules/unread/store.test.ts +++ b/packages/internal/store/src/modules/unread/store.test.ts @@ -26,17 +26,23 @@ vi.mock("@follow/database/services/unread", () => ({ }, })) -const createEntry = (id: string, feedId: string, read = false): EntryModel => ({ +const createEntry = ( + id: string, + feedId: string, + read = false, + publishedAt = new Date("2026-01-01T00:00:00.000Z"), +): EntryModel => ({ id, guid: `${id}-guid`, insertedAt: new Date("2026-01-01T00:00:00.000Z"), - publishedAt: new Date("2026-01-01T00:00:00.000Z"), + publishedAt, feedId, read, }) describe("unreadSyncService", () => { const markAsReadMock = vi.fn() + const markAllAsReadMock = vi.fn() beforeEach(() => { vi.clearAllMocks() @@ -62,6 +68,7 @@ describe("unreadSyncService", () => { useUnreadStore.setState({ data: {} }) apiContext.provide({ reads: { + markAllAsRead: markAllAsReadMock, markAsRead: markAsReadMock, }, } as unknown as FollowAPI) @@ -97,6 +104,50 @@ describe("unreadSyncService", () => { expect(useUnreadStore.getState().data.feed1).toBe(0) }) + it("optimistically decrements unread count for time-limited batch reads", async () => { + const entries = { + entry1: createEntry("entry1", "feed1", false, new Date("2026-01-01T00:05:00.000Z")), + entry2: createEntry("entry2", "feed1", false, new Date("2026-01-01T00:03:00.000Z")), + entry3: createEntry("entry3", "feed1", false, new Date("2026-01-01T00:01:00.000Z")), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 3 } }) + + let resolveMarkAllAsRead!: (value: { data: { read: Record } }) => void + markAllAsReadMock.mockReturnValue( + new Promise((resolve) => { + resolveMarkAllAsRead = resolve + }), + ) + + const markBatchAsRead = unreadSyncService.markBatchAsRead({ + view: FeedViewType.Articles, + filter: { + feedIdList: ["feed1"], + }, + time: { + startTime: new Date("2026-01-01T00:02:00.000Z").getTime(), + endTime: new Date("2026-01-01T00:06:00.000Z").getTime(), + }, + excludePrivate: false, + }) + await Promise.resolve() + + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + expect(useEntryStore.getState().data.entry2?.read).toBe(true) + expect(useEntryStore.getState().data.entry3?.read).toBe(false) + expect(useUnreadStore.getState().data.feed1).toBe(1) + + resolveMarkAllAsRead({ data: { read: { feed1: 2 } } }) + await markBatchAsRead + + expect(useUnreadStore.getState().data.feed1).toBe(1) + }) + it("queues rapid read marks into one batched request", async () => { vi.useFakeTimers() diff --git a/packages/internal/store/src/modules/unread/store.ts b/packages/internal/store/src/modules/unread/store.ts index b8bf44fe587..3daa7804f6c 100644 --- a/packages/internal/store/src/modules/unread/store.ts +++ b/packages/internal/store/src/modules/unread/store.ts @@ -38,6 +38,20 @@ type ReadEntryTarget = { isInbox: boolean } +const countUnreadEntriesById = (entryIds: string[]): UnreadStoreModel => { + const unreadCountById: UnreadStoreModel = {} + + for (const entryId of entryIds) { + const entry = getEntry(entryId) + const id = entry?.inboxHandle || entry?.feedId + if (!id) continue + + unreadCountById[id] = (unreadCountById[id] || 0) + 1 + } + + return unreadCountById +} + class UnreadSyncService { private queuedReadEntryIds = new Set() private queuedReadFlushPromise: Promise | null = null @@ -65,9 +79,13 @@ class UnreadSyncService { if (!ids || ids.length === 0) return const currentUnreadList = ids.map((id) => ({ id, count: get().data[id] || 0 })) + const currentUnreadById = Object.fromEntries( + currentUnreadList.map(({ id, count }) => [id, count]), + ) const newUnreadListWhenNoTimeFilter = ids.map((id) => ({ id, count: 0 })) let affectedEntryIds: string[] = [] + let newUnreadListWhenTimeFilter: typeof currentUnreadList = [] const tx = createTransaction() @@ -80,6 +98,13 @@ class UnreadSyncService { if (!time) { unreadActions.upsertManyInSession(newUnreadListWhenNoTimeFilter) + } else { + const optimisticUnreadCountById = countUnreadEntriesById(affectedEntryIds) + newUnreadListWhenTimeFilter = ids.map((id) => ({ + id, + count: Math.max(0, (currentUnreadById[id] || 0) - (optimisticUnreadCountById[id] || 0)), + })) + unreadActions.upsertManyInSession(newUnreadListWhenTimeFilter) } }) @@ -99,7 +124,13 @@ class UnreadSyncService { await UnreadService.upsertMany(newUnreadListWhenNoTimeFilter) } else { if (res) { - await unreadActions.changeBatch(res, "decrement") + const finalUnreadList = Array.from(new Set([...ids, ...Object.keys(res)])).map((id) => ({ + id, + count: Math.max(0, (currentUnreadById[id] ?? get().data[id] ?? 0) - (res[id] || 0)), + })) + await unreadActions.upsertMany(finalUnreadList) + } else { + await UnreadService.upsertMany(newUnreadListWhenTimeFilter) } } From 69202876edf1178907cd461835f1b4499cd414a1 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 11:56:26 +0800 Subject: [PATCH 22/29] fix(collection): paginate starred entries by collection time --- .../hooks/useEntriesQuery.test.tsx | 72 ++++++++- .../internal/store/src/modules/entry/hooks.ts | 15 +- .../store/src/modules/entry/store.test.ts | 152 ++++++++++++++++++ .../internal/store/src/modules/entry/store.ts | 6 +- 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 packages/internal/store/src/modules/entry/store.test.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx index 0c1eb71ba86..3a395cf2d37 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesQuery.test.tsx @@ -10,7 +10,11 @@ import { afterEach, beforeAll, describe, expect, test, vi } from "vitest" type EntriesResponse = Awaited> -const createEntriesResponse = (entryId: string, publishedAt: string) => +const createEntriesResponse = ( + entryId: string, + publishedAt: string, + collectionCreatedAt?: string, +) => ({ data: [ { @@ -18,6 +22,11 @@ const createEntriesResponse = (entryId: string, publishedAt: string) => id: entryId, publishedAt, }, + ...(collectionCreatedAt && { + collections: { + createdAt: collectionCreatedAt, + }, + }), }, ], }) as unknown as EntriesResponse @@ -140,4 +149,65 @@ describe("useEntriesQuery", () => { await Promise.allSettled([firstFetch, secondFetch]) }) }) + + test("uses collection created time as next-page cursor for starred entries", async () => { + const fetchEntriesSpy = vi + .spyOn(entrySyncServices, "fetchEntries") + .mockImplementation(async (props) => { + if (props.pageParam) { + return createEntriesResponse("entry-2", "2026-02-27T00:00:00.000Z") + } + + return createEntriesResponse( + "entry-1", + "2026-02-28T00:00:00.000Z", + "2026-03-02T00:00:00.000Z", + ) + }) + + let entriesQuery: ReturnType | undefined + const EntriesQueryConsumer = () => { + entriesQuery = useEntriesQuery({ + feedId: "collections", + view: FeedViewType.Articles, + limit: 1, + }) + + return null + } + + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + await act(async () => { + root?.render( + + + , + ) + }) + + await act(async () => { + await vi.waitFor(() => { + expect(entriesQuery?.isSuccess).toBe(true) + }) + }) + + await act(async () => { + await entriesQuery?.fetchNextPage() + }) + + await vi.waitFor(() => { + expect(fetchEntriesSpy).toHaveBeenCalledTimes(2) + }) + expect(fetchEntriesSpy.mock.calls[1]?.[0].pageParam).toBe("2026-03-02T00:00:00.000Z") + }) }) diff --git a/packages/internal/store/src/modules/entry/hooks.ts b/packages/internal/store/src/modules/entry/hooks.ts index ed5665e223a..059c1e2dd9a 100644 --- a/packages/internal/store/src/modules/entry/hooks.ts +++ b/packages/internal/store/src/modules/entry/hooks.ts @@ -48,6 +48,10 @@ export const invalidateEntriesQuery = ({ } const defaultStaleTime = 10 * (60 * 1000) // 10 minutes +const toPageParam = (value: Date | string | null | undefined) => { + if (!value) return + return typeof value === "string" ? value : value.toISOString() +} export const useEntriesQuery = ( props?: Omit & @@ -71,6 +75,7 @@ export const useEntriesQuery = ( const isPop = "history" in globalThis && "isPop" in globalThis.history && !!globalThis.history.isPop + const isCollectionQuery = isCollection === true || feedId === FEED_COLLECTION_LIST const queryKey = useMemo( () => [ "entries", @@ -110,7 +115,15 @@ export const useEntriesQuery = ( excludePrivate: hidePrivateSubscriptionsInTimeline, }), - getNextPageParam: (lastPage) => (aiSort ? null : lastPage.data?.at(-1)?.entries.publishedAt), + getNextPageParam: (lastPage) => { + if (aiSort) return + + const lastEntry = lastPage.data?.at(-1) + return isCollectionQuery + ? (toPageParam(lastEntry?.collections?.createdAt) ?? + toPageParam(lastEntry?.entries.publishedAt)) + : toPageParam(lastEntry?.entries.publishedAt) + }, initialPageParam: undefined as undefined | string, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/packages/internal/store/src/modules/entry/store.test.ts b/packages/internal/store/src/modules/entry/store.test.ts new file mode 100644 index 00000000000..53735f95fa4 --- /dev/null +++ b/packages/internal/store/src/modules/entry/store.test.ts @@ -0,0 +1,152 @@ +import { FeedViewType } from "@follow/constants" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { apiContext } from "../../context" +import type { FollowAPI } from "../../types" +import { useCollectionStore } from "../collection/store" +import { useFeedStore } from "../feed/store" +import { entrySyncServices, useEntryStore } from "./store" + +const { + collectionDeleteManyMock, + collectionUpsertManyMock, + entryGetManyMock, + entryUpsertManyMock, + feedUpsertManyMock, +} = vi.hoisted(() => ({ + collectionDeleteManyMock: vi.fn(), + collectionUpsertManyMock: vi.fn(), + entryGetManyMock: vi.fn(), + entryUpsertManyMock: vi.fn(), + feedUpsertManyMock: vi.fn(), +})) + +vi.mock("@follow/database/services/collection", () => ({ + CollectionService: { + deleteMany: collectionDeleteManyMock, + getCollectionAll: vi.fn(), + reset: vi.fn(), + upsertMany: collectionUpsertManyMock, + }, +})) + +vi.mock("@follow/database/services/entry", () => ({ + EntryService: { + getEntryMany: entryGetManyMock, + getEntriesToHydrate: vi.fn(), + upsertMany: entryUpsertManyMock, + }, +})) + +vi.mock("@follow/database/services/feed", () => ({ + FEED_EXTRA_DATA_KEYS: [], + FeedService: { + getFeedAll: vi.fn(), + reset: vi.fn(), + upsertMany: feedUpsertManyMock, + }, +})) + +const createCollectionResponseItem = (index: number) => ({ + read: true, + feeds: { + id: "feed-1", + title: "Feed", + url: "https://example.com/feed.xml", + image: null, + description: null, + ownerUserId: null, + errorAt: null, + errorMessage: null, + siteUrl: "https://example.com", + }, + entries: { + id: `entry-${index}`, + title: `Entry ${index}`, + url: `https://example.com/${index}`, + description: null, + guid: `entry-${index}`, + author: null, + authorUrl: null, + authorAvatar: null, + insertedAt: "2026-03-01T00:00:00.000Z", + publishedAt: "2026-02-01T00:00:00.000Z", + media: null, + categories: null, + attachments: null, + extra: null, + language: null, + }, + collections: { + createdAt: `2026-03-${String(index).padStart(2, "0")}T00:00:00.000Z`, + }, +}) + +describe("entrySyncServices.fetchEntries", () => { + const listEntriesMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + entryGetManyMock.mockResolvedValue([]) + entryUpsertManyMock.mockImplementation(async () => {}) + collectionUpsertManyMock.mockImplementation(async () => {}) + collectionDeleteManyMock.mockImplementation(async () => {}) + feedUpsertManyMock.mockImplementation(async () => {}) + + useEntryStore.setState({ + data: {}, + entryIdByView: { + [FeedViewType.All]: new Set(), + [FeedViewType.Articles]: new Set(), + [FeedViewType.Audios]: new Set(), + [FeedViewType.Notifications]: new Set(), + [FeedViewType.Pictures]: new Set(), + [FeedViewType.SocialMedia]: new Set(), + [FeedViewType.Videos]: new Set(), + }, + entryIdByCategory: {}, + entryIdByFeed: {}, + entryIdByInbox: {}, + entryIdByList: {}, + entryIdSet: new Set(), + }) + useCollectionStore.setState({ collections: {} }) + useFeedStore.setState({ feeds: {} }) + apiContext.provide({ + entries: { + list: listEntriesMock, + }, + } as unknown as FollowAPI) + }) + + it("keeps known collection entries when the first collection page can have more pages", async () => { + useCollectionStore.setState({ + collections: Object.fromEntries( + Array.from({ length: 25 }, (_, index) => { + const entryId = `entry-${index + 1}` + return [ + entryId, + { + entryId, + feedId: "feed-1", + view: FeedViewType.Articles, + createdAt: `2026-03-${String(index + 1).padStart(2, "0")}T00:00:00.000Z`, + }, + ] + }), + ), + }) + listEntriesMock.mockResolvedValue({ + data: Array.from({ length: 20 }, (_, index) => createCollectionResponseItem(index + 6)), + }) + + await entrySyncServices.fetchEntries({ + feedId: "collections", + view: FeedViewType.Articles, + limit: 20, + }) + + expect(Object.keys(useCollectionStore.getState().collections)).toHaveLength(25) + expect(useCollectionStore.getState().collections["entry-1"]).toBeDefined() + }) +}) diff --git a/packages/internal/store/src/modules/entry/store.ts b/packages/internal/store/src/modules/entry/store.ts index 267b45e307b..ce35e71a589 100644 --- a/packages/internal/store/src/modules/entry/store.ts +++ b/packages/internal/store/src/modules/entry/store.ts @@ -601,8 +601,12 @@ class EntrySyncServices { if (typeof view === "number") { const { collections, entryIdsNotInCollections } = apiMorph.toCollections(res.data, view) + const effectiveLimit = limit !== undefined ? Math.min(limit, 100) : 20 + const shouldResetCollection = + params.isCollection && !pageParam && entries.length < effectiveLimit await collectionActions.upsertMany(collections, { - reset: params.isCollection && !pageParam, + // A full reset is only safe once the first page proves there are no more collection rows. + reset: shouldResetCollection, }) await collectionActions.delete(entryIdsNotInCollections) } From df8b8d0670acb471384252ec147888d148906e4e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 14:00:05 +0800 Subject: [PATCH 23/29] fix(actions): stabilize rule editor state --- .../src/modules/action/rule-card.test.tsx | 84 +++++++++++++++++++ .../renderer/src/modules/action/rule-card.tsx | 22 ++++- .../src/modules/action/segment.test.tsx | 68 +++++++++++++++ .../src/modules/action/then-section.tsx | 11 ++- .../src/modules/action/when-section.tsx | 13 ++- .../components/src/ui/segment/index.tsx | 14 ++-- .../store/src/modules/action/store.test.ts | 33 ++++++++ .../store/src/modules/action/store.ts | 3 + 8 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/action/rule-card.test.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/action/segment.test.tsx create mode 100644 packages/internal/store/src/modules/action/store.test.ts diff --git a/apps/desktop/layer/renderer/src/modules/action/rule-card.test.tsx b/apps/desktop/layer/renderer/src/modules/action/rule-card.test.tsx new file mode 100644 index 00000000000..cd9642bc7d0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/action/rule-card.test.tsx @@ -0,0 +1,84 @@ +import { useActionStore } from "@follow/store/action/store" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +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 { RuleCard } from "./rule-card" + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + ["useTranslation"]: () => ({ + t: (key: string, options?: Record) => { + if (typeof options?.count === "number") { + return `${key}:${options.count}` + } + return key + }, + }), + } +}) + +vi.mock("~/components/ui/modal/stacked/hooks", () => ({ + ["useDialog"]: () => ({ + ask: vi.fn(), + }), +})) + +vi.mock("../settings/modal/useSettingModal", () => ({ + ["useSettingModal"]: () => vi.fn(), +})) + +describe("RuleCard", () => { + 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 + window.removeEventListener ??= () => {} + }) + + beforeEach(() => { + useActionStore.setState({ + rules: [], + isDirty: false, + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + root = null + container = null + }) + + test("does not crash while a deleted rule card is still mounted", async () => { + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + const queryClient = new QueryClient() + + await expect( + act(async () => { + root!.render( + + + , + ) + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/action/rule-card.tsx b/apps/desktop/layer/renderer/src/modules/action/rule-card.tsx index 760bf95b700..113515a52eb 100644 --- a/apps/desktop/layer/renderer/src/modules/action/rule-card.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/rule-card.tsx @@ -28,6 +28,12 @@ export const RuleCard = ({ defaultOpen = false, onOpenChange, }: RuleCardProps) => { + const ruleExists = useActionRules((rules) => Boolean(rules[index])) + + if (!ruleExists) { + return null + } + if (mode === "compact") { return } @@ -73,6 +79,10 @@ const CompactRuleCard = ({ setOpen(defaultOpen) }, [defaultOpen]) + if (!rule) { + return null + } + const toggle = () => { setOpen((prev) => { const next = !prev @@ -123,12 +133,20 @@ const CompactRuleCard = ({ const RuleCardToolbar = ({ index }: { index: number }) => { const { t } = useTranslation("settings") - const name = useActionRule(index, (a) => a.name) - const disabled = useActionRule(index, (a) => a.result.disabled) + const rule = useActionRule(index) const ruleCount = useActionRules((s) => s.length) const mutation = useUpdateActionsMutation() const { ask } = useDialog() + if (!rule) { + return null + } + + const { + name, + result: { disabled }, + } = rule + const handleDelete = () => { if (ruleCount === 1) { ask({ diff --git a/apps/desktop/layer/renderer/src/modules/action/segment.test.tsx b/apps/desktop/layer/renderer/src/modules/action/segment.test.tsx new file mode 100644 index 00000000000..4f2327e8ba0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/action/segment.test.tsx @@ -0,0 +1,68 @@ +import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.js" +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 } from "vitest" + +const ControlledSegment = ({ value }: { value: string }) => ( + + + + +) + +const getTab = (container: HTMLElement, name: string) => { + const tab = Array.from(container.querySelectorAll("[role=tab]")).find( + (element) => element.textContent?.trim() === name, + ) + + if (!tab) { + throw new Error(`Tab not found: ${name}`) + } + + return tab +} + +describe("SegmentGroup", () => { + 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 + }) + + test("updates the active item when the controlled value changes", async () => { + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + + await act(async () => { + root!.render() + }) + + expect(getTab(container, "Custom Filters").dataset.state).toBe("active") + + await act(async () => { + root!.render() + }) + + expect(getTab(container, "All").dataset.state).toBe("active") + expect(getTab(container, "Custom Filters").dataset.state).toBe("inactive") + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/action/then-section.tsx b/apps/desktop/layer/renderer/src/modules/action/then-section.tsx index af27d82ecb3..5cc22a66d7d 100644 --- a/apps/desktop/layer/renderer/src/modules/action/then-section.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/then-section.tsx @@ -206,9 +206,14 @@ export const ThenSection = ({ index, variant: _variant = "detail" }: ThenSection return (
- - {t("actions.action_card.then_do")} - +
+ + 2 + + + {t("actions.action_card.then_do")} + +
{enabledActions.length > 0 && ( {t("actions.action_card.summary.action_count", { count: enabledActions.length })} diff --git a/apps/desktop/layer/renderer/src/modules/action/when-section.tsx b/apps/desktop/layer/renderer/src/modules/action/when-section.tsx index c74897e3867..1b5cc3ef302 100644 --- a/apps/desktop/layer/renderer/src/modules/action/when-section.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/when-section.tsx @@ -26,7 +26,7 @@ export const WhenSection = ({ index }: WhenSectionProps) => { const { t } = useTranslation("settings") const disabled = useActionRule(index, (a) => a.result.disabled) - const condition = useActionRule(index, (a) => a.condition) + const condition = useActionRule(index, (a) => a.condition) ?? [] const mode = condition.length > 0 ? "filter" : "all" @@ -43,9 +43,14 @@ export const WhenSection = ({ index }: WhenSectionProps) => { return (
- - {t("actions.action_card.when_feeds_match")} - +
+ + 1 + + + {t("actions.action_card.when_feeds_match")} + +
handleModeChange(value as "all" | "filter")} diff --git a/packages/internal/components/src/ui/segment/index.tsx b/packages/internal/components/src/ui/segment/index.tsx index 0372451fc16..49d3abacaa2 100644 --- a/packages/internal/components/src/ui/segment/index.tsx +++ b/packages/internal/components/src/ui/segment/index.tsx @@ -14,7 +14,9 @@ interface SegmentGroupProps { export const SegmentGroup = (props: ComponentType) => { const { onValueChanged, value, className } = props - const [currentValue, setCurrentValue] = useState(value || "") + const isControlled = value !== undefined + const [uncontrolledValue, setUncontrolledValue] = useState(value || "") + const currentValue = isControlled ? value : uncontrolledValue const componentId = useId() return ( @@ -23,13 +25,15 @@ export const SegmentGroup = (props: ComponentType) => { value={useMemo( () => ({ value: currentValue, - setValue: (value) => { - setCurrentValue(value) - onValueChanged?.(value) + setValue: (nextValue) => { + if (!isControlled) { + setUncontrolledValue(nextValue) + } + onValueChanged?.(nextValue) }, componentId, }), - [componentId, currentValue, onValueChanged], + [componentId, currentValue, isControlled, onValueChanged], )} >
{ + beforeEach(() => { + useActionStore.setState({ + rules: [], + isDirty: false, + }) + }) + + test("creates new rules that match all feeds by default", () => { + actionActions.addRule((index) => `Action ${index}`) + + expect(useActionStore.getState().rules[0]).toMatchObject({ + name: "Action 1", + condition: [], + index: 0, + result: {}, + }) + }) + + test("keeps rule indexes stable after deleting and adding rules", () => { + actionActions.addRule((index) => `Action ${index}`) + actionActions.addRule((index) => `Action ${index}`) + + actionActions.deleteRule(0) + actionActions.addRule((index) => `Action ${index}`) + + expect(useActionStore.getState().rules.map((rule) => rule.index)).toEqual([0, 1]) + }) +}) diff --git a/packages/internal/store/src/modules/action/store.ts b/packages/internal/store/src/modules/action/store.ts index 60be51bf9af..c919a8f132d 100644 --- a/packages/internal/store/src/modules/action/store.ts +++ b/packages/internal/store/src/modules/action/store.ts @@ -160,6 +160,9 @@ class ActionActions { deleteRule(index: number) { immerSet((state) => { state.rules.splice(index, 1) + state.rules.forEach((rule, ruleIndex) => { + rule.index = ruleIndex + }) state.isDirty = true }) } From b28d7a0154e56a197a3f7559e3ef05757a485931 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 14:23:20 +0800 Subject: [PATCH 24/29] fix(download): route direct downloads through ota --- apps/landing/package.json | 1 + .../src/app/[locale]/download/page.tsx | 3 +- .../widgets/download/PlatformDownloads.tsx | 19 +-- apps/landing/src/constants/download.test.ts | 35 +++++ apps/landing/src/constants/download.ts | 31 ++-- apps/landing/src/constants/site.ts | 1 - apps/landing/vitest.config.ts | 14 ++ apps/ota/src/__tests__/manifest.test.ts | 100 ++++++++++++ apps/ota/src/index.ts | 2 + apps/ota/src/routes/download.ts | 144 ++++++++++++++++++ 10 files changed, 327 insertions(+), 23 deletions(-) create mode 100644 apps/landing/src/constants/download.test.ts create mode 100644 apps/landing/vitest.config.ts create mode 100644 apps/ota/src/routes/download.ts diff --git a/apps/landing/package.json b/apps/landing/package.json index 8b85c7b05ba..0a2ff086e19 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -10,6 +10,7 @@ "cf:deploy:dev": "pnpm exec vinext deploy --preview", "dev": "cross-env NODE_ENV=development vinext dev -p 4399", "start": "cross-env NODE_ENV=production vinext start -p 4399", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/landing/src/app/[locale]/download/page.tsx b/apps/landing/src/app/[locale]/download/page.tsx index 0d8edbd9ff5..bb913fb5319 100644 --- a/apps/landing/src/app/[locale]/download/page.tsx +++ b/apps/landing/src/app/[locale]/download/page.tsx @@ -31,7 +31,8 @@ export async function generateMetadata({ } export default async function DownloadPage() { - const ua = (await headers()).get('user-agent')?.toLowerCase() + const headerList = await headers() + const ua = headerList.get('user-agent')?.toLowerCase() return ( <> diff --git a/apps/landing/src/components/widgets/download/PlatformDownloads.tsx b/apps/landing/src/components/widgets/download/PlatformDownloads.tsx index dfc5a4cf916..0e46aab4850 100644 --- a/apps/landing/src/components/widgets/download/PlatformDownloads.tsx +++ b/apps/landing/src/components/widgets/download/PlatformDownloads.tsx @@ -20,19 +20,16 @@ type PlatformDownloadsProps = { } const getPlatformGroups = ( + groups: PlatformDownloadGroup[], detectedOS: OS | null, showAllPlatforms: boolean, ): PlatformDownloadGroup[] => { if (!detectedOS) { - return PlatformDownloadGroups + return groups } - const currentPlatform = PlatformDownloadGroups.find( - (group) => group.os === detectedOS, - ) - const otherPlatforms = PlatformDownloadGroups.filter( - (group) => group.os !== detectedOS, - ) + const currentPlatform = groups.find((group) => group.os === detectedOS) + const otherPlatforms = groups.filter((group) => group.os !== detectedOS) return showAllPlatforms ? ([currentPlatform, ...otherPlatforms].filter( @@ -40,7 +37,7 @@ const getPlatformGroups = ( ) as PlatformDownloadGroup[]) : currentPlatform ? [currentPlatform] - : PlatformDownloadGroups + : groups } export const PlatformDownloads: Component = ({ @@ -49,7 +46,11 @@ export const PlatformDownloads: Component = ({ const platformT = useTranslations('download.platforms') const [showAllPlatforms, setShowAllPlatforms] = React.useState(false) - const platformGroups = getPlatformGroups(detectedOS, showAllPlatforms) + const platformGroups = getPlatformGroups( + PlatformDownloadGroups, + detectedOS, + showAllPlatforms, + ) const webTitle = platformT('web.title') const webSubtitle = platformT('web.subtitle') const footer = platformT.rich('footer', { diff --git a/apps/landing/src/constants/download.test.ts b/apps/landing/src/constants/download.test.ts new file mode 100644 index 00000000000..8b24cc74b11 --- /dev/null +++ b/apps/landing/src/constants/download.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { PlatformDownloadGroups } from './download' + +const findChannel = (id: string) => { + const channel = PlatformDownloadGroups.flatMap( + (group) => group.channels, + ).find((item) => item.id === id) + + if (!channel) { + throw new Error(`Missing download channel: ${id}`) + } + + return channel +} + +describe('PlatformDownloadGroups', () => { + it('uses the APK asset for Android direct downloads', () => { + expect(findChannel('android-apk').href).toBe( + 'https://ota.folo.is/download/mobile/android/apk', + ) + }) + + it('uses desktop installer assets for desktop direct downloads', () => { + expect(findChannel('macos-dmg').href).toBe( + 'https://ota.folo.is/download/desktop/macos/dmg', + ) + expect(findChannel('windows-exe').href).toBe( + 'https://ota.folo.is/download/desktop/windows/exe', + ) + expect(findChannel('linux-appimage').href).toBe( + 'https://ota.folo.is/download/desktop/linux/appimage', + ) + }) +}) diff --git a/apps/landing/src/constants/download.ts b/apps/landing/src/constants/download.ts index 506426be7df..c4062527e12 100644 --- a/apps/landing/src/constants/download.ts +++ b/apps/landing/src/constants/download.ts @@ -1,5 +1,3 @@ -import { siteInfo } from './site' - export type OS = 'iOS' | 'Android' | 'macOS' | 'Windows' | 'Linux' export type PlatformDownloadChannel = { @@ -16,6 +14,15 @@ export type PlatformDownloadGroup = { channels: PlatformDownloadChannel[] } +const OTA_DOWNLOAD_URL = 'https://ota.folo.is/download' + +export const DEFAULT_RELEASE_DOWNLOAD_LINKS = { + androidApk: `${OTA_DOWNLOAD_URL}/mobile/android/apk`, + linuxAppImage: `${OTA_DOWNLOAD_URL}/desktop/linux/appimage`, + macosDmg: `${OTA_DOWNLOAD_URL}/desktop/macos/dmg`, + windowsExe: `${OTA_DOWNLOAD_URL}/desktop/windows/exe`, +} as const + export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ { os: 'iOS', @@ -43,9 +50,9 @@ export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ }, { id: 'android-apk', - href: siteInfo.releaseLink, + href: DEFAULT_RELEASE_DOWNLOAD_LINKS.androidApk, name: 'Direct Download (APK)', - description: 'Install from the latest GitHub release', + description: 'Download the latest APK', }, ], }, @@ -62,9 +69,9 @@ export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ }, { id: 'macos-dmg', - href: siteInfo.releaseLink, + href: DEFAULT_RELEASE_DOWNLOAD_LINKS.macosDmg, name: 'Direct Download (DMG)', - description: 'Install from the latest GitHub release', + description: 'Download the latest DMG', }, ], }, @@ -81,9 +88,9 @@ export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ }, { id: 'windows-exe', - href: siteInfo.releaseLink, + href: DEFAULT_RELEASE_DOWNLOAD_LINKS.windowsExe, name: 'Direct Download (EXE)', - description: 'Install from the latest GitHub release', + description: 'Download the latest EXE', }, ], }, @@ -93,10 +100,10 @@ export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ icon: 'i-simple-icons-linux', channels: [ { - id: 'linux-github', - href: siteInfo.releaseLink, - name: 'GitHub Release', - description: 'AppImage and other release assets', + id: 'linux-appimage', + href: DEFAULT_RELEASE_DOWNLOAD_LINKS.linuxAppImage, + name: 'Direct Download (AppImage)', + description: 'Download the latest AppImage', }, ], }, diff --git a/apps/landing/src/constants/site.ts b/apps/landing/src/constants/site.ts index 53a22595b39..b59740fa7f2 100644 --- a/apps/landing/src/constants/site.ts +++ b/apps/landing/src/constants/site.ts @@ -11,7 +11,6 @@ export const siteInfo = { xLink: 'https://x.com/folo_is', discordLink: 'https://discord.gg/followapp', productHuntLink: 'https://www.producthunt.com/posts/follow-5', - releaseLink: 'https://github.com/RSSNext/Folo/releases/latest', navigation: [ { title: 'Features', href: '/#features' }, { title: 'Testimonials', href: '/#testimonials' }, diff --git a/apps/landing/vitest.config.ts b/apps/landing/vitest.config.ts new file mode 100644 index 00000000000..6b29d134c16 --- /dev/null +++ b/apps/landing/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '~': fileURLToPath(new URL('src', import.meta.url)), + }, + }, + test: { + environment: 'node', + }, +}) diff --git a/apps/ota/src/__tests__/manifest.test.ts b/apps/ota/src/__tests__/manifest.test.ts index e6307c9582a..2f4f5d61226 100644 --- a/apps/ota/src/__tests__/manifest.test.ts +++ b/apps/ota/src/__tests__/manifest.test.ts @@ -480,6 +480,106 @@ describe("/manifest", () => { }) }) +describe("/download", () => { + it("redirects desktop Windows downloads to the installer from OTA metadata", async () => { + const response = await fetchWorker("/download/desktop/windows/exe", undefined, { + kvEntries: new Map([ + [ + KV_KEYS.policy("desktop", "stable", "direct"), + { + releaseVersion: "1.5.1", + required: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + publishedAt: "2026-04-11T10:00:00Z", + distribution: "direct", + downloadUrl: null, + storeUrl: null, + }, + ], + [KV_KEYS.release("desktop", "1.5.1"), createDesktopRelease()], + ]), + }) + + expect(response.status).toBe(302) + expect(response.headers.get("location")).toBe( + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-windows-x64.exe", + ) + }) + + it("redirects desktop macOS downloads to the dmg from OTA metadata", async () => { + const response = await fetchWorker("/download/desktop/macos/dmg", undefined, { + kvEntries: new Map([ + [ + KV_KEYS.policy("desktop", "stable", "direct"), + { + releaseVersion: "1.5.1", + required: false, + minSupportedBinaryVersion: "1.5.0", + message: null, + publishedAt: "2026-04-11T10:00:00Z", + distribution: "direct", + downloadUrl: null, + storeUrl: null, + }, + ], + [ + KV_KEYS.release("desktop", "1.5.1"), + createDesktopRelease({ + desktop: { + ...createDesktopRelease().desktop, + app: { + platforms: { + ...createDesktopRelease().desktop.app!.platforms, + macos: { + ...createDesktopRelease().desktop.app!.platforms.macos!, + files: [ + { + filename: "Folo-1.5.1-macos-arm64.dmg", + sha512: "f".repeat(88), + size: 654321, + downloadUrl: + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-macos-arm64.dmg", + }, + ...createDesktopRelease().desktop.app!.platforms.macos!.files, + ], + }, + }, + }, + }, + }), + ], + ]), + }) + + expect(response.status).toBe(302) + expect(response.headers.get("location")).toBe( + "https://github.com/RSSNext/Folo/releases/download/desktop/v1.5.1/Folo-1.5.1-macos-arm64.dmg", + ) + }) + + it("redirects Android APK downloads from the cached OTA release version", async () => { + const response = await fetchWorker("/download/mobile/android/apk", undefined, { + kvEntries: new Map([ + [ + KV_KEYS.latestReleaseVersion("mobile"), + { + product: "mobile", + version: "0.5.0", + publishedAt: "2026-04-15T04:49:25Z", + tag: "mobile/v0.5.0", + }, + ], + ]), + }) + + expect(response.status).toBe(302) + expect(response.headers.get("location")).toBe( + "https://github.com/RSSNext/Folo/releases/download/mobile/v0.5.0/build.apk", + ) + }) +}) + describe("/assets/*", () => { it("returns 404 when the asset is missing", async () => { const response = await fetchWorker( diff --git a/apps/ota/src/index.ts b/apps/ota/src/index.ts index ffbca3668db..3628f048604 100644 --- a/apps/ota/src/index.ts +++ b/apps/ota/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono" import type { Env } from "./env" import { syncGitHubReleases, syncStoreVersions } from "./lib/sync" import { assetsRoute } from "./routes/assets" +import { downloadRoute } from "./routes/download" import { internalRoute } from "./routes/internal" import { manifestRoute } from "./routes/manifest" import { policyRoute } from "./routes/policy" @@ -12,6 +13,7 @@ export const app = new Hono<{ Bindings: Env }>() app.route("/", manifestRoute) app.route("/", assetsRoute) +app.route("/", downloadRoute) app.route("/", policyRoute) app.route("/", versionsRoute) app.route("/", internalRoute) diff --git a/apps/ota/src/routes/download.ts b/apps/ota/src/routes/download.ts new file mode 100644 index 00000000000..fd520424f2c --- /dev/null +++ b/apps/ota/src/routes/download.ts @@ -0,0 +1,144 @@ +import { Hono } from "hono" + +import type { Env } from "../env" +import { KV_KEYS } from "../lib/constants" +import { getBinaryPolicyRecord, getLatestReleaseVersionRecord } from "../lib/kv" +import type { DesktopOtaRelease } from "../lib/schema" +import { otaReleaseSchema } from "../lib/schema" + +type DesktopDownloadTarget = "macos-dmg" | "windows-exe" | "linux-appimage" +type DesktopApp = NonNullable +type DesktopAppPlatform = NonNullable + +const desktopDownloadTargets = { + "macos-dmg": { + platform: "macos", + patterns: [/macos.*arm64.*\.dmg$/i, /\.dmg$/i], + }, + "windows-exe": { + platform: "windows", + patterns: [/windows.*x64.*\.exe$/i, /\.exe$/i], + }, + "linux-appimage": { + platform: "linux", + patterns: [/linux.*x64.*\.AppImage$/i, /\.AppImage$/i], + }, +} as const satisfies Record< + DesktopDownloadTarget, + { + platform: keyof DesktopApp["platforms"] + patterns: RegExp[] + } +> + +export const downloadRoute = new Hono<{ Bindings: Env }>() + +downloadRoute.get("/download/desktop/macos/dmg", async (c) => + redirectDesktopDownload(c.env.OTA_KV, "macos-dmg", c.req.query("channel") ?? "stable"), +) + +downloadRoute.get("/download/desktop/windows/exe", async (c) => + redirectDesktopDownload(c.env.OTA_KV, "windows-exe", c.req.query("channel") ?? "stable"), +) + +downloadRoute.get("/download/desktop/linux/appimage", async (c) => + redirectDesktopDownload(c.env.OTA_KV, "linux-appimage", c.req.query("channel") ?? "stable"), +) + +downloadRoute.get("/download/mobile/android/apk", async (c) => { + const versionRecord = await getLatestReleaseVersionRecord(c.env.OTA_KV, "mobile") + + if (!versionRecord) { + return c.json({ error: "Android APK version is unavailable" }, 404) + } + + return redirectToDownload( + `https://github.com/RSSNext/Folo/releases/download/mobile/v${versionRecord.version}/build.apk`, + ) +}) + +async function redirectDesktopDownload( + kv: KVNamespace, + targetName: DesktopDownloadTarget, + channel: string, +) { + const target = desktopDownloadTargets[targetName] + const release = await getLatestDesktopDownloadRelease(kv, channel) + const platformPayload = release?.desktop.app?.platforms[target.platform] + + if (!platformPayload) { + return new Response(JSON.stringify({ error: "Desktop installer is unavailable" }), { + headers: { + "content-type": "application/json; charset=utf-8", + }, + status: 404, + }) + } + + const file = findDesktopInstaller(platformPayload.files, target.patterns) + if (!file) { + return new Response(JSON.stringify({ error: "Desktop installer is unavailable" }), { + headers: { + "content-type": "application/json; charset=utf-8", + }, + status: 404, + }) + } + + return redirectToDownload(file.downloadUrl) +} + +async function getLatestDesktopDownloadRelease(kv: KVNamespace, channel: string) { + const policyRecord = + (await getBinaryPolicyRecord(kv, { + product: "desktop", + channel, + distribution: "direct", + })) ?? + (await getBinaryPolicyRecord(kv, { + product: "desktop", + channel, + })) + + const releaseVersion = + policyRecord?.releaseVersion ?? + (await getLatestReleaseVersionRecord(kv, "desktop"))?.version ?? + null + + if (!releaseVersion) { + return null + } + + const releaseRecord = await kv.get(KV_KEYS.release("desktop", releaseVersion), "json") + if (!releaseRecord) { + return null + } + + const parsedRelease = otaReleaseSchema.safeParse(releaseRecord) + if (!parsedRelease.success || parsedRelease.data.product !== "desktop") { + return null + } + + return parsedRelease.data +} + +function findDesktopInstaller(files: DesktopAppPlatform["files"], patterns: RegExp[]) { + for (const pattern of patterns) { + const file = files.find((item) => pattern.test(item.filename)) + if (file) { + return file + } + } + + return null +} + +function redirectToDownload(url: string) { + return new Response(null, { + headers: { + "cache-control": "public, max-age=300", + location: url, + }, + status: 302, + }) +} From d3b1e93cac9f8eeaa6c4135cc477a233e3f943c4 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 14:47:41 +0800 Subject: [PATCH 25/29] fix(release): always build mobile release apk --- .github/scripts/resolve-mobile-release-config.mjs | 3 +++ .github/scripts/resolve-mobile-release-config.test.ts | 4 +++- .github/workflows/tag.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/scripts/resolve-mobile-release-config.mjs b/.github/scripts/resolve-mobile-release-config.mjs index c798080671b..7fd94c7d683 100644 --- a/.github/scripts/resolve-mobile-release-config.mjs +++ b/.github/scripts/resolve-mobile-release-config.mjs @@ -25,6 +25,7 @@ export function resolveMobileReleaseConfig(input) { if (config.mode === "store") { return { triggerStoreBuilds: true, + triggerReleaseApk: true, triggerOtaPublish: false, runtimeVersion: null, channel: null, @@ -42,6 +43,7 @@ export function resolveMobileReleaseConfig(input) { return { triggerStoreBuilds: false, + triggerReleaseApk: true, triggerOtaPublish: true, runtimeVersion: config.runtimeVersion, channel: config.channel, @@ -72,6 +74,7 @@ async function main() { }) setGitHubOutput("trigger_store_builds", String(result.triggerStoreBuilds)) + setGitHubOutput("trigger_release_apk", String(result.triggerReleaseApk)) setGitHubOutput("trigger_ota_publish", String(result.triggerOtaPublish)) setGitHubOutput("runtime_version", result.runtimeVersion ?? "") setGitHubOutput("channel", result.channel ?? "") diff --git a/.github/scripts/resolve-mobile-release-config.test.ts b/.github/scripts/resolve-mobile-release-config.test.ts index f83c7398251..04e8dc0cc4f 100644 --- a/.github/scripts/resolve-mobile-release-config.test.ts +++ b/.github/scripts/resolve-mobile-release-config.test.ts @@ -16,6 +16,7 @@ describe("resolveMobileReleaseConfig", () => { }), ).toEqual({ triggerStoreBuilds: true, + triggerReleaseApk: true, triggerOtaPublish: false, runtimeVersion: null, channel: null, @@ -23,7 +24,7 @@ describe("resolveMobileReleaseConfig", () => { }) }) - it("triggers OTA publish only for ota mode", async () => { + it("triggers OTA publish and release APK builds for ota mode", async () => { const { resolveMobileReleaseConfig } = await import("./resolve-mobile-release-config.mjs") expect( @@ -38,6 +39,7 @@ describe("resolveMobileReleaseConfig", () => { }), ).toEqual({ triggerStoreBuilds: false, + triggerReleaseApk: true, triggerOtaPublish: true, runtimeVersion: "0.4.1", channel: "production", diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index a37a6d53bf6..e3ba7489771 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -168,7 +168,7 @@ jobs: console.log('Desktop store build triggered successfully'); - name: Trigger Mobile Production APK Release Build - if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' && steps.release_mode.outputs.trigger_store_builds == 'true' + if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main' && steps.release_mode.outputs.trigger_release_apk == 'true' uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} From bf4f91fe9110d42b2ca8fa3f1a68878ba6afb06a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 15:18:57 +0800 Subject: [PATCH 26/29] docs(desktop): prepare release inputs --- apps/desktop/changelog/1.8.0.md | 13 +++++++++++++ apps/desktop/changelog/next.md | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/changelog/1.8.0.md diff --git a/apps/desktop/changelog/1.8.0.md b/apps/desktop/changelog/1.8.0.md new file mode 100644 index 00000000000..210bba08f0f --- /dev/null +++ b/apps/desktop/changelog/1.8.0.md @@ -0,0 +1,13 @@ +# What's new in v1.8.0 + +## Shiny new things + +- Added an Eagle action to image context menus + +## No longer broken + +- Fixed tray menu refreshes losing the active tray instance +- Fixed desktop auth cookies missing max-age persistence +- Fixed unavailable AI summaries breaking summary state +- Fixed final entries and mark-read footers near the end of entry lists +- Delayed feed error indicators to avoid premature error states diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449a4..1ddc33dcbcf 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,11 +1,19 @@ # What's new in vNEXT_VERSION -## Shiny new things - ## Improvements -## No longer broken +- Upgraded AI summaries to a more powerful Kimi model +- Improved Obsidian integration metadata and markdown handling +- Improved scroll mark-read reliability with batched updates, retry handling, and local read-state preservation +- Improved action rule editor state stability +- Routed direct desktop downloads through OTA-backed delivery -## Thanks +## No longer broken -Special thanks to volunteer contributors @ for their valuable contributions +- Fixed stale desktop context menus after web menu changes +- Fixed native image context menus being replaced unexpectedly +- Fixed duplicate timeline page requests +- Fixed timeline refreshes not returning to the top first +- Fixed starred collection pagination order +- Fixed Obsidian file path separator handling +- Fixed unread counts after time-limited mark-read actions From 760d6400f0897df5b5dda7fcf6eaff5abc2e06d1 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 15:19:12 +0800 Subject: [PATCH 27/29] docs(mobile): prepare release metadata --- apps/mobile/changelog/next.md | 13 ++++++++----- apps/mobile/release-plan.json | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 000f858e3d2..af22826ec07 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,11 +1,14 @@ # What's New in vNEXT_VERSION -## Shiny new things - ## Improvements -## No longer broken +- Upgraded AI summaries to a more powerful Kimi model +- Improved scroll mark-read reliability with batched updates, retry handling, and local read-state preservation +- Improved action rule editor state stability +- Routed direct downloads through OTA-backed delivery -## Thanks +## No longer broken -Special thanks to volunteer contributors @ for their valuable contributions +- Fixed duplicate shared links from mobile share actions +- Fixed starred collection pagination order +- Fixed unread counts after time-limited mark-read actions diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 0a4e0fb6f6f..9c818fa2c24 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "store", - "runtimeVersion": null, - "channel": null + "mode": "ota", + "runtimeVersion": "0.5.3", + "channel": "production" } From 57741fe8334e0fc7fa835c10d11934d35af844bd Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 15:31:55 +0800 Subject: [PATCH 28/29] release(desktop): release v1.9.0 --- apps/desktop/changelog/1.9.0.md | 19 +++++++++++++++++++ apps/desktop/changelog/next.md | 18 +++++------------- apps/desktop/package.json | 6 +++--- apps/desktop/release.json | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/changelog/1.9.0.md diff --git a/apps/desktop/changelog/1.9.0.md b/apps/desktop/changelog/1.9.0.md new file mode 100644 index 00000000000..badd3ea8524 --- /dev/null +++ b/apps/desktop/changelog/1.9.0.md @@ -0,0 +1,19 @@ +# What's new in v1.9.0 + +## Improvements + +- Upgraded AI summaries to a more powerful Kimi model +- Improved Obsidian integration metadata and markdown handling +- Improved scroll mark-read reliability with batched updates, retry handling, and local read-state preservation +- Improved action rule editor state stability +- Routed direct desktop downloads through OTA-backed delivery + +## No longer broken + +- Fixed stale desktop context menus after web menu changes +- Fixed native image context menus being replaced unexpectedly +- Fixed duplicate timeline page requests +- Fixed timeline refreshes not returning to the top first +- Fixed starred collection pagination order +- Fixed Obsidian file path separator handling +- Fixed unread counts after time-limited mark-read actions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 1ddc33dcbcf..8f5eac449a4 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,19 +1,11 @@ # What's new in vNEXT_VERSION -## Improvements +## Shiny new things -- Upgraded AI summaries to a more powerful Kimi model -- Improved Obsidian integration metadata and markdown handling -- Improved scroll mark-read reliability with batched updates, retry handling, and local read-state preservation -- Improved action rule editor state stability -- Routed direct desktop downloads through OTA-backed delivery +## Improvements ## No longer broken -- Fixed stale desktop context menus after web menu changes -- Fixed native image context menus being replaced unexpectedly -- Fixed duplicate timeline page requests -- Fixed timeline refreshes not returning to the top first -- Fixed starred collection pagination order -- Fixed Obsidian file path separator handling -- Fixed unread counts after time-limited mark-read actions +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64bee6f9e7b..6244d99f6bb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -96,7 +96,7 @@ "vite-plugin-route-builder": "0.4.1", "vite-tsconfig-paths": "6.1.1" }, - "runtimeVersion": "1.8.0", + "runtimeVersion": "1.9.0", "productName": "Folo", - "mainHash": "a8b359bbef0d2c49abfae0524d5f13ac5aee26a4b60d0691cca9e9ccc3caddcf" + "mainHash": "0ccdf61724c1dac73e38b9267958138671911ef8cf87830abb9451c2729b9211" } diff --git a/apps/desktop/release.json b/apps/desktop/release.json index baaa57b92c9..fcc73b649b9 100644 --- a/apps/desktop/release.json +++ b/apps/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.8.0", + "version": "1.9.0", "mode": "build", "runtimeVersion": null, "channel": null From aaa9916b0a381e452808ff3d1b1938da9d53c828 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 29 May 2026 15:32:48 +0800 Subject: [PATCH 29/29] docs(desktop): restore mobile release inputs --- apps/mobile/changelog/next.md | 13 +++++-------- apps/mobile/release-plan.json | 6 +++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index af22826ec07..000f858e3d2 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,14 +1,11 @@ # What's New in vNEXT_VERSION -## Improvements +## Shiny new things -- Upgraded AI summaries to a more powerful Kimi model -- Improved scroll mark-read reliability with batched updates, retry handling, and local read-state preservation -- Improved action rule editor state stability -- Routed direct downloads through OTA-backed delivery +## Improvements ## No longer broken -- Fixed duplicate shared links from mobile share actions -- Fixed starred collection pagination order -- Fixed unread counts after time-limited mark-read actions +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 9c818fa2c24..0a4e0fb6f6f 100644 --- a/apps/mobile/release-plan.json +++ b/apps/mobile/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "0.5.3", - "channel": "production" + "mode": "store", + "runtimeVersion": null, + "channel": null }