From 5d6b5ed40fa059bf42d41ac7340f45e61d42a9e5 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:58:32 +0800 Subject: [PATCH 01/24] release(mobile): release v0.5.5 --- apps/mobile/changelog/0.5.5.md | 12 ++++++++++++ apps/mobile/changelog/next.md | 11 +++++------ apps/mobile/ios/Folo/Info.plist | 4 ++-- apps/mobile/package.json | 2 +- apps/mobile/release-plan.json | 6 +++--- apps/mobile/release.json | 4 ++-- 6 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/changelog/0.5.5.md diff --git a/apps/mobile/changelog/0.5.5.md b/apps/mobile/changelog/0.5.5.md new file mode 100644 index 00000000000..3c99ff2ec25 --- /dev/null +++ b/apps/mobile/changelog/0.5.5.md @@ -0,0 +1,12 @@ +# What's New in v0.5.5 + +## No longer broken + +- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) +- Fixed timeline refreshes returning to the top before new content is rendered +- Fixed mark-read state changes while the timeline is being reset +- Fixed social timeline scroll reset preservation across mobile list layouts + +## Thanks + +Special thanks to volunteer contributor @TonyRL for the nested code block fix diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 4165126b6db..000f858e3d2 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -1,12 +1,11 @@ # What's New in vNEXT_VERSION -## No longer broken +## Shiny new things + +## Improvements -- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) -- Fixed timeline refreshes returning to the top before new content is rendered -- Fixed mark-read state changes while the timeline is being reset -- Fixed social timeline scroll reset preservation across mobile list layouts +## No longer broken ## Thanks -Special thanks to volunteer contributor @TonyRL for the nested code block fix +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index 7afb39911d9..6680d0043cb 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.5.4 + 0.5.5 CFBundleSignature ???? CFBundleURLTypes @@ -54,7 +54,7 @@ CFBundleVersion - 7 + 8 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6716a87cd6d..500564b5cfe 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.5.4", + "version": "0.5.5", "private": true, "main": "src/main.tsx", "scripts": { diff --git a/apps/mobile/release-plan.json b/apps/mobile/release-plan.json index 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 4bdf69bbbb8..e8eb0ec2e46 100644 --- a/apps/mobile/release.json +++ b/apps/mobile/release.json @@ -1,6 +1,6 @@ { - "version": "0.5.4", + "version": "0.5.5", "mode": "ota", - "runtimeVersion": "0.5.3", + "runtimeVersion": "0.5.0", "channel": "production" } From aa91b25bffee0fed13f413476d64884821f6bd60 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 14:59:02 +0800 Subject: [PATCH 02/24] docs(mobile): restore desktop release inputs --- apps/desktop/changelog/next.md | 15 +++++---------- apps/desktop/release-plan.json | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 743f6b5f0c0..8f5eac449a4 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,16 +1,11 @@ # What's new in vNEXT_VERSION -## No longer broken +## Shiny new things + +## Improvements -- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog) -- Fixed social timeline scroll reset on view changes -- Fixed timeline refreshes returning to the top before new content is rendered -- Fixed mark-read state changes while the timeline is being reset -- Fixed the recent reader spacer appearing when there are no recent entries -- Fixed reading mode content disappearing after translation updates -- Fixed streaming TTS scheduling stability -- Fixed the Spline AI indicator rendering while idle +## No longer broken ## Thanks -Special thanks to volunteer contributor @TonyRL for the nested code block fix +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/release-plan.json b/apps/desktop/release-plan.json index e340e3bfd69..da0a2c1c6d0 100644 --- a/apps/desktop/release-plan.json +++ b/apps/desktop/release-plan.json @@ -1,5 +1,5 @@ { - "mode": "ota", - "runtimeVersion": "1.9.0", - "channel": "stable" + "mode": "build", + "runtimeVersion": null, + "channel": null } From c782efa65e15601a762daf30b4430a34dfe62602 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 22 Jun 2026 20:57:58 +0800 Subject: [PATCH 03/24] fix(landing): bundle legal markdown content --- apps/landing/global.d.ts | 2 ++ .../src/app/[locale]/privacy-policy/page.tsx | 3 +- .../app/[locale]/terms-of-service/page.tsx | 3 +- .../ui/markdown/get-markdown-content.ts | 35 +++++++++++++++++++ .../src/components/ui/markdown/index.tsx | 32 ----------------- .../components/ui/markdown/markdown.test.ts | 19 ++++++++++ 6 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 apps/landing/src/components/ui/markdown/get-markdown-content.ts create mode 100644 apps/landing/src/components/ui/markdown/markdown.test.ts diff --git a/apps/landing/global.d.ts b/apps/landing/global.d.ts index bc4a25e689d..2378c3a1586 100644 --- a/apps/landing/global.d.ts +++ b/apps/landing/global.d.ts @@ -1,3 +1,5 @@ +/// + /* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/method-signature-style */ import type { FC, PropsWithChildren } from 'react' diff --git a/apps/landing/src/app/[locale]/privacy-policy/page.tsx b/apps/landing/src/app/[locale]/privacy-policy/page.tsx index a9e60ec30bc..42233aa7012 100644 --- a/apps/landing/src/app/[locale]/privacy-policy/page.tsx +++ b/apps/landing/src/app/[locale]/privacy-policy/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next/types' -import { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown' +import { MarkdownContent } from '~/components/ui/markdown' +import { getMarkdownContent } from '~/components/ui/markdown/get-markdown-content' import { siteInfo } from '~/constants/site' export const metadata: Metadata = { diff --git a/apps/landing/src/app/[locale]/terms-of-service/page.tsx b/apps/landing/src/app/[locale]/terms-of-service/page.tsx index 347469b27a0..2b8f336263f 100644 --- a/apps/landing/src/app/[locale]/terms-of-service/page.tsx +++ b/apps/landing/src/app/[locale]/terms-of-service/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next/types' -import { getMarkdownContent, MarkdownContent } from '~/components/ui/markdown' +import { MarkdownContent } from '~/components/ui/markdown' +import { getMarkdownContent } from '~/components/ui/markdown/get-markdown-content' import { siteInfo } from '~/constants/site' export const metadata: Metadata = { diff --git a/apps/landing/src/components/ui/markdown/get-markdown-content.ts b/apps/landing/src/components/ui/markdown/get-markdown-content.ts new file mode 100644 index 00000000000..7f02decbab1 --- /dev/null +++ b/apps/landing/src/components/ui/markdown/get-markdown-content.ts @@ -0,0 +1,35 @@ +import rehypeStringify from 'rehype-stringify' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import { unified } from 'unified' + +import privacyPolicyMarkdown from '~/legal/privacy.md?raw' +import termsOfServiceMarkdown from '~/legal/tos.md?raw' + +const markdownContentByPath = { + 'legal/privacy.md': privacyPolicyMarkdown, + 'legal/tos.md': termsOfServiceMarkdown, +} as const + +export type MarkdownFilePath = keyof typeof markdownContentByPath + +export async function getMarkdownContent(filePath: MarkdownFilePath) { + try { + const fileContents = markdownContentByPath[filePath] + + const processedContent = await unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeStringify, { allowDangerousHtml: true }) + .process(fileContents) + + return { + content: processedContent.toString(), + } + } catch (error) { + console.error('Error reading markdown file:', error) + return { + content: '

Error loading content. Please try again later.

', + } + } +} diff --git a/apps/landing/src/components/ui/markdown/index.tsx b/apps/landing/src/components/ui/markdown/index.tsx index 31275a8dc1a..4701095443c 100644 --- a/apps/landing/src/components/ui/markdown/index.tsx +++ b/apps/landing/src/components/ui/markdown/index.tsx @@ -1,11 +1,3 @@ -import fs from 'node:fs' - -import { join } from 'pathe' -import rehypeStringify from 'rehype-stringify' -import remarkParse from 'remark-parse' -import remarkRehype from 'remark-rehype' -import { unified } from 'unified' - interface MarkdownContentProps { content: string } @@ -25,27 +17,3 @@ export function MarkdownContent({ content }: MarkdownContentProps) { ) } - -// Utility function to read and process markdown files -export async function getMarkdownContent(filePath: string) { - try { - const fullPath = join(process.cwd(), 'src', filePath) - const fileContents = fs.readFileSync(fullPath, 'utf8') - - // Process markdown to HTML using unified - const processedContent = await unified() - .use(remarkParse) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeStringify, { allowDangerousHtml: true }) - .process(fileContents) - - return { - content: processedContent.toString(), - } - } catch (error) { - console.error('Error reading markdown file:', error) - return { - content: '

Error loading content. Please try again later.

', - } - } -} diff --git a/apps/landing/src/components/ui/markdown/markdown.test.ts b/apps/landing/src/components/ui/markdown/markdown.test.ts new file mode 100644 index 00000000000..2783e4c86aa --- /dev/null +++ b/apps/landing/src/components/ui/markdown/markdown.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { getMarkdownContent } from './get-markdown-content' + +describe('getMarkdownContent', () => { + it('renders the privacy policy markdown content', async () => { + const { content } = await getMarkdownContent('legal/privacy.md') + + expect(content).toContain('

Privacy Policy

') + expect(content).not.toContain('Error loading content') + }) + + it('renders the terms of service markdown content', async () => { + const { content } = await getMarkdownContent('legal/tos.md') + + expect(content).toContain('

Terms of Service

') + expect(content).not.toContain('Error loading content') + }) +}) From 54447e6faaa21b87bb8f9bb1066b53ce9213697a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:20:14 +0800 Subject: [PATCH 04/24] build(deps): bump actions/cache from 5 to 6 (#5034) Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-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 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index f936b103baf..9b3c504fe1e 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -21,7 +21,7 @@ jobs: with: lfs: true - name: Cache turbo build setup - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml index d91e262bd07..8774865084d 100644 --- a/.github/workflows/deploy-cloudflare-desktop.yml +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -25,7 +25,7 @@ jobs: run: git lfs checkout - name: Cache turbo build setup - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/workflows/deploy-cloudflare-landing.yml b/.github/workflows/deploy-cloudflare-landing.yml index b7894079d87..a9cd903145b 100644 --- a/.github/workflows/deploy-cloudflare-landing.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -29,7 +29,7 @@ jobs: run: git lfs checkout - name: Cache turbo build setup - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml index d053331e4e6..1671fe55803 100644 --- a/.github/workflows/deploy-cloudflare-ssr.yml +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -32,7 +32,7 @@ jobs: run: git lfs checkout - name: Cache turbo build setup - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f4016fa50e..ff1af2bad4b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,7 +26,7 @@ jobs: with: lfs: true - name: Cache turbo build setup - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} From 72e289bb3e8df46cadde80a55c6288a12f0d3cbc Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Jul 2026 16:46:26 +0800 Subject: [PATCH 05/24] fix(tracker): sample PostHog events at five percent --- .../renderer/src/initialize/analytics.ts | 8 ++++++- apps/mobile/src/initialize/analytics.ts | 8 ++++++- .../internal/tracker/src/adapters/index.ts | 7 +++++- .../tracker/src/adapters/posthog.test.ts | 22 +++++++++++++++++++ .../internal/tracker/src/adapters/posthog.ts | 19 ++++++++++++++++ packages/internal/tracker/src/index.ts | 2 ++ 6 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 packages/internal/tracker/src/adapters/posthog.test.ts diff --git a/apps/desktop/layer/renderer/src/initialize/analytics.ts b/apps/desktop/layer/renderer/src/initialize/analytics.ts index cd027c7c392..02e18d7dbbe 100644 --- a/apps/desktop/layer/renderer/src/initialize/analytics.ts +++ b/apps/desktop/layer/renderer/src/initialize/analytics.ts @@ -1,5 +1,10 @@ import { env } from "@follow/shared/env.desktop" -import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker" +import { + createPostHogBeforeSend, + setFirebaseTracker, + setPostHogTracker, + tracker, +} from "@follow/tracker" import { captureAttributionFromURL, getAttributionForAnalytics } from "@follow/utils" import type { AuthSessionResponse } from "@follow-app/client-sdk" import posthog from "posthog-js" @@ -30,6 +35,7 @@ export const initAnalytics = async () => { api_host: env.VITE_POSTHOG_HOST, person_profiles: "identified_only", defaults: "2025-05-24", + before_send: createPostHogBeforeSend(), capture_exceptions: { capture_unhandled_errors: true, capture_unhandled_rejections: true, diff --git a/apps/mobile/src/initialize/analytics.ts b/apps/mobile/src/initialize/analytics.ts index b182cceba1b..d123a928f85 100644 --- a/apps/mobile/src/initialize/analytics.ts +++ b/apps/mobile/src/initialize/analytics.ts @@ -1,5 +1,10 @@ import { whoami } from "@follow/store/user/getters" -import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker" +import { + createPostHogBeforeSend, + setFirebaseTracker, + setPostHogTracker, + tracker, +} from "@follow/tracker" import type { AuthUser } from "@follow-app/client-sdk" import { getAnalytics } from "@react-native-firebase/analytics" import { nativeApplicationVersion, nativeBuildVersion } from "expo-application" @@ -14,6 +19,7 @@ export const initAnalytics = async () => { setPostHogTracker( new PostHog(proxyEnv.POSTHOG_KEY, { host: proxyEnv.POSTHOG_HOST, + before_send: createPostHogBeforeSend(), errorTracking: { autocapture: { uncaughtExceptions: true, diff --git a/packages/internal/tracker/src/adapters/index.ts b/packages/internal/tracker/src/adapters/index.ts index d8958fc1de8..9973772b11b 100644 --- a/packages/internal/tracker/src/adapters/index.ts +++ b/packages/internal/tracker/src/adapters/index.ts @@ -1,4 +1,9 @@ export type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base" export { FirebaseAdapter, type FirebaseAdapterConfig } from "./firebase" -export { PostHogAdapter, type PostHogAdapterConfig } from "./posthog" +export { + createPostHogBeforeSend, + POSTHOG_SAMPLE_RATE, + PostHogAdapter, + type PostHogAdapterConfig, +} from "./posthog" export { ProxyAdapter } from "./proxy" diff --git a/packages/internal/tracker/src/adapters/posthog.test.ts b/packages/internal/tracker/src/adapters/posthog.test.ts new file mode 100644 index 00000000000..a07064e3720 --- /dev/null +++ b/packages/internal/tracker/src/adapters/posthog.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" + +import { createPostHogBeforeSend, POSTHOG_SAMPLE_RATE } from "./posthog" + +describe("PostHogAdapter", () => { + it("uses a 5% PostHog sample rate", () => { + expect(POSTHOG_SAMPLE_RATE).toBe(0.05) + }) + + it("drops PostHog events when outside the configured sample rate", () => { + const beforeSend = createPostHogBeforeSend(0.05, () => 0.5) + + expect(beforeSend({ event: "app init" })).toBeNull() + }) + + it("keeps PostHog events when inside the configured sample rate", () => { + const beforeSend = createPostHogBeforeSend(0.05, () => 0.04) + const event = { event: "app init" } + + expect(beforeSend(event)).toBe(event) + }) +}) diff --git a/packages/internal/tracker/src/adapters/posthog.ts b/packages/internal/tracker/src/adapters/posthog.ts index 715f317d339..083d0340d4b 100644 --- a/packages/internal/tracker/src/adapters/posthog.ts +++ b/packages/internal/tracker/src/adapters/posthog.ts @@ -3,6 +3,25 @@ import type PostHogReactNative from "posthog-react-native" import type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base" +export const POSTHOG_SAMPLE_RATE = 0.05 + +const normalizeSampleRate = (sampleRate: number): number => { + if (!Number.isFinite(sampleRate)) return 1 + + return Math.min(Math.max(sampleRate, 0), 1) +} + +export const createPostHogBeforeSend = ( + sampleRate = POSTHOG_SAMPLE_RATE, + sampleRandom: () => number = Math.random, +) => { + const normalizedSampleRate = normalizeSampleRate(sampleRate) + const isSampled = + normalizedSampleRate >= 1 || (normalizedSampleRate > 0 && sampleRandom() < normalizedSampleRate) + + return (event: T): T | null => (isSampled ? event : null) +} + export interface PostHogAdapterConfig { instance: PostHog | PostHogReactNative enabled?: boolean diff --git a/packages/internal/tracker/src/index.ts b/packages/internal/tracker/src/index.ts index 2585a7856fa..182f01da56d 100644 --- a/packages/internal/tracker/src/index.ts +++ b/packages/internal/tracker/src/index.ts @@ -9,9 +9,11 @@ export const tracker = new TrackerPoints() export { type CaptureExceptionPayload, + createPostHogBeforeSend, FirebaseAdapter, type FirebaseAdapterConfig, type IdentifyPayload, + POSTHOG_SAMPLE_RATE, PostHogAdapter, type PostHogAdapterConfig, ProxyAdapter, From dfcfb0ddff2f06e1944fc26df863f71cf6c89d6f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Jul 2026 16:49:22 +0800 Subject: [PATCH 06/24] docs: remove status badge from readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 47ddfda5bdb..7513e38d084 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@
- From e48a8f4f2eda3934c067414dc690c0980de8f628 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 3 Jul 2026 17:41:58 +0800 Subject: [PATCH 07/24] fix(mobile): use folo callback for social auth --- .../src/lib/auth-cookie-migration.test.ts | 92 +++++++++++++++++++ apps/mobile/src/lib/auth-cookie-migration.ts | 2 +- apps/mobile/src/lib/auth.ts | 2 +- .../src/modules/login/social-login.test.ts | 17 +++- apps/mobile/src/modules/login/social-login.ts | 9 +- apps/mobile/src/modules/login/social.tsx | 4 +- 6 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/lib/auth-cookie-migration.test.ts diff --git a/apps/mobile/src/lib/auth-cookie-migration.test.ts b/apps/mobile/src/lib/auth-cookie-migration.test.ts new file mode 100644 index 00000000000..b4603e3fe18 --- /dev/null +++ b/apps/mobile/src/lib/auth-cookie-migration.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getCookie: vi.fn(() => "better-auth.session_token=session"), + getInstallerPackageName: vi.fn(async () => "com.android.vending"), + getUserAgent: vi.fn(async () => "Folo/0.5.5"), + oneTimeTokenApply: vi.fn(async () => {}), +})) + +vi.mock("@follow/utils/headers", () => ({ + createMobileAPIHeaders: vi.fn(() => ({ + "x-app-version": "0.5.5", + })), +})) + +vi.mock("expo-application", () => ({ + nativeApplicationVersion: "0.5.5", +})) + +vi.mock("react-native", () => ({ + Platform: { + OS: "android", + isPad: false, + }, +})) + +vi.mock("react-native-device-info", () => ({ + default: { + getInstallerPackageName: mocks.getInstallerPackageName, + }, +})) + +vi.mock("./auth", () => ({ + getCookie: mocks.getCookie, + oneTimeToken: { + apply: mocks.oneTimeTokenApply, + }, +})) + +vi.mock("./client-session", () => ({ + getClientId: () => "client-id", + getSessionId: () => "session-id", +})) + +vi.mock("./native/user-agent", () => ({ + getUserAgent: mocks.getUserAgent, +})) + +vi.mock("./proxy-env", () => ({ + proxyEnv: { + API_URL: "https://api.folo.is", + }, +})) + +const { migrateLegacyApiSession } = await import("./auth-cookie-migration") + +describe("migrateLegacyApiSession", () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it("uses the Folo native app scheme as the migration auth origin", async () => { + const fetchMock = vi.fn(async (input: string, _options?: RequestInit): Promise => { + if (input === "https://api.folo.is/better-auth/get-session") { + return new Response(null, { status: 401 }) + } + + if (input === "https://api.follow.is/better-auth/get-session") { + return Response.json({ user: { id: "user-id" } }) + } + + if (input === "https://api.follow.is/better-auth/one-time-token/generate") { + return Response.json({ token: "one-time-token" }) + } + + return new Response(null, { status: 404 }) + }) + + vi.stubGlobal("fetch", fetchMock) + + await migrateLegacyApiSession() + + expect(fetchMock).toHaveBeenCalledTimes(3) + for (const [, options] of fetchMock.mock.calls) { + expect(options?.headers).toMatchObject({ + "expo-origin": "folo://", + }) + } + expect(mocks.oneTimeTokenApply).toHaveBeenCalledWith({ token: "one-time-token" }) + }) +}) diff --git a/apps/mobile/src/lib/auth-cookie-migration.ts b/apps/mobile/src/lib/auth-cookie-migration.ts index 96a6e06b0e9..cf5841c7956 100644 --- a/apps/mobile/src/lib/auth-cookie-migration.ts +++ b/apps/mobile/src/lib/auth-cookie-migration.ts @@ -46,7 +46,7 @@ const createMigrationHeaders = async () => { "X-Client-Id": getClientId(), "X-Session-Id": getSessionId(), "User-Agent": await getUserAgent(), - "expo-origin": "follow://", + "expo-origin": "folo://", "x-skip-oauth-proxy": "true", } } diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index d25b0cead61..fb5da25e720 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -54,7 +54,7 @@ const refreshSessionQueries = () => const plugins = [ ...baseAuthPlugins, expoClient({ - scheme: "follow", + scheme: "folo", storagePrefix, storage: { setItem(key: string, value: string) { diff --git a/apps/mobile/src/modules/login/social-login.test.ts b/apps/mobile/src/modules/login/social-login.test.ts index 88c061be42c..bd31d975b1a 100644 --- a/apps/mobile/src/modules/login/social-login.test.ts +++ b/apps/mobile/src/modules/login/social-login.test.ts @@ -32,7 +32,7 @@ describe("loginWithSocialProvider", () => { }) expect(result).toBe(true) - expect(signInWithProvider).toHaveBeenCalledWith("google") + expect(signInWithProvider).toHaveBeenCalledWith("google", { callbackURL: "folo://" }) expect(trackLogin).toHaveBeenCalledTimes(1) expect(sequence).toEqual(["pending:google", "sign-in:google", "sync", "track", "pending:none"]) }) @@ -58,6 +58,21 @@ describe("loginWithSocialProvider", () => { expect(setPendingProviderId).toHaveBeenLastCalledWith(null) }) + it("uses the Folo native app scheme as the OAuth callback URL", async () => { + const signInWithProvider = vi.fn(async () => {}) + + await loginWithSocialProvider({ + providerId: "github", + setPendingProviderId: vi.fn(), + signInWithProvider, + signInWithAppleIdentityToken: vi.fn(async () => {}), + syncSession: async () => false, + trackLogin: vi.fn(), + }) + + expect(signInWithProvider).toHaveBeenCalledWith("github", { callbackURL: "folo://" }) + }) + it("uses the Apple token flow for Apple sign in", async () => { const signInWithAppleIdentityToken = vi.fn(async () => {}) const signInWithProvider = vi.fn(async () => {}) diff --git a/apps/mobile/src/modules/login/social-login.ts b/apps/mobile/src/modules/login/social-login.ts index 7b037d14cdf..d5cedb1f2e7 100644 --- a/apps/mobile/src/modules/login/social-login.ts +++ b/apps/mobile/src/modules/login/social-login.ts @@ -1,7 +1,12 @@ +export const nativeOAuthCallbackURL = "folo://" + type LoginWithSocialProviderOptions = { providerId: string setPendingProviderId: (providerId: string | null) => void - signInWithProvider: (providerId: string) => Promise + signInWithProvider: ( + providerId: string, + options: { callbackURL: typeof nativeOAuthCallbackURL }, + ) => Promise signInWithAppleIdentityToken: () => Promise syncSession: () => Promise trackLogin: () => void @@ -23,7 +28,7 @@ export async function loginWithSocialProvider({ if (providerId === "apple") { await signInWithAppleIdentityToken() } else { - await signInWithProvider(providerId) + await signInWithProvider(providerId, { callbackURL: nativeOAuthCallbackURL }) } const hasSession = await syncSession() diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index 94dc3c898eb..4fbba659444 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -78,10 +78,10 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai void loginWithSocialProvider({ providerId: provider.id, setPendingProviderId, - signInWithProvider: async (providerId) => { + signInWithProvider: async (providerId, { callbackURL }) => { await signIn.social({ provider: providerId as SocialProviderSignInInput["provider"], - callbackURL: "/", + callbackURL, }) }, signInWithAppleIdentityToken: async () => { From 1158281f59b93cfa600f8ad0dd56d661bb8e7f07 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 10 Jul 2026 22:36:12 +0800 Subject: [PATCH 08/24] chore(deps): update frontend dependencies (#5038) * chore(deps): update frontend dependencies * fix(ci): format OTA verification commands --- .gitattributes | 1 + .gitignore | 1 + .nvmrc | 2 +- .prettierignore | 1 + apps/cli/package.json | 8 +- apps/desktop/configs/vite.render.config.ts | 1 + apps/desktop/e2e/support/app.ts | 511 +- apps/desktop/e2e/tests/web/core.spec.ts | 2 +- .../e2e/tests/web/settings-sync.spec.ts | 53 +- apps/desktop/electron.vite.config.ts | 3 + apps/desktop/layer/main/package.json | 35 +- .../layer/main/src/ipc/services/app.ts | 65 +- .../layer/main/src/ipc/services/auth.ts | 129 +- .../layer/main/src/ipc/services/cli.ts | 10 +- .../layer/main/src/ipc/services/debug.ts | 7 +- .../layer/main/src/ipc/services/dock.ts | 3 +- .../main/src/ipc/services/integration.test.ts | 4 +- .../main/src/ipc/services/integration.ts | 43 +- .../layer/main/src/ipc/services/menu.ts | 23 +- .../ipc/services/obsidian-frontmatter.test.ts | 2 +- .../layer/main/src/ipc/services/reader.ts | 14 +- .../layer/main/src/ipc/services/setting.ts | 23 +- apps/desktop/layer/main/src/lib/cleaner.ts | 2 - apps/desktop/layer/main/src/lib/user.test.ts | 109 + apps/desktop/layer/main/src/lib/user.ts | 6 +- .../layer/main/src/manager/app.test.ts | 213 + apps/desktop/layer/main/src/manager/app.ts | 21 +- apps/desktop/layer/main/tsconfig.json | 1 - apps/desktop/layer/main/vitest.config.ts | 3 +- apps/desktop/layer/renderer/package.json | 114 +- apps/desktop/layer/renderer/setup-file.ts | 93 +- .../layer/renderer/src/atoms/updater.ts | 6 +- .../src/components/common/LCPEndDetector.tsx | 7 +- .../ui/code-highlighter/shiki/shared.ts | 3 +- .../code-highlighter/shiki/shiki.module.css | 2 + .../ui/dropdown-menu/dropdown-menu.tsx | 2 +- .../ui/media/PreviewMediaContent.tsx | 7 +- .../src/components/ui/media/VideoPlayer.tsx | 4 +- .../src/components/ui/modal/stacked/modal.tsx | 2 +- .../src/initialize/global-shortcuts.ts | 4 +- .../layer/renderer/src/lib/api-client.ts | 56 +- apps/desktop/layer/renderer/src/main.tsx | 2 - .../ai-chat/components/layouts/ChatInput.tsx | 6 +- .../layouts/ScrollToBottomButton.tsx | 2 +- .../components/shared/common-states.tsx | 2 +- .../ai-chat/editor/plugins/mention/types.ts | 6 +- .../src/modules/ai-chat/store/types.ts | 3 +- .../modules/ai-onboarding/ai-chat-pane.tsx | 2 +- .../ai-task/components/schedule-config.tsx | 4 +- .../SubscriptionColumn.tsx | 1 - .../src/modules/app/EnvironmentIndicator.tsx | 1 - .../layer/renderer/src/modules/auth/Form.tsx | 46 + .../modules/command/commands/entry-render.tsx | 5 +- .../src/modules/discover/FeedForm.tsx | 5 +- .../entry-column/EntryItemSkeleton.tsx | 1 - .../Items/picture-item-stateless.tsx | 2 +- .../entry-column/Items/picture-masonry.tsx | 2 +- .../components/mark-all-button.tsx | 1 - .../entry-content/actions/picture-gallery.tsx | 2 +- .../modules/new-user-guide/ai-chat-pane.tsx | 2 +- .../src/modules/panel/cmdk.module.css | 2 + .../renderer/src/modules/player/entry-tts.ts | 3 +- .../tx-table/components.tsx | 3 +- .../renderer/src/modules/renderer/context.tsx | 1 - .../renderer/src/modules/renderer/html.tsx | 1 - .../src/modules/renderer/markdown.tsx | 1 - .../modules/settings/helper/SyncIndicator.tsx | 26 +- .../settings/helper/setting-builder.tsx | 7 +- .../settings/helper/sync-queue.test.ts | 69 +- .../src/modules/settings/helper/sync-queue.ts | 117 +- .../src/modules/settings/modal/layout.tsx | 2 +- .../settings/modal/use-setting-modal-hack.ts | 32 +- .../renderer/src/modules/settings/section.tsx | 3 - .../modules/settings/settings-glob.test.ts | 30 + .../src/modules/settings/settings-glob.ts | 8 +- .../integration/CustomIntegrationModal.tsx | 4 +- .../src/modules/settings/tabs/plan.tsx | 8 +- .../TimelineTabsSettingsModal.tsx | 4 +- .../src/modules/subscription-column/index.tsx | 3 +- .../src/modules/user/ProfileButton.tsx | 15 +- .../src/pages/settings/(settings)/about.tsx | 2 +- .../src/pages/settings/(settings)/ai.tsx | 3 +- .../pages/settings/(settings)/appearance.tsx | 2 +- .../src/pages/settings/(settings)/cli.tsx | 2 +- .../settings/(settings)/data-control.tsx | 2 +- .../src/pages/settings/(settings)/feeds.tsx | 2 +- .../src/pages/settings/(settings)/general.tsx | 2 +- .../pages/settings/(settings)/integration.tsx | 2 +- .../src/pages/settings/(settings)/list.tsx | 2 +- .../settings/(settings)/notifications.tsx | 2 +- .../src/pages/settings/(settings)/plan.tsx | 2 +- .../src/pages/settings/(settings)/profile.tsx | 2 +- .../pages/settings/(settings)/shortcuts.tsx | 2 +- .../pages/settings/(settings)/spotlight.tsx | 3 +- .../providers/context-menu-provider.test.tsx | 2 + .../renderer/src/providers/root-providers.tsx | 4 +- .../layer/renderer/src/queries/auth.ts | 92 +- .../layer/renderer/src/store/utils/helper.ts | 6 +- .../layer/renderer/src/styles/main.css | 9 + apps/desktop/layer/renderer/tsconfig.json | 3 +- apps/desktop/package.json | 63 +- apps/desktop/postcss.config.cjs | 7 +- apps/desktop/tailwind.config.ts | 10 - apps/landing/eslint.config.mjs | 95 +- apps/landing/global.d.ts | 2 - apps/landing/package.json | 98 +- .../ui/theme-switcher/ThemeSwitcher.tsx | 1 - .../src/components/ui/tooltip/index.tsx | 2 +- .../src/hooks/common/use-click-away.ts | 4 +- apps/landing/src/lib/datetime.ts | 2 +- apps/landing/tsconfig.json | 3 +- apps/mobile/app.config.base.ts | 2 + apps/mobile/e2e/flows/ios/content.yaml | 2 + .../e2e/flows/ios/dismiss-overlays.yaml | 6 + .../ios/ensure-onboarding-unfollowed.yaml | 5 +- .../e2e/flows/ios/follow-onboarding.yaml | 5 +- apps/mobile/e2e/flows/ios/sign-out.yaml | 4 + apps/mobile/e2e/flows/ios/timeline-entry.yaml | 2 + .../e2e/flows/ios/unfollow-onboarding.yaml | 5 +- .../e2e/flows/shared/follow-onboarding.yaml | 3 +- apps/mobile/e2e/run-maestro.sh | 2 +- .../mobile/ios/Folo.xcodeproj/project.pbxproj | 52 +- apps/mobile/ios/Podfile | 2 +- apps/mobile/ios/Podfile.properties.json | 1 + .../SharedWebView/WebViewManager.swift | 1 + .../Modules/SharedWebView/WebViewState.swift | 15 +- .../StoreKitTestHelperModule.swift | 27 +- .../ios/Modules/TabBar/TabBarRootView.swift | 3 + apps/mobile/package.json | 184 +- apps/mobile/src/App.tsx | 2 +- .../src/components/common/BlurEffect.tsx | 2 +- .../src/components/common/SwipeableItem.tsx | 2 +- .../src/components/common/ThemedBlurView.tsx | 4 +- .../layouts/header/NavigationHeader.tsx | 46 +- .../src/components/layouts/tabbar/Tabbar.tsx | 2 +- .../src/components/native/PagerView/index.tsx | 2 +- .../native/webview/native-webview.android.tsx | 14 +- .../src/components/ui/form/FormProvider.tsx | 18 +- .../src/components/ui/form/PickerIos.tsx | 1 - .../src/components/ui/grouped/GroupedList.tsx | 4 +- .../ui/grouped/grouped-list-key.test.ts | 20 + .../components/ui/grouped/grouped-list-key.ts | 6 + .../ImageItem/ImageItem.android.tsx | 4 +- .../components/ImageItem/ImageItem.ios.tsx | 4 +- .../components/ImageItem/ImageItem.tsx | 4 +- .../src/components/ui/tabview/TabView.tsx | 1 - .../src/components/ui/video/PlayerAction.tsx | 2 +- apps/mobile/src/hooks/useBackHandler.ts | 1 - apps/mobile/src/lib/auth-cookie-sync.test.ts | 53 + apps/mobile/src/lib/auth-cookie-sync.ts | 289 + apps/mobile/src/lib/auth.ts | 132 +- apps/mobile/src/lib/loading.tsx | 4 +- apps/mobile/src/lib/navigation/types.ts | 2 +- .../discover/search-tabs/SearchFeed.tsx | 23 +- .../discover/search-tabs/SearchList.tsx | 2 +- .../modules/discover/search-tabs/hooks.tsx | 6 +- .../search-tabs/search-feed-items.test.ts | 39 + .../discover/search-tabs/search-feed-items.ts | 25 + .../entry-content/pull-up-navigation/types.ts | 4 +- .../entry-list/EntryListContentSocial.tsx | 2 +- apps/mobile/src/modules/feed/FollowFeed.tsx | 9 +- apps/mobile/src/modules/login/email.tsx | 1 - apps/mobile/src/modules/login/social.tsx | 7 +- .../src/modules/player/GlassPlayerTabBar.tsx | 2 +- .../src/modules/player/TtsStreamProvider.tsx | 5 +- .../modules/player/tts-stream-controller.ts | 10 +- .../src/modules/review-prompt/provider.tsx | 2 +- .../src/modules/settings/UserHeaderBanner.tsx | 10 +- .../modules/settings/components/OTPWindow.tsx | 2 +- .../src/modules/settings/routes/Account.tsx | 22 +- .../src/modules/settings/routes/Plan.tsx | 6 +- .../mobile/src/providers/AppleIAPProvider.tsx | 61 +- apps/mobile/src/providers/index.tsx | 1 - .../mobile/src/screens/(modal)/ListScreen.tsx | 6 +- .../screens/(modal)/TwoFactorAuthScreen.tsx | 17 +- .../(stack)/feeds/[feedId]/FeedScreen.tsx | 2 +- apps/mobile/tsconfig.json | 1 - .../mobile/web-app/html-renderer/package.json | 29 +- .../web-app/html-renderer/postcss.config.cjs | 4 +- .../src/components/shiki/shared.ts | 3 +- .../src/components/shiki/shiki.module.css | 2 + .../web-app/html-renderer/src/index.css | 6 + .../mobile/web-app/html-renderer/src/index.ts | 2 - .../web-app/html-renderer/vite.config.mts | 16 +- apps/ota/package.json | 12 +- apps/ota/src/lib/schema.ts | 4 +- apps/ota/tsconfig.json | 3 +- apps/ssr/client/components/items/picture.tsx | 2 +- apps/ssr/client/index.tsx | 1 - .../pages/(main)/share/users/[id]/index.tsx | 4 +- apps/ssr/client/query/users.ts | 4 +- apps/ssr/client/styles/index.css | 10 + apps/ssr/package.json | 68 +- apps/ssr/postcss.config.cjs | 4 +- apps/ssr/tailwind.config.ts | 116 +- apps/ssr/tsconfig.json | 1 + apps/ssr/tsdown.config.ts | 6 +- apps/ssr/tsdown.worker.config.ts | 8 +- apps/ssr/types/daisyui.d.ts | 6 + docs/superpowers/plans/2026-04-10-ota.md | 6 +- eslint.config.mjs | 100 +- package.json | 47 +- packages/configs/package.json | 21 +- .../tailwindcss/ratio-mixing-plugin.js | 35 +- .../configs/tailwindcss/tailwind-extend.css | 19 +- packages/configs/tailwindcss/web.ts | 9 +- packages/internal/atoms/package.json | 2 +- packages/internal/atoms/tsconfig.json | 1 - .../internal/components/assets/tailwind.css | 4 - packages/internal/components/package.json | 99 +- .../src/providers/stable-router-provider.tsx | 2 +- .../src/ui/context-menu/context-menu.tsx | 6 +- .../src/ui/input/DateTimePicker.tsx | 10 +- .../components/src/ui/input/TimeSelect.tsx | 4 +- .../components/src/ui/segment/index.tsx | 1 - .../components/src/ui/sheet/Sheet.tsx | 2 +- .../components/src/utils/parse-markdown.tsx | 3 +- packages/internal/components/tsconfig.json | 1 - packages/internal/constants/tsconfig.json | 1 - packages/internal/database/package.json | 9 +- .../src/drizzle/0033_shiny_sebastian_shaw.sql | 30 +- packages/internal/database/src/types.ts | 3 +- packages/internal/hooks/package.json | 8 +- packages/internal/hooks/src/useRefValue.ts | 1 - packages/internal/hooks/tsconfig.json | 1 - packages/internal/logger/package.json | 2 +- packages/internal/logger/tsconfig.json | 1 - packages/internal/models/tsconfig.json | 1 - packages/internal/shared/package.json | 13 +- packages/internal/shared/src/auth.ts | 27 +- packages/internal/shared/src/review-prompt.ts | 5 +- .../internal/shared/src/settings/interface.ts | 10 +- packages/internal/shared/tsconfig.json | 1 - packages/internal/store/package.json | 14 +- packages/internal/store/src/lib/helper.ts | 6 +- .../internal/store/src/modules/user/store.ts | 7 +- packages/internal/tracker/package.json | 4 +- .../tracker/src/adapters/firebase.test.ts | 49 + .../internal/tracker/src/adapters/firebase.ts | 14 +- packages/internal/tracker/tsconfig.json | 1 - packages/internal/utils/package.json | 28 +- packages/internal/utils/src/event-bus.rn.ts | 1 - packages/internal/utils/src/event-bus.ts | 1 - packages/internal/utils/src/ns.ts | 8 +- packages/internal/utils/src/spotlight.ts | 3 +- packages/internal/utils/tsconfig.json | 1 - packages/readability/package.json | 13 +- packages/readability/src/index.ts | 16 +- packages/readability/src/sanitize.test.ts | 25 + packages/readability/src/sanitize.ts | 15 + packages/readability/tsconfig.json | 1 - ...icroflash__remark-callout-directives.patch | 12 - patches/daisyui@4.12.24.patch | 37 - .../react-native-ios-utilities@5.2.0.patch | 62 + ... => react-native-track-player@4.1.2.patch} | 3 +- plugins/eslint/eslint-package-json.js | 16 + pnpm-lock.yaml | 44059 +++++++++------- pnpm-workspace.yaml | 63 +- tsslint.config.ts | 11 +- 259 files changed, 27126 insertions(+), 21808 deletions(-) create mode 100644 apps/desktop/layer/main/src/lib/user.test.ts create mode 100644 apps/desktop/layer/main/src/manager/app.test.ts create mode 100644 apps/desktop/layer/renderer/src/modules/settings/settings-glob.test.ts create mode 100644 apps/mobile/src/components/ui/grouped/grouped-list-key.test.ts create mode 100644 apps/mobile/src/components/ui/grouped/grouped-list-key.ts create mode 100644 apps/mobile/src/lib/auth-cookie-sync.test.ts create mode 100644 apps/mobile/src/lib/auth-cookie-sync.ts create mode 100644 apps/mobile/src/modules/discover/search-tabs/search-feed-items.test.ts create mode 100644 apps/mobile/src/modules/discover/search-tabs/search-feed-items.ts create mode 100644 apps/ssr/types/daisyui.d.ts create mode 100644 packages/internal/tracker/src/adapters/firebase.test.ts create mode 100644 packages/readability/src/sanitize.test.ts create mode 100644 packages/readability/src/sanitize.ts delete mode 100644 patches/@microflash__remark-callout-directives.patch delete mode 100644 patches/daisyui@4.12.24.patch create mode 100644 patches/react-native-ios-utilities@5.2.0.patch rename patches/{react-native-track-player@4.1.1.patch => react-native-track-player@4.1.2.patch} (99%) diff --git a/.gitattributes b/.gitattributes index e8c09c27f37..61a7b3d4b39 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto eol=lf *.splinecode filter=lfs diff=lfs merge=lfs -text +*.patch whitespace=-trailing-space diff --git a/.gitignore b/.gitignore index 5c4fa992f71..e62bd9c740c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin .vercel +.vinext stats.html electron.vite.config.*.mjs diff --git a/.nvmrc b/.nvmrc index 2bf5ad0447d..2bd5a0a98a3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -stable +22 diff --git a/.prettierignore b/.prettierignore index fd7d191ee40..f400f18587d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ pnpm-lock.yaml CHANGELOG.md .context .waylog +.vinext apps/external/postcss.config.cjs diff --git a/apps/cli/package.json b/apps/cli/package.json index 607c9f9deb0..e22abd02e6d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,14 +36,14 @@ }, "dependencies": { "@follow-app/client-sdk": "catalog:", - "commander": "14.0.1", + "commander": "15.0.0", "pathe": "2.0.3" }, "devDependencies": { "@follow/configs": "workspace:*", - "@types/node": "25.2.3", - "tsup": "8.5.0", - "tsx": "4.21.0", + "@types/node": "26.1.1", + "tsup": "8.5.1", + "tsx": "4.23.0", "typescript": "catalog:" } } diff --git a/apps/desktop/configs/vite.render.config.ts b/apps/desktop/configs/vite.render.config.ts index 9b3dc821f28..1856d45bb6b 100644 --- a/apps/desktop/configs/vite.render.config.ts +++ b/apps/desktop/configs/vite.render.config.ts @@ -66,6 +66,7 @@ export const viteRenderBaseConfig = { codeInspectorPlugin({ bundler: "vite", hotKeys: ["altKey"], + needEnvInspector: true, }), react({ // jsxImportSource: "@welldone-software/why-did-you-render", // <----- diff --git a/apps/desktop/e2e/support/app.ts b/apps/desktop/e2e/support/app.ts index c918378791d..14c4de5d71d 100644 --- a/apps/desktop/e2e/support/app.ts +++ b/apps/desktop/e2e/support/app.ts @@ -10,6 +10,77 @@ const ONBOARDING_FEED_URL = "folo://onboarding" const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false) const visibleByTestId = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]:visible`).last() +const isElectronPage = (page: Page) => page.url().startsWith("app://") +const optionalRendererResponseTimeout = (page: Page) => (isElectronPage(page) ? 5_000 : 120_000) + +const hasRenderedAppShell = async (page: Page) => { + const visibleTestIds = [ + "login-button", + "login-modal", + "profile-menu-trigger", + "timeline-tab-articles", + "subscription-discover-trigger", + "discover-form-input", + "settings-language-select", + ] + + for (const testId of visibleTestIds) { + if ( + await visibleByTestId(page, testId) + .isVisible() + .catch(() => false) + ) { + return true + } + } + + return false +} + +const waitForRenderedAppShell = async (page: Page, timeout = 30_000) => { + return expect + .poll(hasRenderedAppShell.bind(null, page), { timeout }) + .toBe(true) + .then(() => true) + .catch(() => false) +} + +const isAuthenticatedUiReady = async (page: Page) => { + const profileVisible = await visibleByTestId(page, "profile-menu-trigger") + .isVisible() + .catch(() => false) + const loggedOutUiVisible = await isLoggedOutUiReady(page) + if (loggedOutUiVisible) { + return false + } + + return profileVisible +} + +const isLoggedOutUiReady = async (page: Page) => { + const loginButtonVisible = await page + .locator('[data-testid="login-button"]:visible') + .last() + .isVisible() + .catch(() => false) + const loginModalVisible = await page + .locator('[data-testid="login-modal"]:visible') + .last() + .isVisible() + .catch(() => false) + const loginInputVisible = await page + .locator('[data-testid="login-email-input"]:visible') + .last() + .isVisible() + .catch(() => false) + const registerInputVisible = await page + .locator('[data-testid="register-email-input"]:visible') + .last() + .isVisible() + .catch(() => false) + + return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible +} export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { await page.addInitScript( @@ -37,7 +108,12 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { headers.set("x-token", "r3:e2e-token") } - return originalFetch(new Request(request, { headers })) + return originalFetch( + new Request(request, { + headers, + credentials: "include", + }), + ) } if (!nextEnv) { @@ -99,86 +175,104 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { ) } -export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => { +export const openWebApp = async ( + page: Page, + env: DesktopE2EEnv, + route = "/timeline/articles/all/pending", +) => { await injectRecaptchaToken(page, env) await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) -} -export const waitForAuthenticated = async (page: Page) => { - const isAuthenticatedUiReady = async () => { - const profileVisible = await page - .getByTestId("profile-menu-trigger") - .isVisible() - .catch(() => false) - const loginModalVisible = await page - .getByTestId("login-modal") - .isVisible() - .catch(() => false) - - return profileVisible && !loginModalVisible + if (!(await waitForRenderedAppShell(page))) { + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}) + await waitForRenderedAppShell(page) } +} - await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true) +export const waitForAuthenticated = async (page: Page) => { + await expect.poll(() => isAuthenticatedUiReady(page), { timeout: 90_000 }).toBe(true) } export const waitForLoggedOut = async (page: Page) => { + await expect.poll(() => isLoggedOutUiReady(page), { timeout: 30_000 }).toBe(true) +} + +const waitForBetterAuthSessionCookie = async (page: Page, apiURL: string) => { await expect .poll( async () => { - const loginButtonVisible = await page - .getByTestId("login-button") - .last() - .isVisible() - .catch(() => false) - const loginModalVisible = await page - .getByTestId("login-modal") - .last() - .isVisible() - .catch(() => false) - const loginInputVisible = await page - .getByTestId("login-email-input") - .last() - .isVisible() - .catch(() => false) - const registerInputVisible = await page - .getByTestId("register-email-input") - .last() - .isVisible() - .catch(() => false) - - return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible + const cookies = await page.context().cookies(apiURL) + return cookies.some( + (cookie) => + cookie.name === "better-auth.session_token" || + cookie.name === "__Secure-better-auth.session_token", + ) }, { timeout: 30_000 }, ) .toBe(true) } +const waitForBetterAuthSessionCookieCleared = async (page: Page, apiURL: string) => { + await expect + .poll( + async () => { + const cookies = await page.context().cookies(apiURL) + return !cookies.some( + (cookie) => + cookie.name === "better-auth.session_token" || + cookie.name === "__Secure-better-auth.session_token", + ) + }, + { timeout: 60_000 }, + ) + .toBe(true) +} + export const ensureLoginModal = async (page: Page) => { await expect .poll( async () => { const loginModalVisible = await page - .getByTestId("login-modal") + .locator('[data-testid="login-modal"]:visible') .last() .isVisible() .catch(() => false) const loginButtonVisible = await page - .getByTestId("login-button") + .locator('[data-testid="login-button"]:visible') .last() .isVisible() .catch(() => false) const loginInputVisible = await page - .getByTestId("login-email-input") + .locator('[data-testid="login-email-input"]:visible') .last() .isVisible() .catch(() => false) const registerInputVisible = await page - .getByTestId("register-email-input") + .locator('[data-testid="register-email-input"]:visible') + .last() + .isVisible() + .catch(() => false) + const credentialProviderVisible = await page + .locator('[data-testid="login-provider-credential"]:visible') + .last() + .isVisible() + .catch(() => false) + const loginDialogVisible = await page + .locator('[role="dialog"]:visible') + .filter({ hasText: /Sign in to Folo|Login/ }) .last() .isVisible() .catch(() => false) - return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible + return ( + loginModalVisible || + loginButtonVisible || + loginInputVisible || + registerInputVisible || + credentialProviderVisible || + loginDialogVisible + ) }, { timeout: 30_000 }, ) @@ -222,17 +316,41 @@ const ensureCredentialForm = async (page: Page, mode: "register" | "login") => { if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) { await expect(credentialProvider).toBeVisible({ timeout: 30_000 }) - await credentialProvider.click({ timeout: 30_000, noWaitAfter: true }) - await expect - .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), { - timeout: 30_000, - }) - .toBe(true) + for (let attempt = 0; attempt < 3; attempt++) { + await credentialProvider.click({ force: true, timeout: 30_000, noWaitAfter: true }) + if ( + await expect + .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), { + timeout: 5_000, + }) + .toBe(true) + .then(() => true) + .catch(() => false) + ) { + break + } + } } + await expect + .poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), { + timeout: 15_000, + }) + .toBe(true) + if (await isVisible(oppositeForm)) { await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 }) - await oppositeFormSwitcher.click({ timeout: 30_000, noWaitAfter: true }) + for (let attempt = 0; attempt < 3; attempt++) { + await oppositeFormSwitcher.click({ force: true, timeout: 10_000, noWaitAfter: true }) + if ( + await targetInput + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false) + ) { + return + } + } } await expect(targetInput).toBeVisible({ timeout: 30_000 }) @@ -247,7 +365,24 @@ export const registerWithCredential = async (page: Page, account: TestAccount) = const submit = visibleByTestId(page, "register-submit") await expect(submit).toBeEnabled({ timeout: 30_000 }) - await submit.click() + const signUpResponse = page + .waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/better-auth/sign-up/email"), + { timeout: optionalRendererResponseTimeout(page) }, + ) + .catch(() => null) + + const [response] = await Promise.all([ + signUpResponse, + submit.click({ force: true, noWaitAfter: true }), + ]) + if (response) { + expect(response.ok()).toBe(true) + await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin) + } + await waitForAuthenticated(page) } @@ -259,27 +394,69 @@ export const loginWithCredential = async (page: Page, account: TestAccount) => { const submit = visibleByTestId(page, "login-submit") await expect(submit).toBeEnabled({ timeout: 30_000 }) - await submit.click() + const signInResponse = page + .waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/better-auth/sign-in/email"), + { timeout: optionalRendererResponseTimeout(page) }, + ) + .catch(() => null) + + const [response] = await Promise.all([ + signInResponse, + submit.click({ force: true, noWaitAfter: true }), + ]) + if (response) { + expect(response.ok()).toBe(true) + await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin) + } + + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}) + await waitForRenderedAppShell(page, 120_000) + await waitForAuthenticated(page) } export const logoutFromProfileMenu = async (page: Page) => { await page.keyboard.press("Escape").catch(() => {}) - if (page.url().startsWith("app://")) { - await returnToMainShell(page) - } - await page.getByTestId("profile-menu-trigger").click() + await returnToMainShell(page) + const isElectron = isElectronPage(page) + const appHomeURL = new URL("/timeline/articles/all/pending", page.url()).toString() + await visibleByTestId(page, "profile-menu-trigger").click() const signOutResponse = page .waitForResponse( (response) => response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"), - { timeout: 30_000 }, + { timeout: optionalRendererResponseTimeout(page) }, ) .catch(() => null) - await page.getByTestId("profile-menu-logout").click() - await signOutResponse + const [response] = await Promise.all([ + signOutResponse, + page.getByTestId("profile-menu-logout").click({ force: true, noWaitAfter: true }), + ]) + if (response) { + expect(response.ok()).toBe(true) + await waitForBetterAuthSessionCookieCleared(page, new URL(response.url()).origin) + } + + if (isElectron) { + await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {}) + } else { + await page.goto(appHomeURL, { waitUntil: "domcontentloaded" }).catch(() => {}) + } + await waitForRenderedAppShell(page, 60_000) + if (!(await isLoggedOutUiReady(page))) { + if (!isElectron) { + await page.goto(appHomeURL, { waitUntil: "domcontentloaded" }).catch(() => {}) + } else { + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}) + } + await waitForRenderedAppShell(page, 60_000) + } + await waitForLoggedOut(page) } @@ -303,6 +480,35 @@ const waitForMainShell = async (page: Page) => { .toBe(true) } +const closeVisibleDialog = async (page: Page, timeout = 10_000) => { + const activeDialog = page.locator('[role="dialog"]:visible').last() + if (!(await activeDialog.isVisible().catch(() => false))) { + return + } + + await page.keyboard.press("Escape").catch(() => {}) + + if (!(await activeDialog.isVisible().catch(() => false))) { + return + } + + const modalClose = activeDialog.getByTestId("modal-close").first() + + if (await modalClose.isVisible().catch(() => false)) { + await modalClose.click({ force: true, timeout: 3_000 }).catch(() => {}) + } else { + await page.keyboard.press("Escape").catch(() => {}) + } + + if (await activeDialog.isVisible().catch(() => false)) { + await page.keyboard.press("Escape").catch(() => {}) + } + + await expect + .poll(async () => activeDialog.isVisible().catch(() => false), { timeout }) + .toBe(false) +} + const returnToMainShell = async (page: Page) => { const discoverInput = page.getByTestId("discover-form-input") if (await discoverInput.isVisible().catch(() => false)) { @@ -315,7 +521,15 @@ const returnToMainShell = async (page: Page) => { } if (await discoverInput.isVisible().catch(() => false)) { - await page.keyboard.press("Escape").catch(() => {}) + if (page.url().startsWith("app://")) { + await page.keyboard.press("Escape").catch(() => {}) + } else { + await page + .goto(new URL("/timeline/articles/all/pending", page.url()).toString(), { + waitUntil: "domcontentloaded", + }) + .catch(() => {}) + } } await expect @@ -323,23 +537,18 @@ const returnToMainShell = async (page: Page) => { .toBe(false) } - await waitForMainShell(page) - - const activeDialog = page.locator('[role="dialog"]:visible').last() - if (await activeDialog.isVisible().catch(() => false)) { - await page.keyboard.press("Escape").catch(() => {}) + if (!(await waitForRenderedAppShell(page, 15_000)) && !page.url().startsWith("app://")) { + await page + .goto(new URL("/timeline/articles/all/pending", page.url()).toString(), { + waitUntil: "domcontentloaded", + }) + .catch(() => {}) + await waitForRenderedAppShell(page) + } - if (await activeDialog.isVisible().catch(() => false)) { - const modalClose = activeDialog.getByTestId("modal-close").first() - if (await modalClose.isVisible().catch(() => false)) { - await modalClose.click().catch(() => {}) - } - } + await waitForMainShell(page) - await expect - .poll(async () => activeDialog.isVisible().catch(() => false), { timeout: 10_000 }) - .toBe(false) - } + await closeVisibleDialog(page) } const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => { @@ -360,13 +569,53 @@ export const openSettings = async (page: Page, tab: "general" | "feeds" = "gener const settingsModal = page.locator("#setting-modal").first() + const openSettingsFromRouter = async () => { + await returnToMainShell(page) + const opened = await page + .evaluate((targetTab) => { + const router = ( + window as Window & { + router?: { + showSettings?: (options?: string | { tab?: string }) => void + } + } + ).router + if (typeof router?.showSettings !== "function") { + return false + } + + router.showSettings({ tab: targetTab }) + return true + }, tab) + .catch(() => false) + + if (!opened) { + return false + } + + await expect(settingsModal).toBeVisible({ timeout: 15_000 }) + return true + } + const openSettingsFromMenu = async () => { await returnToMainShell(page) - const profileTrigger = page.getByTestId("profile-menu-trigger") + const profileTrigger = visibleByTestId(page, "profile-menu-trigger") await expect(profileTrigger).toBeVisible({ timeout: 15_000 }) - await profileTrigger.click() const preferencesItem = page.getByTestId("profile-menu-preferences") + for (let attempt = 0; attempt < 3; attempt++) { + await profileTrigger.click({ force: true }) + if ( + await preferencesItem + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false) + ) { + break + } + await page.keyboard.press("Escape").catch(() => {}) + } + await expect(preferencesItem).toBeVisible({ timeout: 15_000 }) await preferencesItem.click() @@ -374,10 +623,13 @@ export const openSettings = async (page: Page, tab: "general" | "feeds" = "gener } if (!(await settingsModal.isVisible().catch(() => false))) { - try { - await openSettingsFromMenu() - } catch { - await openSettingsFromMenu() + const openedFromRouter = await openSettingsFromRouter().catch(() => false) + if (!openedFromRouter) { + try { + await openSettingsFromMenu() + } catch { + await openSettingsFromMenu() + } } } @@ -425,31 +677,81 @@ export const closeSettings = async (page: Page) => { } export const setLanguage = async (page: Page, label: string) => { + const languageValue = label.includes("日本語") ? "ja" : label.includes("English") ? "en" : null + const syncResponse = languageValue + ? page.waitForResponse( + (response) => + response.request().method() === "PATCH" && + response.url().includes("/settings/general") && + response.status() < 400 && + (response.request().postData() ?? "").includes(`"language":"${languageValue}"`), + { timeout: 120_000 }, + ) + : null + await page.getByTestId("settings-language-select").click() await page.getByRole("option", { name: label }).click() + + await syncResponse + await expect(page.getByTestId("settings-language-select")).toContainText(label, { + timeout: 30_000, + }) } export const getLanguageLabel = async (page: Page) => { - return page.getByTestId("settings-language-select").textContent() + return visibleByTestId(page, "settings-language-select").textContent() } export const openOnboardingFeedForm = async ( page: Page, - _env?: DesktopE2EEnv, - _options?: { electron?: boolean }, + env?: DesktopE2EEnv, + options?: { electron?: boolean }, ) => { const discoverInput = page.getByTestId("discover-form-input") - if (!(await discoverInput.isVisible().catch(() => false))) { + if (env && !options?.electron && !page.url().startsWith("app://")) { + await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" }) + await waitForRenderedAppShell(page, 120_000) + } else if (!(await discoverInput.isVisible().catch(() => false))) { await returnToMainShell(page) const discoverTrigger = page.getByTestId("subscription-discover-trigger") await expect(discoverTrigger).toBeVisible({ timeout: 15_000 }) await discoverTrigger.click() + + if ( + !(await discoverInput.isVisible().catch(() => false)) && + env && + !options?.electron && + !page.url().startsWith("app://") + ) { + await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" }) + await waitForRenderedAppShell(page, 120_000) + } } - await expect(discoverInput).toBeVisible({ timeout: 15_000 }) + await expect(discoverInput).toBeVisible({ timeout: 120_000 }) + await discoverInput.fill("") + await expect(discoverInput).toHaveValue("", { timeout: 15_000 }) + + const onboardingFeedResponse = page + .waitForResponse( + (response) => { + const url = new URL(response.url()) + return ( + response.request().method() === "GET" && + url.pathname === "/feeds" && + url.searchParams.get("url") === ONBOARDING_FEED_URL + ) + }, + { timeout: 120_000 }, + ) + .catch(() => null) + await discoverInput.fill(ONBOARDING_FEED_URL) + await expect(discoverInput).toHaveValue(ONBOARDING_FEED_URL, { timeout: 15_000 }) await discoverInput.press("Enter") - await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 }) + await onboardingFeedResponse + await expect(visibleByTestId(page, "feed-form-submit")).toBeVisible({ timeout: 120_000 }) + await expect(visibleByTestId(page, "feed-form-submit")).toBeEnabled({ timeout: 120_000 }) } export const followOnboardingFeed = async ( @@ -472,23 +774,38 @@ export const followOnboardingFeed = async ( export const dismissFeedForm = async (page: Page) => { const cancelButton = visibleByTestId(page, "feed-form-cancel") - const dialog = page.locator('[role="dialog"]').last() + const dialog = page.locator('[role="dialog"]:visible').last() - if (!(await cancelButton.isVisible().catch(() => false))) { - if (await dialog.isVisible().catch(() => false)) { - await page.keyboard.press("Escape").catch(() => {}) - } + if (!(await dialog.isVisible().catch(() => false))) { return } - await cancelButton.click() + const modalClose = dialog.getByTestId("modal-close").first() + await expect + .poll( + async () => + (await cancelButton.isVisible().catch(() => false)) || + (await modalClose.isVisible().catch(() => false)), + { timeout: 15_000 }, + ) + .toBe(true) + + if (await cancelButton.isVisible().catch(() => false)) { + await cancelButton.click({ force: true, timeout: 3_000 }).catch(() => {}) + } else { + await closeVisibleDialog(page) + } if ( (await cancelButton.isVisible().catch(() => false)) || (await dialog.isVisible().catch(() => false)) ) { - await page.keyboard.press("Escape").catch(() => {}) + await closeVisibleDialog(page) } + + await expect + .poll(async () => dialog.isVisible().catch(() => false), { timeout: 10_000 }) + .toBe(false) } const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => { @@ -576,12 +893,14 @@ export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => { const videosTab = page.getByTestId("timeline-tab-videos") await videosTab.click() await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) - await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) const articlesTab = page.getByTestId("timeline-tab-articles") await articlesTab.click() await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) - await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) + await closeVisibleDialog(page) + await expect + .poll(async () => page.locator("[data-entry-id]").count(), { timeout: 120_000 }) + .toBeGreaterThan(0) const unreadOnboardingEntry = page .locator('[data-entry-id][data-read="false"]:visible') diff --git a/apps/desktop/e2e/tests/web/core.spec.ts b/apps/desktop/e2e/tests/web/core.spec.ts index f82b2abb6b6..f8c5ee4a974 100644 --- a/apps/desktop/e2e/tests/web/core.spec.ts +++ b/apps/desktop/e2e/tests/web/core.spec.ts @@ -20,7 +20,7 @@ test.describe("web core flows", () => { page, browser, }) => { - test.setTimeout(180_000) + test.setTimeout(600_000) const env = resolveDesktopE2EEnv() const account = createTestAccount("web-core") diff --git a/apps/desktop/e2e/tests/web/settings-sync.spec.ts b/apps/desktop/e2e/tests/web/settings-sync.spec.ts index 6b1b5496910..a7d7db1293f 100644 --- a/apps/desktop/e2e/tests/web/settings-sync.spec.ts +++ b/apps/desktop/e2e/tests/web/settings-sync.spec.ts @@ -24,9 +24,39 @@ const closeContextSafely = async (context: BrowserContext) => { } } +const reloadAndOpenSettingsWithRemoteLanguage = async ( + page: Awaited>, + apiURL: string, + language: string, +) => { + await expect + .poll( + async () => + page.evaluate(async (targetApiURL) => { + const response = await fetch(`${targetApiURL}/settings`, { + cache: "no-store", + credentials: "include", + }) + if (!response.ok) { + return null + } + + const payload = (await response.json().catch(() => null)) as { + settings?: { general?: { language?: string } } + } | null + return payload?.settings?.general?.language ?? null + }, apiURL), + { timeout: 120_000 }, + ) + .toBe(language) + + await page.reload({ waitUntil: "domcontentloaded" }) + await openSettings(page) +} + test.describe("web multi-session sync", () => { test("syncs settings between two browser sessions", async ({ browser }) => { - test.setTimeout(180_000) + test.setTimeout(600_000) const env = resolveDesktopE2EEnv() const account = createTestAccount("web-sync") @@ -44,22 +74,15 @@ test.describe("web multi-session sync", () => { await loginWithCredential(pageB, account) await openSettings(pageA) - await openSettings(pageB) await test.step("session A change syncs to session B", async () => { await setLanguage(pageA, "日本語") await expect .poll(async () => getLanguageLabel(pageA), { timeout: 15_000 }) .toContain("日本語") + await reloadAndOpenSettingsWithRemoteLanguage(pageB, env.apiURL, "ja") await expect - .poll( - async () => { - await pageB.reload({ waitUntil: "domcontentloaded" }) - await openSettings(pageB) - return getLanguageLabel(pageB) - }, - { timeout: 30_000 }, - ) + .poll(async () => getLanguageLabel(pageB), { timeout: 15_000 }) .toContain("日本語") }) @@ -68,15 +91,9 @@ test.describe("web multi-session sync", () => { await expect .poll(async () => getLanguageLabel(pageB), { timeout: 15_000 }) .toContain("English") + await reloadAndOpenSettingsWithRemoteLanguage(pageA, env.apiURL, "en") await expect - .poll( - async () => { - await pageA.reload({ waitUntil: "domcontentloaded" }) - await openSettings(pageA) - return getLanguageLabel(pageA) - }, - { timeout: 60_000 }, - ) + .poll(async () => getLanguageLabel(pageA), { timeout: 15_000 }) .toContain("English") }) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 1b861c787ce..fd751254dc9 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ lib: { entry: "./layer/main/src/index.ts", }, + rollupOptions: { + external: ["canvas", "jsdom"], + }, }, resolve: { alias: { diff --git a/apps/desktop/layer/main/package.json b/apps/desktop/layer/main/package.json index 1e7a74fd7a8..3baf4a0e811 100644 --- a/apps/desktop/layer/main/package.json +++ b/apps/desktop/layer/main/package.json @@ -24,38 +24,37 @@ "dependencies": { "@electron-toolkit/preload": "3.0.2", "@electron-toolkit/utils": "4.0.0", - "@eneris/push-receiver": "4.3.0", + "@eneris/push-receiver": "4.3.1", "@follow-app/client-sdk": "catalog:", "@follow-app/readability": "workspace:*", "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", - "builder-util-runtime": "9.5.1", - "electron-context-menu": "4.1.1", - "electron-ipc-decorator": "0.2.0", - "electron-log": "5.4.3", + "builder-util-runtime": "9.7.0", + "electron-context-menu": "4.1.2", + "electron-ipc-decorator": "1.0.1", + "electron-log": "5.4.4", "electron-squirrel-startup": "1.0.1", "electron-store": "11.0.2", - "electron-updater": "6.7.3", - "es-toolkit": "1.44.0", - "font-list": "2.0.2", - "i18next": "25.8.6", - "js-yaml": "4.1.1", - "ky": "1.14.3", - "linkedom": "0.18.12", + "electron-updater": "6.8.9", + "es-toolkit": "1.49.0", + "font-list": "2.1.0", + "i18next": "26.3.5", + "js-yaml": "5.2.1", + "ky": "2.0.2", + "linkedom": "0.18.13", "lowdb": "7.0.1", "node-machine-id": "1.1.12", "ofetch": "1.5.1", "pathe": "2.0.3", - "semver": "7.7.4", - "tar": "7.5.7", - "vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22" + "semver": "7.8.5", + "tar": "7.5.19", + "vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.23" }, "devDependencies": { "@follow/models": "workspace:*", "@follow/types": "workspace:*", - "@types/js-yaml": "4.0.9", - "@types/node": "25.2.3", - "electron": "38.3.0", + "@types/node": "26.1.1", + "electron": "43.1.0", "electron-devtools-installer": "4.0.0", "typescript": "catalog:" } diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index 2076fa685ce..b5b2ef88b2a 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -4,8 +4,7 @@ import { fileURLToPath } from "node:url" import { callWindowExpose } from "@follow/shared/bridge" import { DEV } from "@follow/shared/constants" import { app, BrowserWindow, clipboard, dialog, shell } from "electron" -import type { IpcContext } from "electron-ipc-decorator" -import { IpcMethod, IpcService } from "electron-ipc-decorator" +import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator" import path from "pathe" import { START_IN_TRAY_ARGS } from "~/constants/app" @@ -56,7 +55,7 @@ export class AppService extends IpcService { } @IpcMethod() - switchAppLocale(context: IpcContext, input: string): void { + switchAppLocale(input: string): void { i18n.changeLanguage(input) AppManager.registerMenuAndContextMenu() registerAppTray() @@ -90,16 +89,17 @@ export class AppService extends IpcService { } @IpcMethod() - async openExternal(_context: IpcContext, url: string): Promise { + async openExternal(url: string): Promise { if (!url) return await shell.openExternal(url) } @IpcMethod() - windowAction(context: IpcContext, input: WindowActionInput): void { - if (context.sender.getType() === "window") { - const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow() + windowAction(input: WindowActionInput): void { + const { sender } = getIpcContext() + if (sender.getType() === "window") { + const window: BrowserWindow | null = (sender as Sender).getOwnerBrowserWindow() if (!window) return switch (input.action) { @@ -124,18 +124,18 @@ export class AppService extends IpcService { } @IpcMethod() - quitAndInstall(_context: IpcContext): void { + quitAndInstall(): void { quitAndInstall() } @IpcMethod() - readClipboard(_context: IpcContext): string { + readClipboard(): string { return clipboard.readText() } @IpcMethod() - async search(context: IpcContext, input: SearchInput): Promise { - const { sender: webContents } = context + async search(input: SearchInput): Promise { + const { sender: webContents } = getIpcContext() const { promise, resolve } = Promise.withResolvers() @@ -148,12 +148,13 @@ export class AppService extends IpcService { } @IpcMethod() - clearSearch(context: IpcContext): void { - context.sender.stopFindInPage("keepSelection") + clearSearch(): void { + getIpcContext().sender.stopFindInPage("keepSelection") } @IpcMethod() - async download(context: IpcContext, input: string): Promise { + async download(input: string): Promise { + const { sender } = getIpcContext() const result = await dialog.showSaveDialog({ defaultPath: input.split("/").pop(), }) @@ -162,14 +163,14 @@ export class AppService extends IpcService { try { await downloadFile(input, result.filePath) - const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() + const senderWindow = (sender as Sender).getOwnerBrowserWindow() if (senderWindow) { callWindowExpose(senderWindow).toast.success("Download success!", { duration: 1000, }) } } catch (err) { - const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() + const senderWindow = (sender as Sender).getOwnerBrowserWindow() if (senderWindow) { callWindowExpose(senderWindow).toast.error("Download failed!", { duration: 1000, @@ -180,11 +181,9 @@ export class AppService extends IpcService { } @IpcMethod() - async exportCurrentPageAsPdf( - context: IpcContext, - input: ExportCurrentPageAsPdfInput = {}, - ): Promise { - const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() + async exportCurrentPageAsPdf(input: ExportCurrentPageAsPdfInput = {}): Promise { + const { sender } = getIpcContext() + const senderWindow = (sender as Sender).getOwnerBrowserWindow() const dialogOptions: Electron.SaveDialogOptions = { defaultPath: ensurePdfExtension(input.defaultPath || "Untitled.pdf"), filters: [{ name: "PDF", extensions: ["pdf"] }], @@ -196,7 +195,7 @@ export class AppService extends IpcService { if (result.canceled || !result.filePath) return null - const pdfData = await context.sender.printToPDF({ + const pdfData = await sender.printToPDF({ printBackground: true, preferCSSPageSize: true, }) @@ -208,12 +207,12 @@ export class AppService extends IpcService { } @IpcMethod() - getAppPath(_context: IpcContext): string { + getAppPath(): string { return app.getAppPath() } @IpcMethod() - resolveAppAsarPath(_context: IpcContext, input: string): string { + resolveAppAsarPath(input: string): string { const resolvedInput = input.startsWith("file://") ? fileURLToPath(input) : input if (path.isAbsolute(resolvedInput)) { @@ -224,7 +223,7 @@ export class AppService extends IpcService { } @IpcMethod() - readyToShowMainWindow(_context: IpcContext) { + readyToShowMainWindow() { const shouldShowWindow = !app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS) if (shouldShowWindow) { @@ -234,18 +233,18 @@ export class AppService extends IpcService { } @IpcMethod() - openCacheFolder(_context: IpcContext): void { + openCacheFolder(): void { const dir = path.join(app.getPath("userData"), "cache") shell.openPath(dir) } @IpcMethod() - getCacheLimit(_context: IpcContext): number { + getCacheLimit(): number { return store.get(StoreKey.CacheSizeLimit) || 0 } @IpcMethod() - async clearCache(_context: IpcContext): Promise { + async clearCache(): Promise { const cachePath = path.join(app.getPath("userData"), "cache", "Cache_Data") if (process.platform === "win32") { // Request elevation on Windows @@ -268,7 +267,7 @@ export class AppService extends IpcService { } @IpcMethod() - limitCacheSize(_context: IpcContext, input: number): void { + limitCacheSize(input: number): void { if (input === 0) { store.delete(StoreKey.CacheSizeLimit) } else { @@ -277,17 +276,17 @@ export class AppService extends IpcService { } @IpcMethod() - revealLogFile(_context: IpcContext) { + revealLogFile() { return revealLogFile() } @IpcMethod() - getCacheSize(_context: IpcContext) { + getCacheSize() { return getCacheSize() } @IpcMethod() - async selectDirectory(_context: IpcContext): Promise { + async selectDirectory(): Promise { const result = await dialog.showOpenDialog({ properties: ["openDirectory"], }) @@ -296,7 +295,7 @@ export class AppService extends IpcService { } @IpcMethod() - async checkPathExists(_context: IpcContext, input: string): Promise { + async checkPathExists(input: string): Promise { try { await fsp.access(input) return true diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 02d9620026c..888d487bf84 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -5,7 +5,6 @@ import { import { env } from "@follow/shared/env.desktop" import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers" import PKG from "@pkg" -import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" @@ -28,6 +27,37 @@ export class AuthService extends IpcService { private pendingTwoFactorCookieHeader: string | null = null + private async getManagedCookieHeader(): Promise { + const mainWindow = WindowManager.getMainWindow() + if (!mainWindow) { + return "" + } + + return buildManagedAuthCookieHeader( + await getManagedAuthCookies({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + }), + ) + } + + private async persistAuthCookiesFromResponse(response: Response): Promise { + const setCookieValues = + typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [] + const setCookie = + setCookieValues.length > 0 + ? setCookieValues.join(", ") + : response.headers.get("set-cookie") || "" + const mainWindow = WindowManager.getMainWindow() + if (response.ok && setCookie && mainWindow) { + await persistManagedAuthCookiesFromSetCookieHeader({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + setCookieHeader: setCookie, + }) + } + } + private getAuthRequestHeaders(additionalHeaders?: Record) { return { ...createDesktopAPIHeaders({ version: PKG.version }), @@ -104,13 +134,7 @@ export class AuthService extends IpcService { ? setCookieValues.join(", ") : response.headers.get("set-cookie") || "" const mainWindow = WindowManager.getMainWindow() - if (response.ok && setCookie && mainWindow) { - await persistManagedAuthCookiesFromSetCookieHeader({ - apiURL: env.VITE_API_URL, - session: mainWindow.webContents.session, - setCookieHeader: setCookie, - }) - } + await this.persistAuthCookiesFromResponse(response) const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie) this.pendingTwoFactorCookieHeader = @@ -142,7 +166,53 @@ export class AuthService extends IpcService { } @IpcMethod() - async sessionChanged(_context: IpcContext, preferredToken?: string): Promise { + async fetchWithAuth(payload: { + body?: string + headers?: Record + method: string + url: string + }) { + const requestURL = new URL(payload.url) + const apiURL = new URL(env.VITE_API_URL) + if (requestURL.origin !== apiURL.origin) { + throw new Error("Refusing to proxy non-API request") + } + + const headers = new Headers(payload.headers) + headers.delete("content-length") + headers.delete("host") + for (const [key, value] of Object.entries(this.getAuthRequestHeaders())) { + headers.set(key, value) + } + + if (!headers.has("cookie")) { + const cookieHeader = await this.getManagedCookieHeader() + if (cookieHeader) { + headers.set("Cookie", cookieHeader) + } + } + + const response = await fetch(payload.url, { + method: payload.method, + headers, + body: + payload.body !== undefined && payload.method !== "GET" && payload.method !== "HEAD" + ? payload.body + : undefined, + }) + + await this.persistAuthCookiesFromResponse(response) + + return { + body: await response.text(), + headers: Array.from(response.headers.entries()), + status: response.status, + statusText: response.statusText, + } + } + + @IpcMethod() + async sessionChanged(preferredToken?: string): Promise { await updateNotificationsToken() // Sync the current desktop session to the npm CLI login. @@ -155,7 +225,7 @@ export class AuthService extends IpcService { } @IpcMethod() - async signOut(_context: IpcContext): Promise { + async signOut(): Promise { await deleteNotificationsToken() // Clear the synced CLI login on sign out. @@ -165,7 +235,7 @@ export class AuthService extends IpcService { } @IpcMethod() - async signOutRemote(_context: IpcContext, token?: string): Promise { + async signOutRemote(token?: string): Promise { await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, { method: "POST", headers: this.getAuthRequestHeaders( @@ -181,10 +251,11 @@ export class AuthService extends IpcService { } @IpcMethod() - async verifyTotp( - _context: IpcContext, - payload: { code: string; trustDevice?: boolean; headers?: Record }, - ) { + async verifyTotp(payload: { + code: string + trustDevice?: boolean + headers?: Record + }) { const mainWindow = WindowManager.getMainWindow() const cookieHeader = this.pendingTwoFactorCookieHeader || @@ -253,10 +324,11 @@ export class AuthService extends IpcService { } @IpcMethod() - async signInWithCredential( - _context: IpcContext, - payload: { email: string; password: string; headers?: Record }, - ) { + async signInWithCredential(payload: { + email: string + password: string + headers?: Record + }) { return this.requestCredentialAuth( "/sign-in/email", { @@ -268,16 +340,13 @@ export class AuthService extends IpcService { } @IpcMethod() - async signUpWithCredential( - _context: IpcContext, - payload: { - email: string - password: string - name: string - callbackURL: string - headers?: Record - }, - ) { + async signUpWithCredential(payload: { + email: string + password: string + name: string + callbackURL: string + headers?: Record + }) { return this.requestCredentialAuth( "/sign-up/email", { @@ -291,7 +360,7 @@ export class AuthService extends IpcService { } @IpcMethod() - async setSessionToken(_context: IpcContext, token: string): Promise { + async setSessionToken(token: string): Promise { await this.applySessionToken(token) } } diff --git a/apps/desktop/layer/main/src/ipc/services/cli.ts b/apps/desktop/layer/main/src/ipc/services/cli.ts index 09dd2f0be89..4bcf3d47b5b 100644 --- a/apps/desktop/layer/main/src/ipc/services/cli.ts +++ b/apps/desktop/layer/main/src/ipc/services/cli.ts @@ -1,4 +1,3 @@ -import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { @@ -27,7 +26,7 @@ export class CliService extends IpcService { static override readonly groupName = "cli" @IpcMethod() - async getInstallStatus(_context: IpcContext): Promise { + async getInstallStatus(): Promise { const [config, npxAvailable, desktopToken] = await Promise.all([ readCliConfig(), isCliRunnerAvailable(), @@ -46,10 +45,7 @@ export class CliService extends IpcService { } @IpcMethod() - async installCli( - _context: IpcContext, - preferredToken?: string, - ): Promise<{ success: boolean; error?: string }> { + async installCli(preferredToken?: string): Promise<{ success: boolean; error?: string }> { try { if (!(await isCliRunnerAvailable())) { return { success: false, error: "npx is not available. Install Node.js and npm first." } @@ -73,7 +69,7 @@ export class CliService extends IpcService { } @IpcMethod() - async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { + async uninstallCli(): Promise<{ success: boolean; error?: string }> { try { await syncSessionToCliConfig() return { success: true } diff --git a/apps/desktop/layer/main/src/ipc/services/debug.ts b/apps/desktop/layer/main/src/ipc/services/debug.ts index 028d61fc47c..da51b5cfd02 100644 --- a/apps/desktop/layer/main/src/ipc/services/debug.ts +++ b/apps/desktop/layer/main/src/ipc/services/debug.ts @@ -1,5 +1,4 @@ -import type { IpcContext } from "electron-ipc-decorator" -import { IpcMethod, IpcService } from "electron-ipc-decorator" +import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator" interface InspectElementInput { x: number @@ -10,7 +9,7 @@ export class DebugService extends IpcService { static override readonly groupName = "debug" @IpcMethod() - inspectElement(context: IpcContext, input: InspectElementInput): void { - context.sender.inspectElement(input.x, input.y) + inspectElement(input: InspectElementInput): void { + getIpcContext().sender.inspectElement(input.x, input.y) } } diff --git a/apps/desktop/layer/main/src/ipc/services/dock.ts b/apps/desktop/layer/main/src/ipc/services/dock.ts index 1ed1ccafdf5..db5afbf0c36 100644 --- a/apps/desktop/layer/main/src/ipc/services/dock.ts +++ b/apps/desktop/layer/main/src/ipc/services/dock.ts @@ -1,4 +1,3 @@ -import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../../constants/app" @@ -82,7 +81,7 @@ export class DockService extends IpcService { } @IpcMethod() - setDockBadge(_context: IpcContext, count: number): void { + setDockBadge(count: number): void { setDockCount(count) } } diff --git a/apps/desktop/layer/main/src/ipc/services/integration.test.ts b/apps/desktop/layer/main/src/ipc/services/integration.test.ts index 75af50f4cc8..d4c60c08fe2 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.test.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.test.ts @@ -1,7 +1,6 @@ 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" @@ -51,10 +50,9 @@ describe("IntegrationService", () => { 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, { + service.saveToObsidian({ url: "https://example.com", title: "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1/6比例手办", content: "content", diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index 539599f82c9..90e97f1f21b 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -2,7 +2,6 @@ import { existsSync } from "node:fs" import fsp from "node:fs/promises" import { shell } from "electron" -import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import path from "pathe" @@ -86,20 +85,17 @@ export class IntegrationService extends IpcService { static override readonly groupName = "integration" @IpcMethod() - async saveToObsidian( - context: IpcContext, - input: { - url: string - title: string - content: string - author: string - publishedAt: string - vaultPath: string - description?: string - feedTitle?: string - feedUrl?: string - }, - ) { + async saveToObsidian(input: { + url: string + title: string + content: string + author: string + publishedAt: string + vaultPath: string + description?: string + feedTitle?: string + feedUrl?: string + }) { try { const { url, @@ -151,22 +147,22 @@ ${content} } @IpcMethod() - async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise { + async saveToEagle(input: SaveToEagleInput): Promise { return saveMediaToEagle(input) } @IpcMethod() - setEagleContextMenuEnabled(context: IpcContext, input: SetEagleContextMenuEnabledInput): void { + setEagleContextMenuEnabled(input: SetEagleContextMenuEnabledInput): void { store.set("eagleContextMenuEnabled", input.enabled) } @IpcMethod() - async loginToQBittorrent(context: IpcContext, input: LoginToQBittorrentInput) { + async loginToQBittorrent(input: LoginToQBittorrentInput) { const { host, username, password } = input const existingSID = store.get("qbittorrentSID") if (existingSID) { - const errorMessage = await this.checkQBittorrentAuth(context, { host }) + const errorMessage = await this.checkQBittorrentAuth({ host }) if (!errorMessage) { return } @@ -194,7 +190,7 @@ ${content} return } - async checkQBittorrentAuth(context: IpcContext, input: CheckQBittorrentAuthInput) { + async checkQBittorrentAuth(input: CheckQBittorrentAuthInput) { const { host } = input const sid = store.get("qbittorrentSID") if (!sid) { @@ -214,7 +210,7 @@ ${content} } @IpcMethod() - async addMagnet(context: IpcContext, input: AddMagnetInput) { + async addMagnet(input: AddMagnetInput) { const { host, urls } = input const sid = store.get("qbittorrentSID") if (!sid) { @@ -235,12 +231,11 @@ ${content} return `Failed to add magnet links: ${text}` } - // eslint-disable-next-line no-console console.log(`Added magnet links to qBittorrent: ${urls.join(", ")}`) } @IpcMethod() - async customFetch(context: IpcContext, input: CustomFetchInput) { + async customFetch(input: CustomFetchInput) { const requestId = Math.random().toString(36).slice(2, 8) const { url, method, headers, body, timeout = 10_000 } = input @@ -383,7 +378,7 @@ ${content} } @IpcMethod() - async openURLScheme(context: IpcContext, scheme: string) { + async openURLScheme(scheme: string) { const requestId = Math.random().toString(36).slice(2, 8) try { diff --git a/apps/desktop/layer/main/src/ipc/services/menu.ts b/apps/desktop/layer/main/src/ipc/services/menu.ts index 9a01c7c2f2b..17e1b339449 100644 --- a/apps/desktop/layer/main/src/ipc/services/menu.ts +++ b/apps/desktop/layer/main/src/ipc/services/menu.ts @@ -1,7 +1,6 @@ -import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron" +import type { MenuItemConstructorOptions, MessageBoxOptions, WebContents } from "electron" import { dialog, Menu, ShareMenu } from "electron" -import type { IpcContext } from "electron-ipc-decorator" -import { IpcMethod, IpcService } from "electron-ipc-decorator" +import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator" type SerializableMenuItem = Omit & { submenu?: SerializableMenuItem[] @@ -22,7 +21,7 @@ export class MenuService extends IpcService { private normalizeMenuItems( items: SerializableMenuItem[], - context: IpcContext, + sender: WebContents, path: number[] = [], ): MenuItemConstructorOptions[] { return items.map((item, index) => { @@ -30,20 +29,21 @@ export class MenuService extends IpcService { return { ...item, click() { - context.sender.send("menu-click", { + sender.send("menu-click", { id: item.id, path: curPath, }) }, - submenu: item.submenu ? this.normalizeMenuItems(item.submenu, context, curPath) : undefined, + submenu: item.submenu ? this.normalizeMenuItems(item.submenu, sender, curPath) : undefined, } }) } @IpcMethod() - async showContextMenu(context: IpcContext, input: ShowContextMenuInput): Promise { + async showContextMenu(input: ShowContextMenuInput): Promise { + const { sender } = getIpcContext() const defer = Promise.withResolvers() - const normalizedMenuItems = this.normalizeMenuItems(input.items, context) + const normalizedMenuItems = this.normalizeMenuItems(input.items, sender) const menu = Menu.buildFromTemplate(normalizedMenuItems) menu.popup({ @@ -53,7 +53,7 @@ export class MenuService extends IpcService { } @IpcMethod() - async showConfirmDialog(_context: IpcContext, input: ShowConfirmDialogInput): Promise { + async showConfirmDialog(input: ShowConfirmDialogInput): Promise { const result = await dialog.showMessageBox({ message: input.title, detail: input.message, @@ -64,14 +64,15 @@ export class MenuService extends IpcService { } @IpcMethod() - async showShareMenu(context: IpcContext, input: string): Promise { + async showShareMenu(input: string): Promise { + const { sender } = getIpcContext() const menu = new ShareMenu({ urls: [input], }) menu.popup({ callback: () => { - context.sender.send("menu-closed") + sender.send("menu-closed") }, }) } 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 index 90f5bb19052..650570e63b2 100644 --- a/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts +++ b/apps/desktop/layer/main/src/ipc/services/obsidian-frontmatter.test.ts @@ -35,7 +35,7 @@ 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"), + publishedAt: "2026-05-14T04:20:44", description: [ "Author: David, Deep Tide TechFlow.", "", diff --git a/apps/desktop/layer/main/src/ipc/services/reader.ts b/apps/desktop/layer/main/src/ipc/services/reader.ts index d51e50c1731..ab8baf965ba 100644 --- a/apps/desktop/layer/main/src/ipc/services/reader.ts +++ b/apps/desktop/layer/main/src/ipc/services/reader.ts @@ -3,8 +3,7 @@ import fs from "node:fs" import { callWindowExpose } from "@follow/shared/bridge" import { readability } from "@follow-app/readability" import { app, BrowserWindow } from "electron" -import type { IpcContext } from "electron-ipc-decorator" -import { IpcMethod, IpcService } from "electron-ipc-decorator" +import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator" import path from "pathe" import type { ModelResult } from "vscode-languagedetection" @@ -74,7 +73,7 @@ export class ReaderService extends IpcService { static override readonly groupName = "reader" @IpcMethod() - async readability(_context: IpcContext, input: ReadabilityInput) { + async readability(input: ReadabilityInput) { const { url } = input if (!url) { @@ -86,7 +85,7 @@ export class ReaderService extends IpcService { } @IpcMethod() - async tts(context: IpcContext, input: TtsInput): Promise { + async tts(input: TtsInput): Promise { const { id } = input const text = input.text.trim() const voice = input.voice?.trim() @@ -95,7 +94,7 @@ export class ReaderService extends IpcService { return null } - const window = BrowserWindow.fromWebContents(context.sender) + const window = BrowserWindow.fromWebContents(getIpcContext().sender) if (!window) return null const dirPath = path.join(app.getPath("userData"), "Cache", "tts", id) @@ -131,8 +130,8 @@ export class ReaderService extends IpcService { } @IpcMethod() - async getVoices(context: IpcContext) { - const window = BrowserWindow.fromWebContents(context.sender) + async getVoices() { + const window = BrowserWindow.fromWebContents(getIpcContext().sender) try { const response = await fetch(`${TTS_SERVICE_URL}/voices`) if (!response.ok) { @@ -149,7 +148,6 @@ export class ReaderService extends IpcService { @IpcMethod() async detectCodeStringLanguage( - _context: IpcContext, input: DetectCodeStringLanguageInput, ): Promise { const { codeString } = input diff --git a/apps/desktop/layer/main/src/ipc/services/setting.ts b/apps/desktop/layer/main/src/ipc/services/setting.ts index e6d3d0b003e..5ade827d595 100644 --- a/apps/desktop/layer/main/src/ipc/services/setting.ts +++ b/apps/desktop/layer/main/src/ipc/services/setting.ts @@ -1,7 +1,6 @@ import { createRequire } from "node:module" import { app, nativeTheme } from "electron" -import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { WindowManager } from "~/manager/window" @@ -23,62 +22,62 @@ export class SettingService extends IpcService { static override readonly groupName = "setting" @IpcMethod() - getLoginItemSettings(_context: IpcContext): Electron.LoginItemSettings { + getLoginItemSettings(): Electron.LoginItemSettings { return app.getLoginItemSettings() } @IpcMethod() - setLoginItemSettings(_context: IpcContext, input: SetLoginItemSettingsInput): void { + setLoginItemSettings(input: SetLoginItemSettingsInput): void { app.setLoginItemSettings(input) } @IpcMethod() - openSettingWindow(_context: IpcContext): void { + openSettingWindow(): void { WindowManager.showSetting() } @IpcMethod() - async getSystemFonts(_context: IpcContext): Promise { + async getSystemFonts(): Promise { const fonts = await require("font-list").getFonts() return fonts.map((font: string) => font.replaceAll('"', "")) } @IpcMethod() - getAppearance(_context: IpcContext): "light" | "dark" | "system" { + getAppearance(): "light" | "dark" | "system" { return nativeTheme.themeSource } @IpcMethod() - setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void { + setAppearance(appearance: "light" | "dark" | "system"): void { nativeTheme.themeSource = appearance store.set("appearance", appearance) } @IpcMethod() - getMinimizeToTray(_context: IpcContext): boolean { + getMinimizeToTray(): boolean { return getTrayConfig() } @IpcMethod() - setMinimizeToTray(_context: IpcContext, minimize: boolean): void { + setMinimizeToTray(minimize: boolean): void { setTrayConfig(minimize) } @IpcMethod() - getProxyConfig(_context: IpcContext) { + getProxyConfig() { const proxy = store.get("proxy") return proxy ?? undefined } @IpcMethod() - setProxyConfig(_context: IpcContext, config: string) { + setProxyConfig(config: string) { const result = setProxyConfig(config) updateProxy() return result } @IpcMethod() - getMessagingToken(_context: IpcContext): string | null { + getMessagingToken(): string | null { return store.get("notifications-credentials") as string | null } } diff --git a/apps/desktop/layer/main/src/lib/cleaner.ts b/apps/desktop/layer/main/src/lib/cleaner.ts index 5e9f6cc9f18..678f32ab67c 100644 --- a/apps/desktop/layer/main/src/lib/cleaner.ts +++ b/apps/desktop/layer/main/src/lib/cleaner.ts @@ -74,12 +74,10 @@ export const clearAllData = async () => { await ses.clearStorageData({ storages: [ - "websql", "filesystem", "indexdb", "localstorage", "shadercache", - "websql", "serviceworkers", "cookies", ], diff --git a/apps/desktop/layer/main/src/lib/user.test.ts b/apps/desktop/layer/main/src/lib/user.test.ts new file mode 100644 index 00000000000..ca4305edd08 --- /dev/null +++ b/apps/desktop/layer/main/src/lib/user.test.ts @@ -0,0 +1,109 @@ +import type { Credentials } from "@eneris/push-receiver/dist/types" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { logger } from "~/logger" + +import { apiClient } from "./api-client" +import { store } from "./store" +import { updateNotificationsToken } from "./user" + +const { createTokenMock } = vi.hoisted(() => ({ + createTokenMock: vi.fn(), +})) + +vi.mock("./api-client", () => ({ + apiClient: { + messaging: { + createToken: createTokenMock, + deleteToken: vi.fn(), + }, + }, +})) + +vi.mock("./store", () => ({ + store: { + get: vi.fn(), + set: vi.fn(), + }, +})) + +vi.mock("~/env", () => ({ + isLinux: false, + isMacOS: true, + isWindows: false, +})) + +vi.mock("~/logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + }, +})) + +const credentials = { + fcm: { + installation: { + createdAt: 0, + expiresIn: 0, + fid: "fid", + refreshToken: "refresh-token-secret", + token: "installation-token-secret", + }, + token: "fcm-token-secret", + }, + gcm: { + androidId: "android-id", + appId: "app-id", + securityToken: "security-token-secret", + token: "gcm-token-secret", + }, + keys: { + authSecret: "auth-secret", + privateKey: "private-key-secret", + publicKey: "public-key", + }, + config: { + bundleId: "is.follow", + projectId: "project-id", + vapidKey: "vapid-key", + }, +} satisfies Credentials + +describe("updateNotificationsToken", () => { + beforeEach(() => { + vi.clearAllMocks() + createTokenMock.mockResolvedValue(undefined) + }) + + it("registers the token without writing it to logs", async () => { + await updateNotificationsToken(credentials) + + expect(store.set).toHaveBeenCalledWith("notifications-credentials", credentials) + expect(apiClient.messaging.createToken).toHaveBeenCalledWith({ + channel: "macos", + token: credentials.fcm.token, + }) + + const serializedLogs = JSON.stringify([ + ...vi.mocked(logger.info).mock.calls, + ...vi.mocked(logger.error).mock.calls, + ]) + expect(serializedLogs).not.toContain(credentials.fcm.token) + }) + + it("logs only a safe summary when registration fails", async () => { + createTokenMock.mockRejectedValueOnce({ + message: "request failed", + request: { + body: { + token: credentials.fcm.token, + }, + }, + }) + + await updateNotificationsToken(credentials) + + expect(logger.error).toHaveBeenCalledWith("Failed to update notification token") + expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(credentials.fcm.token) + }) +}) diff --git a/apps/desktop/layer/main/src/lib/user.ts b/apps/desktop/layer/main/src/lib/user.ts index dd6a77161ba..1c853f04a5d 100644 --- a/apps/desktop/layer/main/src/lib/user.ts +++ b/apps/desktop/layer/main/src/lib/user.ts @@ -25,9 +25,9 @@ export const updateNotificationsToken = async (newCredentials?: Credentials) => token: credentials.fcm.token, channel: notificationChannel, }) - logger.info("updateNotificationsToken success: ", credentials.fcm.token) - } catch (error) { - logger.error("updateNotificationsToken error: ", error) + logger.info("Notification token updated successfully") + } catch { + logger.error("Failed to update notification token") } } } diff --git a/apps/desktop/layer/main/src/manager/app.test.ts b/apps/desktop/layer/main/src/manager/app.test.ts new file mode 100644 index 00000000000..e864bd3a528 --- /dev/null +++ b/apps/desktop/layer/main/src/manager/app.test.ts @@ -0,0 +1,213 @@ +import type { Credentials } from "@eneris/push-receiver/dist/types" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { logger } from "~/logger" + +import { updateNotificationsToken } from "../lib/user" +import { AppManager } from "./app" + +const credentials = { + fcm: { + installation: { + createdAt: 0, + expiresIn: 0, + fid: "fid", + refreshToken: "refresh-token-secret", + token: "installation-token-secret", + }, + token: "fcm-token-secret", + }, + gcm: { + androidId: "android-id", + appId: "app-id", + securityToken: "security-token-secret", + token: "gcm-token-secret", + }, + keys: { + authSecret: "auth-secret", + privateKey: "private-key-secret", + publicKey: "public-key", + }, + config: { + bundleId: "is.follow", + projectId: "project-id", + vapidKey: "vapid-key", + }, +} satisfies Credentials + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + firebaseConfig: { + apiKey: "firebase-api-key-secret", + appId: "app-id", + messagingSenderId: "sender-id", + projectId: "project-id", + }, + onCredentialsChanged: undefined as ((event: { newCredentials: Credentials }) => void) | undefined, + onNotification: undefined as + ((event: { message: { data?: Record } }) => void) | undefined, + onReady: undefined as (() => void) | undefined, + receiverConfig: undefined as Record | undefined, + storeGet: vi.fn(), + storeSet: vi.fn(), + updateNotificationsToken: vi.fn(), +})) + +vi.mock("@eneris/push-receiver", () => ({ + PushReceiver: class { + persistentIds = ["persistent-id"] + + constructor(config: Record) { + mocks.receiverConfig = config + } + + connect = mocks.connect + + onCredentialsChanged(callback: (event: { newCredentials: Credentials }) => void) { + mocks.onCredentialsChanged = callback + } + + onNotification(callback: (event: { message: { data?: Record } }) => void) { + mocks.onNotification = callback + } + + onReady(callback: () => void) { + mocks.onReady = callback + } + }, +})) + +vi.mock("@follow/shared/bridge", () => ({ + callWindowExpose: vi.fn(), +})) + +vi.mock("@follow/shared/constants", () => ({ + APP_PROTOCOL: "folo", + DEV: false, + LEGACY_APP_PROTOCOL: "follow", +})) + +vi.mock("@follow/shared/env.desktop", () => ({ + env: { + VITE_FIREBASE_CONFIG: JSON.stringify(mocks.firebaseConfig), + }, +})) + +vi.mock("electron", () => ({ + app: {}, + nativeTheme: {}, + Notification: class {}, + shell: {}, +})) + +vi.mock("electron-context-menu", () => ({ + default: vi.fn(), +})) + +vi.mock("~/manager/window", () => ({ + WindowManager: {}, +})) + +vi.mock("../helper", () => ({ getIconPath: vi.fn() })) +vi.mock("../ipc", () => ({ initializeIpcServices: vi.fn() })) +vi.mock("../ipc/services/integration", () => ({ saveMediaToEagle: vi.fn() })) +vi.mock("../lib/cleaner", () => ({ + checkAndCleanCodeCache: vi.fn(), + clearCacheCronJob: vi.fn(), +})) +vi.mock("../lib/cli-session-sync", () => ({ + getSessionTokenFromCookies: vi.fn(), + syncSessionToCliConfig: vi.fn(), +})) +vi.mock("../lib/i18n", () => ({ t: vi.fn() })) +vi.mock("../lib/proxy", () => ({ updateProxy: vi.fn() })) +vi.mock("../lib/store", () => ({ + store: { + get: mocks.storeGet, + set: mocks.storeSet, + }, +})) +vi.mock("../lib/tray", () => ({ registerAppTray: vi.fn() })) +vi.mock("../lib/user", () => ({ + updateNotificationsToken: mocks.updateNotificationsToken, +})) +vi.mock("../logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + }, +})) +vi.mock("../menu", () => ({ registerAppMenu: vi.fn() })) +vi.mock("../updater", () => ({ registerUpdater: vi.fn() })) +vi.mock("./lifecycle", () => ({ + LifecycleManager: { + onReady: vi.fn(), + }, +})) + +describe("AppManager push notifications", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.receiverConfig = undefined + mocks.onCredentialsChanged = undefined + mocks.onNotification = undefined + mocks.onReady = undefined + mocks.connect.mockResolvedValue(undefined) + mocks.storeGet.mockImplementation((key: string) => { + if (key === "notifications-credentials") return credentials + if (key === "notifications-persistent-ids") return ["stored-persistent-id"] + return undefined + }) + }) + + it("initializes and handles events without logging push secrets or payloads", async () => { + const registerPushNotifications = Reflect.get( + AppManager, + "registerPushNotifications", + ) as () => Promise + + await registerPushNotifications.call(AppManager) + mocks.onCredentialsChanged?.({ newCredentials: credentials }) + mocks.onNotification?.({ + message: { + data: { + description: "private-notification-description", + type: "private-event", + }, + }, + }) + + expect(mocks.receiverConfig?.debug).toBe(false) + expect(updateNotificationsToken).toHaveBeenCalledWith(credentials) + + const serializedLogs = JSON.stringify([ + ...vi.mocked(logger.info).mock.calls, + ...vi.mocked(logger.error).mock.calls, + ]) + for (const secret of [ + credentials.fcm.token, + credentials.fcm.installation.refreshToken, + credentials.keys.privateKey, + mocks.firebaseConfig.apiKey, + "private-notification-description", + ]) { + expect(serializedLogs).not.toContain(secret) + } + }) + + it("does not log connection error details or report a failed connection as connected", async () => { + mocks.connect.mockRejectedValueOnce( + new Error(`connection failed with ${credentials.fcm.token}`), + ) + const registerPushNotifications = Reflect.get( + AppManager, + "registerPushNotifications", + ) as () => Promise + + await registerPushNotifications.call(AppManager) + + expect(logger.error).toHaveBeenCalledWith("PushReceiver connection failed") + expect(logger.info).not.toHaveBeenCalledWith("PushReceiver connected") + expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(credentials.fcm.token) + }) +}) diff --git a/apps/desktop/layer/main/src/manager/app.ts b/apps/desktop/layer/main/src/manager/app.ts index 8a99787e759..db1d5351cb9 100644 --- a/apps/desktop/layer/main/src/manager/app.ts +++ b/apps/desktop/layer/main/src/manager/app.ts @@ -108,32 +108,26 @@ class AppManagerStatic { const persistentIds = store.get(persistentIdsKey) const instance = new PushReceiver({ - debug: true, + debug: false, firebase: JSON.parse(env.VITE_FIREBASE_CONFIG), persistentIds: persistentIds || [], credentials: credentials || undefined, bundleId: "is.follow", chromeId: "is.follow", }) - logger.info( - `PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${ - env.VITE_FIREBASE_CONFIG - }`, - ) + logger.info("PushReceiver initialized") instance.onReady(() => { logger.info("PushReceiver ready") }) instance.onCredentialsChanged(({ newCredentials }) => { - logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`) + logger.info("PushReceiver credentials changed") updateNotificationsToken(newCredentials) }) instance.onNotification((notification) => { - logger.info( - `PushReceiver received notification: ${JSON.stringify(notification.message.data)}`, - ) + logger.info("PushReceiver received notification") const { data } = notification.message if (!data) { return @@ -167,11 +161,10 @@ class AppManagerStatic { try { await instance.connect() - } catch (error) { - logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`) + logger.info("PushReceiver connected") + } catch { + logger.error("PushReceiver connection failed") } - - logger.info("PushReceiver connected") } private contextMenuDisposer?: () => void diff --git a/apps/desktop/layer/main/tsconfig.json b/apps/desktop/layer/main/tsconfig.json index a6f78f0af26..0928f516fa9 100644 --- a/apps/desktop/layer/main/tsconfig.json +++ b/apps/desktop/layer/main/tsconfig.json @@ -11,7 +11,6 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "experimentalDecorators": true, - "baseUrl": ".", "paths": { "@pkg": ["../../package.json"], "@locales/*": ["../../../../locales/*"], diff --git a/apps/desktop/layer/main/vitest.config.ts b/apps/desktop/layer/main/vitest.config.ts index 359b3b0a8ea..e3941a73b82 100644 --- a/apps/desktop/layer/main/vitest.config.ts +++ b/apps/desktop/layer/main/vitest.config.ts @@ -1,13 +1,14 @@ import { fileURLToPath } from "node:url" import tsconfigPath from "vite-tsconfig-paths" -import { defineProject } from "vitest/config" +import { configDefaults, defineProject } from "vitest/config" const __dirname = fileURLToPath(new URL(".", import.meta.url)) export default defineProject({ root: "./", test: { + exclude: [...configDefaults.exclude, "dist/**", "out/**"], globals: true, environment: "node", }, diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index d3aca995f1a..e4b95ff1923 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -23,97 +23,97 @@ "@follow/store": "workspace:*", "@follow/tracker": "workspace:*", "@fontsource/sn-pro": "5.2.6", - "@headlessui/react": "2.2.9", - "@hookform/resolvers": "5.2.2", - "@lexical/markdown": "0.40.0", - "@lexical/react": "0.40.0", - "@number-flow/react": "0.5.11", - "@radix-ui/react-avatar": "1.1.11", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.8", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.4", - "@shikijs/transformers": "3.22.0", + "@headlessui/react": "2.2.10", + "@hookform/resolvers": "5.4.0", + "@lexical/markdown": "0.46.0", + "@lexical/react": "0.46.0", + "@number-flow/react": "0.6.1", + "@radix-ui/react-avatar": "1.2.2", + "@radix-ui/react-context-menu": "2.3.3", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.20", + "@radix-ui/react-hover-card": "1.1.19", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-popover": "1.1.19", + "@radix-ui/react-slider": "1.4.3", + "@radix-ui/react-slot": "1.3.0", + "@shikijs/transformers": "4.3.1", "@splinetool/react-spline": "4.1.0", - "@tanstack/query-sync-storage-persister": "5.90.22", - "@tanstack/react-query": "5.90.21", - "@tanstack/react-query-devtools": "5.91.3", - "@tanstack/react-query-persist-client": "5.90.22", - "@tanstack/react-virtual": "3.13.18", - "@toon-format/toon": "2.1.0", + "@tanstack/query-sync-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-devtools": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", + "@tanstack/react-virtual": "3.14.5", + "@toon-format/toon": "2.3.0", "@use-gesture/react": "10.3.1", "@welldone-software/why-did-you-render": "10.0.1", - "@xyflow/react": "12.10.0", + "@xyflow/react": "12.11.2", "@yornaath/batshit": "0.14.0", - "ai": "6.0.85", + "ai": "7.0.18", "camelcase-keys": "10.0.2", - "chrono-node": "2.9.0", + "chrono-node": "2.9.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", - "cookie-es": "2.0.0", - "dayjs": "1.11.19", + "cookie-es": "3.1.1", + "dayjs": "1.11.21", "dnum": "2.17.0", - "electron-ipc-decorator": "0.2.0", + "electron-ipc-decorator": "1.0.1", "embla-carousel-react": "8.6.0", "embla-carousel-wheel-gestures": "8.1.0", - "es-toolkit": "1.44.0", - "firebase": "12.9.0", - "foxact": "0.2.52", + "es-toolkit": "1.49.0", + "firebase": "12.16.0", + "foxact": "0.3.8", "franc-min": "6.2.0", - "fuse.js": "7.1.0", + "fuse.js": "7.4.2", "hast-util-to-jsx-runtime": "2.3.6", "hast-util-to-mdast": "10.1.2", - "i18next": "25.8.6", + "i18next": "26.3.5", "i18next-browser-languagedetector": "8.2.1", - "idb-keyval": "6.2.2", - "immer": "11.1.4", - "jotai": "2.17.1", + "idb-keyval": "6.3.0", + "immer": "11.1.11", + "jotai": "2.20.1", "lethargy": "1.0.9", - "lexical": "0.40.0", + "lexical": "0.46.0", "masonic": "4.1.0", "mdast-util-gfm-table": "2.0.0", "mdast-util-to-markdown": "2.1.2", - "motion": "12.34.0", - "nanoid": "5.1.6", + "motion": "12.42.2", + "nanoid": "5.1.16", "ofetch": "1.5.1", "plain-shiki": "0.3.2", "re-resizable": "6.11.2", - "react": "19.0.0", + "react": "19.2.7", "react-blurhash": "0.3.0", - "react-dom": "19.0.0", - "react-error-boundary": "6.1.0", + "react-dom": "19.2.7", + "react-error-boundary": "6.1.2", "react-fast-compare": "3.2.2", "react-fast-marquee": "1.6.5", "react-google-recaptcha-v3": "1.11.0", - "react-hook-form": "7.71.1", - "react-hotkeys-hook": "5.2.4", - "react-i18next": "16.5.4", - "react-intersection-observer": "10.0.2", + "react-hook-form": "7.81.0", + "react-hotkeys-hook": "5.3.3", + "react-i18next": "17.0.8", + "react-intersection-observer": "10.0.3", "react-ios-pwa-prompt": "2.0.6", "react-markdown": "10.1.0", - "react-qr-code": "2.0.18", + "react-qr-code": "2.2.0", "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", - "react-router": "7.13.0", + "react-router": "8.2.0", "react-selecto": "1.26.3", "react-shadow": "20.6.0", - "react-zoom-pan-pinch": "3.7.0", + "react-zoom-pan-pinch": "4.0.3", "rehype-raw": "7.0.0", - "shiki": "3.22.0", + "shiki": "4.3.1", "sonner": "2.0.7", - "tinykeys": "3.0.0", + "tinykeys": "4.0.0", "title-case": "4.3.2", - "tldts": "7.0.23", - "ufo": "1.6.3", + "tldts": "7.4.7", + "ufo": "1.6.4", "use-context-selector": "2.0.0", "use-sync-external-store": "1.6.0", "usehooks-ts": "3.1.1", - "zod": "3.25.76", - "zustand": "5.0.11" + "zod": "4.4.3", + "zustand": "5.0.14" }, "devDependencies": { "@follow/atoms": "workspace:*", @@ -125,11 +125,11 @@ "@follow/types": "workspace:*", "@follow/utils": "workspace:*", "@folo-services/ai-tools": "catalog:", - "@types/node": "25.2.3", + "@types/node": "26.1.1", "@vite-pwa/assets-generator": "1.0.2", "fake-indexeddb": "6.2.5", - "happy-dom": "20.6.1", - "react-scan": "0.4.3", + "happy-dom": "20.10.6", + "react-scan": "0.5.7", "typescript": "catalog:" } } diff --git a/apps/desktop/layer/renderer/setup-file.ts b/apps/desktop/layer/renderer/setup-file.ts index 1af298ffbe7..0c5626f248a 100644 --- a/apps/desktop/layer/renderer/setup-file.ts +++ b/apps/desktop/layer/renderer/setup-file.ts @@ -3,19 +3,88 @@ import "fake-indexeddb/auto" import { enableMapSet } from "immer" -globalThis.window = { - location: new URL("https://example.com"), - __dbIsReady: true, - addEventListener: () => {}, - get navigator() { - return globalThis.navigator - }, -} +const createMemoryStorage = () => { + const data = new Map() -if (!globalThis.navigator) { - globalThis.navigator = { - onLine: true, - userAgent: "node", + return { + get length() { + return data.size + }, + clear() { + data.clear() + }, + getItem(key) { + return data.get(String(key)) ?? null + }, + key(index) { + return Array.from(data.keys())[index] ?? null + }, + removeItem(key) { + data.delete(String(key)) + }, + setItem(key, value) { + data.set(String(key), String(value)) + }, } } + +const testNavigator = globalThis.navigator ?? { + onLine: true, + userAgent: "node", +} + +const testWindow = globalThis.window ?? {} + +globalThis.window = testWindow + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: testNavigator, +}) + +Object.defineProperties(testWindow, { + location: { + configurable: true, + value: testWindow.location ?? new URL("https://example.com"), + }, + __dbIsReady: { + configurable: true, + value: true, + }, + addEventListener: { + configurable: true, + writable: true, + value: testWindow.addEventListener?.bind(testWindow) ?? (() => {}), + }, + navigator: { + configurable: true, + value: testNavigator, + }, +}) + +const localStorageMock = testWindow.localStorage ?? createMemoryStorage() +const sessionStorageMock = testWindow.sessionStorage ?? createMemoryStorage() + +Object.defineProperties(testWindow, { + localStorage: { + configurable: true, + value: localStorageMock, + }, + sessionStorage: { + configurable: true, + value: sessionStorageMock, + }, +}) + +Object.defineProperties(globalThis, { + localStorage: { + configurable: true, + value: localStorageMock, + }, + sessionStorage: { + configurable: true, + value: sessionStorageMock, + }, +}) + enableMapSet() diff --git a/apps/desktop/layer/renderer/src/atoms/updater.ts b/apps/desktop/layer/renderer/src/atoms/updater.ts index 2ae4a1dc966..f4d6988f5ff 100644 --- a/apps/desktop/layer/renderer/src/atoms/updater.ts +++ b/apps/desktop/layer/renderer/src/atoms/updater.ts @@ -26,11 +26,7 @@ type DistributionUpdaterStatus = BaseUpdaterStatus<"distribution"> & { } export type UpdaterStatusAtom = - | AppUpdaterStatus - | RendererUpdaterStatus - | PwaUpdaterStatus - | DistributionUpdaterStatus - | null + AppUpdaterStatus | RendererUpdaterStatus | PwaUpdaterStatus | DistributionUpdaterStatus | null export const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks( atom(null as UpdaterStatusAtom), ) diff --git a/apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx b/apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx index 8b1ae387ca5..a32cfd633f2 100644 --- a/apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx +++ b/apps/desktop/layer/renderer/src/components/common/LCPEndDetector.tsx @@ -34,8 +34,7 @@ export const LCPEndDetector = () => { scheduleRaf(() => { // Prefer idle if available to avoid jank const ric = (typeof window !== "undefined" && (window as any).requestIdleCallback) as - | ((cb: () => void, opts?: { timeout?: number }) => number) - | undefined + ((cb: () => void, opts?: { timeout?: number }) => number) | undefined if (ric) { const id = ric(() => jotaiStore.set(LCPEndAtom, true), { timeout: 200, @@ -101,8 +100,7 @@ export const LCPEndDetector = () => { rafIds.forEach((id) => (window.cancelAnimationFrame as (h: number) => void)(id)) } const cic = (typeof window !== "undefined" && (window as any).cancelIdleCallback) as - | ((id: number) => void) - | undefined + ((id: number) => void) | undefined if (cic) idleCallbackIds.forEach((id) => cic(id)) timeoutIds.forEach((id) => clearTimeout(id)) if (safetyTimer) clearTimeout(safetyTimer) @@ -116,5 +114,4 @@ export const LCPEndDetector = () => { return null } -// eslint-disable-next-line react-refresh/only-export-components export const isLCPEnded = () => jotaiStore.get(LCPEndAtom) diff --git a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts index 0b071f67160..771418866a1 100644 --- a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts +++ b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shared.ts @@ -4,7 +4,8 @@ import { transformerNotationHighlight, } from "@shikijs/transformers" import type { ShikiTransformer } from "shiki" -import { createHighlighterCoreSync, createJavaScriptRegexEngine } from "shiki" +import { createHighlighterCoreSync } from "shiki/core" +import { createJavaScriptRegexEngine } from "shiki/engine/javascript" export const shikiTransformers: ShikiTransformer[] = [ transformerMetaHighlight(), diff --git a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css index 70381462387..0746f385d9b 100644 --- a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css +++ b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/shiki.module.css @@ -1,3 +1,5 @@ +@reference "../../../../styles/main.css"; + .shiki-wrapper { @apply overflow-hidden; diff --git a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx index f3f1b1a5f47..ae05296d9a7 100644 --- a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -171,7 +171,7 @@ const DropdownMenuItem = ({ ref={ref} className={cn( "relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - "focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1", + "focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background-accent-9-1", "h-[28px]", inset && "pl-8", className, diff --git a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx index a85e576da0a..97e5404b835 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx @@ -598,16 +598,17 @@ const DOMImageViewer: FC<{ centerOnInit={true} smooth={true} centerZoomedOut - alignmentAnimation={{ + autoAlignment={{ sizeX: 0, sizeY: 0, velocityAlignmentTime: 0.2, }} velocityAnimation={{ - sensitivity: 1, + sensitivityMouse: 1, + sensitivityTouch: 1, animationTime: 0.2, }} - onTransformed={onTransformed} + onTransform={onTransformed} > {state.hasAudio && !state.muted && state.playing && } - {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} ({ state, controls, wrapperRef, src, variant }), @@ -189,7 +189,6 @@ const BizControlOutsideMedia = () => { } const FloatMutedButton = () => { - // eslint-disable-next-line @eslint-react/no-use-context const ctx = useContext(VideoPlayerContext) const isMuted = ctx.state.muted return ( @@ -372,7 +371,6 @@ const VolumeControl = () => { } const PlayProgressBar = () => { - // eslint-disable-next-line @eslint-react/no-use-context const { state, controls } = useContext(VideoPlayerContext) const [currentDragging, setCurrentDragging] = useState(false) const [dragTime, setDragTime] = useState(0) diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx index 7b7e0b9eb15..f58a1f08117 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/modal.tsx @@ -441,7 +441,7 @@ const ModalContext: FC< return ( - {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} ({ diff --git a/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts b/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts index 1c28ce0ae96..4aee1e507a0 100644 --- a/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts +++ b/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts @@ -1,6 +1,8 @@ import { callWindowExposeRenderer } from "@follow/shared/bridge" import { detectIsEditableElement } from "@follow/utils" +import { showSettings } from "~/modules/settings/modal/use-setting-modal-hack" + interface ShortcutDefinition { accelerator: string action: () => void @@ -24,7 +26,7 @@ export const registerAppGlobalShortcuts = () => { const shortcuts: ShortcutDefinition[] = [ { accelerator: "CmdOrCtrl+,", - action: () => window.router.showSettings(), + action: () => showSettings(), inputBypass: true, }, { diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index 33f489cada2..101088cdd7b 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -9,17 +9,63 @@ import PKG from "@pkg" import { setLoginModalShow } from "~/atoms/user" +import { ipcServices } from "./client" import { getAuthSessionToken, getClientId, getSessionId } from "./client-session" +const isElectronRuntime = () => { + return IN_ELECTRON || (typeof window !== "undefined" && !!window.electron) +} + +const fetchWithElectronAuth = async (request: Request) => { + const requestURL = new URL(request.url) + const apiURL = new URL(env.VITE_API_URL) + const authService = ipcServices?.auth as + | (NonNullable["auth"] & { + fetchWithAuth?: (payload: { + body?: string + headers?: Record + method: string + url: string + }) => Promise<{ + body: string + headers: [string, string][] + status: number + statusText: string + }> + }) + | undefined + + if (!isElectronRuntime() || requestURL.origin !== apiURL.origin || !authService?.fetchWithAuth) { + return fetch(request) + } + + const body = + request.method !== "GET" && request.method !== "HEAD" ? await request.clone().text() : undefined + const response = await authService.fetchWithAuth({ + body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + url: request.url, + }) + + return new Response(response.body, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }) +} + export const followClient = new FollowClient({ credentials: "include", timeout: 60_000, baseURL: env.VITE_API_URL, - fetch: async (input, options = {}) => - fetch(input.toString(), { + fetch: async (input, options = {}) => { + const request = new Request(input.toString(), { ...options, cache: "no-store", - }), + }) + return fetchWithElectronAuth(request) + }, }) export const followApi = followClient.api @@ -29,7 +75,7 @@ followClient.addRequestInterceptor(async (ctx) => { headers.set("X-Client-Id", getClientId()) headers.set("X-Session-Id", getSessionId()) - const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null + const authSessionToken = isElectronRuntime() ? getAuthSessionToken() : null if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) { headers.set( "Cookie", @@ -48,7 +94,7 @@ followClient.addRequestInterceptor(async (ctx) => { followClient.addResponseInterceptor(async ({ response }) => { if (response.status === 401) { - const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null + const authSessionToken = isElectronRuntime() ? getAuthSessionToken() : null const shouldPromptForLogin = response.url.includes("/better-auth/get-session") || (!whoami() && !authSessionToken) diff --git a/apps/desktop/layer/renderer/src/main.tsx b/apps/desktop/layer/renderer/src/main.tsx index e09c49b0cd7..b312ebf54ac 100644 --- a/apps/desktop/layer/renderer/src/main.tsx +++ b/apps/desktop/layer/renderer/src/main.tsx @@ -1,5 +1,4 @@ import "./wdyr" -import "@follow/components/tailwind" import "./styles/main.css" import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants" @@ -31,7 +30,6 @@ initializeApp().finally(() => { } }) - // eslint-disable-next-line @eslint-react/dom/no-flush-sync flushSync(() => setAppIsReady(true)) }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx index 3444267d3e4..dc5a9a50c5c 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx @@ -8,7 +8,7 @@ import type { EditorState, LexicalEditor } from "lexical" import { $getRoot } from "lexical" import type { Ref } from "react" import { memo, use, useCallback, useImperativeHandle, useRef, useState } from "react" -import { matchKeyBindingPress, parseKeybinding } from "tinykeys" +import { matchKeybindingPress, parseKeybinding } from "tinykeys" import { AIChatContextBar } from "~/modules/ai-chat/components/layouts/AIChatContextBar" import { COMMAND_ID } from "~/modules/command/commands/id" @@ -24,7 +24,7 @@ import { AIModelIndicator } from "./AIModelIndicator" const chatInputVariants = cva( [ - "bg-mix-background/transparent-8/2 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80", + "bg-mix-background-transparent-8-2 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80", "relative overflow-hidden rounded-2xl border backdrop-blur-background duration-200 focus-within:ring-2", "z-[1]", ], @@ -129,7 +129,7 @@ export const ChatInput = memo( const presses = parseKeybinding(shortcut) // For single key shortcuts (not sequences), check if the first press matches - return presses.length === 1 && presses[0] && matchKeyBindingPress(event, presses[0]) + return presses.length === 1 && presses[0] && matchKeybindingPress(event, presses[0]) }) if (matchesToggleShortcut) { diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx index aa0a9473e31..3360a63f043 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx @@ -11,7 +11,7 @@ export const ScrollToBottomButton = ({ onClick }: ScrollToBottomButtonProps) => type="button" onClick={onClick} className={cn( - "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2", + "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2", "border-border", "hover:border-border/60 active:scale-[0.98]", )} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/shared/common-states.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/shared/common-states.tsx index a91ba309708..340d5de106c 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/shared/common-states.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/shared/common-states.tsx @@ -14,7 +14,7 @@ export const LoadingState = ({ description = "Fetching data..." }: LoadingStateP export const ErrorState = ({ error = "An error occurred. Please try again." }: ErrorStateProps) => { return ( -
+
{error}
) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/types.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/types.ts index 663dc8abc4b..264094540f8 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/types.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/types.ts @@ -43,11 +43,7 @@ export interface ViewMentionData extends MentionBaseData { } export type MentionData = - | EntryMentionData - | FeedMentionData - | DateMentionData - | CategoryMentionData - | ViewMentionData + EntryMentionData | FeedMentionData | DateMentionData | CategoryMentionData | ViewMentionData export type MentionType = MentionData["type"] diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts index 8745bae656d..b0d36920817 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts @@ -37,8 +37,7 @@ export type AIChatContextBlock = ValueContextBlock | FileAttachmentContextBlock // Helper type for creating new blocks without id export type AIChatContextBlockInput = - | Omit - | Omit + Omit | Omit export type AIChatContextBlockType = AIChatContextBlock["type"] diff --git a/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-chat-pane.tsx b/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-chat-pane.tsx index f940ad9f1e9..8a6c7ec5410 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-chat-pane.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-chat-pane.tsx @@ -526,7 +526,7 @@ function AIChatInterface({ inputRef }: AIChatInterfaceProps) { type="button" onClick={() => resetScrollState()} className={cn( - "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2", + "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2", "border-border", "hover:border-border/60 active:scale-[0.98]", )} diff --git a/apps/desktop/layer/renderer/src/modules/ai-task/components/schedule-config.tsx b/apps/desktop/layer/renderer/src/modules/ai-task/components/schedule-config.tsx index 102a63b5777..b3e49dc8e92 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-task/components/schedule-config.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-task/components/schedule-config.tsx @@ -349,7 +349,7 @@ export const ScheduleConfig = memo( } value={value.dayOfWeek.toString()} > - + @@ -406,7 +406,7 @@ export const ScheduleConfig = memo( } value={value.dayOfMonth.toString()} > - + diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx index 9042333f296..f7f9ce4ad10 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx @@ -138,7 +138,6 @@ const FeedResponsiveResizerContainer = ({ }, [feedColumnShow]) const when = useGlobalFocusableScopeSelector( - // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback React.useCallback((activeScope) => !activeScope.or(...FloatingLayerScope), []), ) diff --git a/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx b/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx index 2cd76257ae1..540e95dbec5 100644 --- a/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx +++ b/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/dom/no-missing-iframe-sandbox */ import { Button } from "@follow/components/ui/button/index.js" import { Switch } from "@follow/components/ui/switch/index.jsx" import { diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index f6913f4e47d..35e611b9a5a 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -12,6 +12,8 @@ import { Input } from "@follow/components/ui/input/Input.js" import type { LoginRuntime } from "@follow/shared/auth" import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" +import { userActions } from "@follow/store/user/store" +import type { AuthUser } from "@follow-app/client-sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { Trans, useTranslation } from "react-i18next" @@ -83,6 +85,47 @@ const getAuthTokenFromResult = (result: unknown) => { return null } +const getAuthUserFromResult = (result: unknown): AuthUser | null => { + if (!result || typeof result !== "object") { + return null + } + + const data = + "data" in result && result.data && typeof result.data === "object" ? result.data : result + if (!("user" in data) || !data.user || typeof data.user !== "object") { + return null + } + + const user = data.user as { id?: unknown } + return typeof user.id === "string" ? (data.user as AuthUser) : null +} + +const persistElectronAuthUser = async (result: unknown) => { + if (!IN_ELECTRON) { + return + } + + const authUser = getAuthUserFromResult(result) + if (!authUser) { + return + } + + await userActions.upsertMany([ + { + id: authUser.id, + email: authUser.email ?? null, + handle: authUser.handle ?? null, + name: authUser.name ?? null, + image: authUser.image ?? null, + isMe: true, + emailVerified: authUser.emailVerified ?? false, + bio: authUser.bio ?? null, + website: authUser.website ?? null, + socialLinks: authUser.socialLinks ?? null, + }, + ]) +} + type ElectronAuthResult = { data?: Record error?: { @@ -220,6 +263,7 @@ export function LoginWithPassword({ if (token) { setAuthSessionToken(token) } + await persistElectronAuthUser(result) } }} onSuccess={() => { @@ -235,6 +279,7 @@ export function LoginWithPassword({ if (token) { setAuthSessionToken(token) } + await persistElectronAuthUser(res) } handleSessionChanges() } @@ -429,6 +474,7 @@ export function RegisterForm({ if (token) { setAuthSessionToken(token) } + await persistElectronAuthUser(result) } handleSessionChanges() diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx index 6249aca0557..f71884d7977 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx @@ -88,7 +88,4 @@ type EntryPreviousEntryCommand = Command<{ }> export type EntryRenderCommand = - | EntryScrollDownCommand - | EntryScrollUpCommand - | EntryNextEntryCommand - | EntryPreviousEntryCommand + EntryScrollDownCommand | EntryScrollUpCommand | EntryNextEntryCommand | EntryPreviousEntryCommand diff --git a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx index 95b9f714461..b63a29741e9 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx @@ -99,10 +99,11 @@ export const FeedForm: Component<{ const feedQuery = useFeedQuery(queryParams) const id = feedQuery.data?.feed.id || _id - const feed = useFeedByIdOrUrl({ + const feedFromStore = useFeedByIdOrUrl({ id, url, - }) as FeedModel + }) as FeedModel | undefined + const feed = feedFromStore || (feedQuery.data?.feed as FeedModel | undefined) const { t } = useTranslation() diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx index cfff4ca8962..3d57de11430 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx @@ -30,7 +30,6 @@ export const EntryItemSkeleton: FC<{ return (
{Array.from({ length: count }).map((_, index) => ( - // eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique
{SkeletonItem}
))}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-stateless.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-stateless.tsx index c6daf1258a4..b5d37556691 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-stateless.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item-stateless.tsx @@ -30,7 +30,7 @@ export function PictureItemStateLess({ entry }: EntryItemStatelessProps) { return (
- {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx index a7743268dd1..f9a7302c670 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -277,7 +277,7 @@ export const PictureMasonry: FC = (props) => {
{isInitDim && deferIsInitLayout && ( - {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 61c59713002..34dee9e1c5f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -39,7 +39,6 @@ export const MarkAllReadButton = ({ // const activeScope = useGlobalFocusableScope() const when = useGlobalFocusableScopeSelector( - // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback useCallback( (activeScope) => activeScope.or(HotkeyScope.Timeline, HotkeyScope.SubscriptionList), [], diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx index 4b4edecb417..dcb2c97409b 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx @@ -54,7 +54,7 @@ export const ImageGallery = ({ images }: { images: MediaModel[] }) => { return (
- {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx index 0c3e8351f11..6d33200ebd6 100644 --- a/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx @@ -509,7 +509,7 @@ function AIChatInterface({ inputRef }: AIChatInterfaceProps) { type="button" onClick={() => resetScrollState()} className={cn( - "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2", + "group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2", "border-border", "hover:border-border/60 active:scale-[0.98]", )} diff --git a/apps/desktop/layer/renderer/src/modules/panel/cmdk.module.css b/apps/desktop/layer/renderer/src/modules/panel/cmdk.module.css index 546265fef0d..86d8ad909c5 100644 --- a/apps/desktop/layer/renderer/src/modules/panel/cmdk.module.css +++ b/apps/desktop/layer/renderer/src/modules/panel/cmdk.module.css @@ -1,3 +1,5 @@ +@reference "../../styles/main.css"; + .status-bar { @apply relative z-10 h-px w-full shrink-0 scale-y-75 transform; diff --git a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts index 911be17b142..0b3bcce28a3 100644 --- a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts +++ b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts @@ -19,8 +19,7 @@ type TtsAudioHandle = { } type AudioContextConstructor = - | (new (contextOptions?: AudioContextOptions | undefined) => AudioContext) - | null + (new (contextOptions?: AudioContextOptions | undefined) => AudioContext) | null const getAudioContextConstructor = (): AudioContextConstructor => { if (typeof window === "undefined") { diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx index 11dee293312..0db2209aac3 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx @@ -60,8 +60,7 @@ export const UserRenderer = ({ iconClassName, }: { user?: NonNullable["data"]>[number][ - | "fromUser" - | "toUser"] + "fromUser" | "toUser"] hideName?: boolean iconClassName?: string }) => { diff --git a/apps/desktop/layer/renderer/src/modules/renderer/context.tsx b/apps/desktop/layer/renderer/src/modules/renderer/context.tsx index b5ad758e716..3cdfa2c8887 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/context.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/context.tsx @@ -18,7 +18,6 @@ const defaultContextValue: EntryContentContext = { export const EntryContentContext = createContext(defaultContextValue) export const EntryContentProvider: Component = ({ children, ...value }) => ( - // eslint-disable-next-line @eslint-react/no-context-provider {children} ) diff --git a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx index 4c18ca72569..3ec5c6e79e1 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx @@ -68,7 +68,6 @@ export function EntryContentHTMLRenderer ({ feedId, entryId }), [feedId, entryId])}> diff --git a/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx index 678371cb0ba..d6c7e791ef6 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx @@ -70,7 +70,6 @@ export function EntryContentMarkdownRenderer({ }, [entry, feedId, onImageContextMenu, view]) return ( - // eslint-disable-next-line @eslint-react/no-context-provider ({ feedId, entryId }), [feedId, entryId])}> diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/SyncIndicator.tsx b/apps/desktop/layer/renderer/src/modules/settings/helper/SyncIndicator.tsx index 2a46375d9f0..82aba6f329e 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/SyncIndicator.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/SyncIndicator.tsx @@ -11,6 +11,7 @@ import { useAuthQuery } from "~/hooks/common" import { settings } from "~/queries/settings" import { useSettingContextSelector } from "../modal/hooks" +import type { RemoteSettingsResponse } from "./sync-queue" import { settingSyncQueue } from "./sync-queue" export const SettingSyncIndicator = () => { @@ -19,16 +20,23 @@ export const SettingSyncIndicator = () => { const canSync = useSettingContextSelector((s) => s.canSync) const isOnline = useIsOnline() - const onceRef = useRef(false) + const seedEmptyRemoteOnceRef = useRef(false) useEffect(() => { - if (!isLoading && remoteSettings && !onceRef.current) { - const hasSetting = JSON.stringify(remoteSettings.settings) !== "{}" - onceRef.current = true - if (hasSetting) { - return - } - // Replace local to remote - settingSyncQueue.replaceRemote() + if (isLoading || !remoteSettings) { + return + } + + const hasSetting = JSON.stringify(remoteSettings.settings) !== "{}" + if (hasSetting) { + settingSyncQueue.applyRemoteSettings(remoteSettings as RemoteSettingsResponse) + return + } + + if (!seedEmptyRemoteOnceRef.current) { + seedEmptyRemoteOnceRef.current = true + // Seed remote settings once for new accounts, but re-check before writing because another + // session may have created remote settings while this modal was loading. + settingSyncQueue.replaceRemoteIfEmpty() } }, [remoteSettings, isLoading]) diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx b/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx index 14d79ae8955..51267febb7d 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/no-array-index-key */ import { UserRole } from "@follow/constants" import { SettingPaidLevels } from "@follow/shared/settings/constants" import { useUserRole } from "@follow/store/user/hooks" @@ -51,11 +50,7 @@ export const createSettingBuilder = (useSetting: () => T) => (props: { settings: ( - | SettingItem - | SectionSettingItem - | CustomSettingItem - | ActionSettingItem - | boolean + SettingItem | SectionSettingItem | CustomSettingItem | ActionSettingItem | boolean )[] }) => { const { settings } = props diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.test.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.test.ts index 2e5cbd9e431..bd64398c92f 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.test.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { + getGeneralSettings, + initializeDefaultGeneralSettings, + setGeneralSetting, +} from "~/atoms/settings/general" import { getSpotlightSettings, initializeDefaultSpotlightSettings, @@ -9,11 +14,14 @@ import { initializeDefaultUISettings } from "~/atoms/settings/ui" import { settingSyncQueue } from "./sync-queue" -const { settingsPrefetchMock, settingsUpdateMock, whoamiMock } = vi.hoisted(() => ({ - settingsPrefetchMock: vi.fn(), - settingsUpdateMock: vi.fn(), - whoamiMock: vi.fn(), -})) +const { settingsGetMock, settingsPrefetchMock, settingsUpdateMock, whoamiMock } = vi.hoisted( + () => ({ + settingsGetMock: vi.fn(), + settingsPrefetchMock: vi.fn(), + settingsUpdateMock: vi.fn(), + whoamiMock: vi.fn(), + }), +) vi.mock("@follow/store/user/getters", () => ({ whoami: whoamiMock, @@ -31,6 +39,7 @@ vi.mock("~/lib/api-client", () => ({ followClient: { api: { settings: { + get: settingsGetMock, update: settingsUpdateMock, }, }, @@ -40,7 +49,8 @@ vi.mock("~/lib/api-client", () => ({ vi.mock("~/queries/settings", () => ({ settings: { get: () => ({ - prefetch: settingsPrefetchMock, + key: ["settings"], + fn: settingsPrefetchMock, }), }, })) @@ -79,8 +89,14 @@ describe("desktop spotlight setting sync", () => { settings: {}, updated: {}, }) + settingsGetMock.mockResolvedValue({ + code: 0, + settings: {}, + updated: {}, + }) initializeDefaultUISettings() + initializeDefaultGeneralSettings() initializeDefaultSpotlightSettings() localStorage.clear() }) @@ -92,9 +108,32 @@ describe("desktop spotlight setting sync", () => { settingSyncQueue.queue = [] localStorage.clear() initializeDefaultUISettings() + initializeDefaultGeneralSettings() initializeDefaultSpotlightSettings() }) + test("applyRemoteSettings hydrates general settings from provided payload", () => { + setGeneralSetting("language", "ja") + + settingSyncQueue.applyRemoteSettings({ + code: 0, + settings: { + general: { + language: "en", + }, + }, + updated: { + general: "2030-04-14T12:00:00.000Z", + }, + }) + + expect(getGeneralSettings()).toMatchObject({ + language: "en", + updated: Date.parse("2030-04-14T12:00:00.000Z"), + }) + expect(settingsPrefetchMock).not.toHaveBeenCalled() + }) + test("syncLocal hydrates spotlight rules from remote appearance settings", async () => { const rule = createRule() settingsPrefetchMock.mockResolvedValue({ @@ -149,4 +188,22 @@ describe("desktop spotlight setting sync", () => { }), ) }) + + test("replaceRemoteIfEmpty does not overwrite existing remote settings", async () => { + settingsGetMock.mockResolvedValue({ + code: 0, + settings: { + general: { + language: "ja", + }, + }, + updated: { + general: "2026-04-14T12:00:00.000Z", + }, + }) + + await settingSyncQueue.replaceRemoteIfEmpty() + + expect(settingsUpdateMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts index de640d404fa..e5009cae2d4 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/sync-queue.ts @@ -28,6 +28,7 @@ import { import { __uiSettingAtom, getUISettings, uiServerSyncWhiteListKeys } from "~/atoms/settings/ui" import { followClient } from "~/lib/api-client" import { jotaiStore } from "~/lib/jotai" +import { queryClient } from "~/lib/query-client" import { settings } from "~/queries/settings" type SettingMapping = { @@ -38,6 +39,11 @@ type SettingMapping = { } type SettingDomain = keyof SettingMapping type RemoteSettingsTab = "appearance" | "general" | "ai" +export interface RemoteSettingsResponse { + code: 0 + settings: Record + updated: Record +} const pickSyncPayload = (payload: T, keys: readonly (keyof T | string)[]) => { const nextPayload = {} as Partial @@ -337,22 +343,19 @@ class SettingSyncQueue { private threshold = 1000 private flushScheduled = false - private pendingPromise: Promise<{ - code: 0 - settings: Record - updated: Record - }> | null = null + private pendingPromise: Promise | null = null private fetchSettingRemote() { if (this.pendingPromise) { return this.pendingPromise } - const promise = settings.get().prefetch() as Promise<{ - code: 0 - settings: Record - updated: Record - }> + const settingQuery = settings.get() + const promise = queryClient.fetchQuery({ + queryKey: settingQuery.key, + queryFn: settingQuery.fn, + staleTime: 0, + }) as Promise this.pendingPromise = promise.finally(() => { this.pendingPromise = null @@ -370,6 +373,10 @@ class SettingSyncQueue { : {} } + private async fetchFreshRemoteSettings() { + return followClient.api.settings.get() as Promise + } + async enqueue(tab: T, payload: Partial) { const currentUserId = this.getCurrentUserId() if (!currentUserId) { @@ -500,6 +507,23 @@ class SettingSyncQueue { } } + private replaceAllRemote() { + return Promise.all([ + followClient.api.settings.update({ + tab: "appearance", + ...buildFullLocalAppearancePayload(), + }), + followClient.api.settings.update({ + tab: "general", + ...getLocalPayloadForRemoteTab("general"), + }), + followClient.api.settings.update({ + tab: "ai", + ...getLocalPayloadForRemoteTab("ai"), + }), + ]) + } + replaceRemote(tab?: SettingSyncTab) { const currentUserId = this.getCurrentUserId() if (!currentUserId) { @@ -509,22 +533,7 @@ class SettingSyncQueue { this.bindQueueOwner(currentUserId) if (!tab) { - const promises = [ - followClient.api.settings.update({ - tab: "appearance", - ...buildFullLocalAppearancePayload(), - }), - followClient.api.settings.update({ - tab: "general", - ...getLocalPayloadForRemoteTab("general"), - }), - followClient.api.settings.update({ - tab: "ai", - ...getLocalPayloadForRemoteTab("ai"), - }), - ] - - this.chain = this.chain.finally(() => Promise.all(promises)) + this.chain = this.chain.finally(() => this.replaceAllRemote()) return this.chain } else { this.chain = this.chain.finally(async () => { @@ -547,25 +556,37 @@ class SettingSyncQueue { } } - async syncLocal() { + replaceRemoteIfEmpty() { const currentUserId = this.getCurrentUserId() - if (!currentUserId) return + if (!currentUserId) { + return this.chain + } this.bindQueueOwner(currentUserId) - const remoteSettings = await this.fetchSettingRemote().catch((error) => { - if (isUnauthorizedError(error)) { - this.queue = [] - this.ownerUserId = currentUserId - return null - } + this.chain = this.chain.finally(async () => { + try { + const remoteSettings = await this.fetchFreshRemoteSettings() + if (!isEmptyObject(remoteSettings.settings)) { + return + } - this.reportSyncError("syncLocal", error) - return null + await this.replaceAllRemote() + } catch (error) { + if (isUnauthorizedError(error)) { + this.queue = [] + this.ownerUserId = currentUserId + return + } + + this.reportSyncError("flush", error) + } }) - if (!remoteSettings) return + return this.chain + } + applyRemoteSettings(remoteSettings: RemoteSettingsResponse) { if (isEmptyObject(remoteSettings.settings)) return for (const tab in remoteSettings.settings) { @@ -597,6 +618,28 @@ class SettingSyncQueue { } } } + + async syncLocal() { + const currentUserId = this.getCurrentUserId() + if (!currentUserId) return + + this.bindQueueOwner(currentUserId) + + const remoteSettings = await this.fetchSettingRemote().catch((error) => { + if (isUnauthorizedError(error)) { + this.queue = [] + this.ownerUserId = currentUserId + return null + } + + this.reportSyncError("syncLocal", error) + return null + }) + + if (!remoteSettings) return + + this.applyRemoteSettings(remoteSettings) + } } export const settingSyncQueue = new SettingSyncQueue() diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index 9824612210c..6f8c76fea1d 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -98,7 +98,7 @@ export function SettingModalLayout(props: PropsWithChildren) { cursor: "grabbing", }} > - {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {} window.router.showSettings.call(null, args) +const getShowSettings = () => { + const showSettings = window.router?.showSettings + return typeof showSettings === "function" ? showSettings : null +} + +export const showSettings = (args?: SettingModalOptions) => { + const currentShowSettings = getShowSettings() + if (currentShowSettings) { + currentShowSettings(args) + return + } + + let retryCount = 0 + const retry = () => { + const nextShowSettings = getShowSettings() + if (nextShowSettings) { + nextShowSettings(args) + return + } + + retryCount += 1 + if (retryCount >= 20) { + console.error("[Settings] window.router.showSettings is not ready") + return + } + + window.setTimeout(retry, 50) + } + + window.setTimeout(retry) +} export const useSettingModal = () => showSettings diff --git a/apps/desktop/layer/renderer/src/modules/settings/section.tsx b/apps/desktop/layer/renderer/src/modules/settings/section.tsx index 3d295b1e647..8a99f999100 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/section.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/section.tsx @@ -1,6 +1,3 @@ -/* eslint-disable @eslint-react/no-children-to-array */ -/* eslint-disable @eslint-react/no-children-map */ - import { cn } from "@follow/utils/utils" import type { FC, PropsWithChildren, ReactNode } from "react" import { cloneElement, createContext, use, useEffect, useRef } from "react" diff --git a/apps/desktop/layer/renderer/src/modules/settings/settings-glob.test.ts b/apps/desktop/layer/renderer/src/modules/settings/settings-glob.test.ts new file mode 100644 index 00000000000..a02185395d1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/settings/settings-glob.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest" + +import * as generalRouteModule from "~/pages/settings/(settings)/general" + +import { getSettingPages } from "./settings-glob" +import type { SettingPageConfig } from "./utils" + +vi.mock("@follow/shared/env.desktop", () => ({ + env: { + VITE_API_URL: "https://api.example.com", + VITE_WEB_URL: "https://app.example.com", + }, +})) + +type SettingRouteModule = { + handle?: SettingPageConfig + loader?: unknown +} + +describe("settings route metadata", () => { + it("uses route handles while preserving the modal metadata mapping", () => { + const pages = getSettingPages() as Record + const routeModule = generalRouteModule as SettingRouteModule + + expect(Object.keys(pages)).toHaveLength(14) + expect(routeModule.loader).toBeUndefined() + expect(routeModule.handle).toBeDefined() + expect(pages.general?.loader).toBe(routeModule.handle) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/settings/settings-glob.ts b/apps/desktop/layer/renderer/src/modules/settings/settings-glob.ts index c88136e4494..6bb0ef94753 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/settings-glob.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/settings-glob.ts @@ -30,14 +30,14 @@ function getSettings() { const Module = map[path] as { Component: () => JSX.Element - loader: SettingPageConfig + handle: SettingPageConfig } - if (!Module.loader) continue + if (!Module.handle) continue settings.push({ - ...Module.loader, + ...Module.handle, Component: Module.Component, - loader: Module.loader, + loader: Module.handle, path: p, }) } diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationModal.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationModal.tsx index cb2dcc62942..57ce852bef0 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/CustomIntegrationModal.tsx @@ -33,7 +33,7 @@ import { URLSchemePreview } from "~/modules/integration/URLSchemePreview" const httpTemplateSchema = z.object({ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), url: z.string().url("URL is required"), - headers: z.record(z.string()), + headers: z.record(z.string(), z.string()), body: z.string().optional(), }) @@ -60,7 +60,7 @@ const createFormSchema = () => } } catch (error) { if (error instanceof z.ZodError) { - error.errors.forEach((zodError) => { + error.issues.forEach((zodError) => { ctx.addIssue({ code: z.ZodIssueCode.custom, message: zodError.message, diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx index 89c2a9fc42c..58bd7860381 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx @@ -220,13 +220,7 @@ interface PlanCardProps { const PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardProps) => { const { t } = useTranslation("settings") const getPlanActionType = (): - | "current" - | "upgrade" - | "coming-soon" - | "in-trial" - | "switch" - | "new" - | null => { + "current" | "upgrade" | "coming-soon" | "in-trial" | "switch" | "new" | null => { if (plan.isComingSoon) return "coming-soon" switch (true) { case isCurrentPlan: { diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx index 7cb502e3cc2..175497a7ce8 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx @@ -148,9 +148,7 @@ const TimelineTabsSettings = () => { // Determine hovered list const overContainer = (over.data?.current as any)?.container as - | "visible" - | "hidden" - | undefined + "visible" | "hidden" | undefined const overKey: "visible" | "hidden" = overContainer || (visible.includes(overId) ? "visible" : "hidden") diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index 5d34afbac34..99274be46a1 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -204,7 +204,7 @@ const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo if (prevOrderIndex < orderIndex) setDirection("right") else setDirection("left") } - // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout + setTimeout(() => { setCurrentAnimatedActive(viewIndex) }, 0) @@ -317,7 +317,6 @@ const CommandsHandler = ({ timelineList: string[] }) => { const when = useGlobalFocusableScopeSelector( - // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback useCallback( (activeScope) => activeScope.or(HotkeyScope.SubscriptionList, HotkeyScope.Timeline) || diff --git a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx index ebca95607c2..7f0d2926f4d 100644 --- a/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/ProfileButton.tsx @@ -3,7 +3,7 @@ import { RSSHubLogo } from "@follow/components/ui/platform-icon/icons.js" import { RootPortal } from "@follow/components/ui/portal/index.js" import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js" import { useMeasure } from "@follow/hooks" -import { useUserRole } from "@follow/store/user/hooks" +import { useUserRole, useWhoami } from "@follow/store/user/hooks" import { cn } from "@follow/utils/utils" import type { FC } from "react" import { memo, useCallback, useLayoutEffect, useState } from "react" @@ -38,7 +38,8 @@ export type ProfileButtonProps = LoginProps & { export const ProfileButton: FC = memo((props) => { const serverConfig = useServerConfigs() const { status, session } = useSession() - const { user } = session || {} + const whoami = useWhoami() + const user = session?.user ?? whoami const settingModalPresent = useSettingModal() const presentUserProfile = usePresentUserProfileModal("dialog") const { t } = useTranslation() @@ -53,7 +54,7 @@ export const ProfileButton: FC = memo((props) => { const role = useUserRole() const isInMASReview = useIsInMASReview() - if (status !== "authenticated") { + if (status !== "authenticated" && !user) { return } @@ -65,9 +66,13 @@ export const ProfileButton: FC = memo((props) => { data-testid="profile-menu-trigger" > {props.animatedAvatar ? ( - + ) : ( - + )} diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/about.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/about.tsx index 9b73bff165d..c05ba7547aa 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/about.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/about.tsx @@ -3,7 +3,7 @@ import { SettingsTitle } from "~/modules/settings/title" import { defineSettingPageData } from "~/modules/settings/utils" const priority = Number.MAX_SAFE_INTEGER -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: "i-mgc-information-cute-re", name: "titles.about", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/ai.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/ai.tsx index 5a091b8d5bd..663390ad11c 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/ai.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/ai.tsx @@ -6,8 +6,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-ai-cute-re" const priority = (1000 << 1) + 15 -// eslint-disable-next-line react-refresh/only-export-components -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.ai", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/appearance.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/appearance.tsx index 57a92cb35c3..833bd202924 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/appearance.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/appearance.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-palette-cute-re" const priority = (1000 << 1) + 10 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.appearance", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx index cad159987b1..2ee22b21510 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx @@ -7,7 +7,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const priority = (1000 << 1) + 25 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: , headerIcon: , name: "titles.cli", diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/data-control.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/data-control.tsx index 1582dbf5bb9..badb974a9ea 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/data-control.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/data-control.tsx @@ -6,7 +6,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const priority = (1000 << 1) + 30 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: , name: "titles.data_control", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx index b6a3e8598df..b784325b9c9 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/feeds.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-certificate-cute-re" const priority = (1000 << 2) + 20 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.feeds", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/general.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/general.tsx index 85ef8e953b4..ff4785f35e4 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/general.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/general.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-settings-7-cute-re" const priority = 1000 << 1 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.general", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/integration.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/integration.tsx index 8350d8a4c7e..976361cde38 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/integration.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/integration.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-department-cute-re" const priority = (1000 << 1) + 20 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.integration", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/list.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/list.tsx index 87dcda6a740..d3753a6a1a2 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/list.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/list.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-rada-cute-re" const priority = (1000 << 2) + 10 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.lists", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/notifications.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/notifications.tsx index 3d202519ebf..2be47052a0f 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/notifications.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/notifications.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-notification-cute-re" const priority = (1000 << 1) + 50 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.notifications", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/plan.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/plan.tsx index 405ff286edd..4bad8ea83c2 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/plan.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/plan.tsx @@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-power-outline" const priority = (1000 << 1) + 17 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.plan.short", title: "titles.plan.long", diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/profile.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/profile.tsx index 10a1315dff7..64345aefd1b 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/profile.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/profile.tsx @@ -15,7 +15,7 @@ import { deleteUser } from "~/queries/auth" const iconName = "i-mgc-user-setting-cute-re" const priority = (1000 << 3) + 10 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.account", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx index 9916dea34a0..765d530433d 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx @@ -7,7 +7,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-hotkey-cute-re" const priority = (1000 << 1) + 40 -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.shortcuts", priority, diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/spotlight.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/spotlight.tsx index 842032424df..11b3d1ea557 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/spotlight.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/spotlight.tsx @@ -5,8 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils" const iconName = "i-mgc-flashlight-cute-re" const priority = (1000 << 1) + 20 -// eslint-disable-next-line react-refresh/only-export-components -export const loader = defineSettingPageData({ +export const handle = defineSettingPageData({ icon: iconName, name: "titles.spotlight", priority, 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 index e8cd7814e47..b500907d32c 100644 --- a/apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx +++ b/apps/desktop/layer/renderer/src/providers/context-menu-provider.test.tsx @@ -83,6 +83,7 @@ describe("ContextMenuProvider", () => { vi.spyOn(console, "info").mockImplementation(() => {}) Object.assign(window, { + addEventListener: document.defaultView?.addEventListener.bind(document.defaultView), clearTimeout, Element: document.defaultView?.Element ?? Element, getComputedStyle: @@ -91,6 +92,7 @@ describe("ContextMenuProvider", () => { innerHeight: 768, innerWidth: 1024, Node: document.defaultView?.Node ?? Node, + removeEventListener: document.defaultView?.removeEventListener.bind(document.defaultView), setTimeout, }) }) diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index f63c7ddf823..26d4798d9e5 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -19,12 +19,12 @@ import { persistConfig, queryClient } from "~/lib/query-client" import { FollowCommandManager } from "~/modules/command/command-manager" import { ReviewPromptProvider } from "~/modules/review-prompt/provider" +import { ExtensionExposeProvider } from "./extension-expose-provider" import { HotkeyProvider } from "./hotkey-provider" import { I18nProvider } from "./i18n-provider" import { InvalidateQueryProvider } from "./invalidate-query-provider" import { LazyContextMenuProvider, - LazyExtensionExposeProvider, LazyExternalJumpInProvider, LazyPopoverProvider, LazyPWAPrompt, @@ -53,13 +53,13 @@ export const RootProviders: FC = ({ children }) => ( + {import.meta.env.DEV && } {children} - diff --git a/apps/desktop/layer/renderer/src/queries/auth.ts b/apps/desktop/layer/renderer/src/queries/auth.ts index 00118121031..1b9f5d002ef 100644 --- a/apps/desktop/layer/renderer/src/queries/auth.ts +++ b/apps/desktop/layer/renderer/src/queries/auth.ts @@ -1,5 +1,5 @@ import { IN_ELECTRON } from "@follow/shared/constants" -import { whoamiQueryKey } from "@follow/store/user/hooks" +import { useWhoami, whoamiQueryKey } from "@follow/store/user/hooks" import { userSyncService } from "@follow/store/user/store" import { tracker } from "@follow/tracker" import { clearStorage } from "@follow/utils/ns" @@ -25,8 +25,22 @@ const sessionCookieRefreshInterval = 1000 * 60 * 60 * 12 let lastSessionCookieRefreshAt = 0 let sessionCookieRefreshPromise: Promise | null = null +const hasBetterAuthSessionCookie = () => { + if (typeof document === "undefined") { + return false + } + + return /(?:^|;\s*)(?:better-auth\.session_token|__Secure-better-auth\.session_token)=/.test( + document.cookie, + ) +} + +const isElectronRuntime = () => { + return IN_ELECTRON || (typeof window !== "undefined" && !!window.electron) +} + const refreshAuthSessionCookie = async () => { - if (IN_ELECTRON && !getAuthSessionToken()) { + if (isElectronRuntime() && !getAuthSessionToken()) { return } @@ -75,6 +89,7 @@ export const useHasPassword = () => { } export const useSession = (options?: { enabled?: boolean }) => { + const whoami = useWhoami() const { data, isLoading, ...rest } = useAuthQuery(auth.getSession(), { retry(failureCount, error) { const fetchError = error as FetchError @@ -94,23 +109,26 @@ export const useSession = (options?: { enabled?: boolean }) => { }) const { error } = rest const fetchError = error as FetchError + const hasLocalAuthCredential = isElectronRuntime() + ? !!getAuthSessionToken() + : hasBetterAuthSessionCookie() + const session = + (data === undefined || (data === null && hasLocalAuthCredential)) && whoami + ? ({ user: whoami } as NonNullable) + : data const getAuthStatus = (): - | "loading" - | "authenticated" - | "error" - | "unauthenticated" - | "unknown" => { - if (isLoading) { - return "loading" + "loading" | "authenticated" | "error" | "unauthenticated" | "unknown" => { + if (session) { + return "authenticated" } if (fetchError) { return "error" } - if (data) { - return "authenticated" + if (isLoading) { + return "loading" } if (data === null) { @@ -121,7 +139,7 @@ export const useSession = (options?: { enabled?: boolean }) => { } return { - session: data, + session, ...rest, status: getAuthStatus(), } as const @@ -155,6 +173,7 @@ export const useAuthSessionCookieRefresh = (enabled: boolean) => { export const handleSessionChanges = () => { setLoginModalShow(false) + localStorage.removeItem(QUERY_PERSIST_KEY) const authSessionToken = getAuthSessionToken() ipcServices?.auth.sessionChanged(authSessionToken ?? undefined) window.location.reload() @@ -162,29 +181,36 @@ export const handleSessionChanges = () => { export const signOut = async () => { const authSessionToken = getAuthSessionToken() - clearAuthSessionToken() - // Clear query cache - localStorage.removeItem(QUERY_PERSIST_KEY) - // clear local store data - await clearLocalPersistStoreData() - - // Clear local storage - clearStorage() - // Sign out - await tracker.manager.clear() - if (IN_ELECTRON) { - void ipcServices?.auth.signOut() - const authService = ipcServices?.auth as - | ({ signOutRemote?: (token?: string) => Promise } & NonNullable< - typeof ipcServices - >["auth"]) - | undefined - void authService?.signOutRemote?.(authSessionToken ?? undefined) - } else { - await ipcServices?.auth.signOut() - await signOutFn() + try { + if (IN_ELECTRON) { + const authService = ipcServices?.auth as + | ({ signOutRemote?: (token?: string) => Promise } & NonNullable< + typeof ipcServices + >["auth"]) + | undefined + await Promise.allSettled([ + ipcServices?.auth.signOut(), + authService?.signOutRemote?.(authSessionToken ?? undefined), + ]) + } else { + await ipcServices?.auth.signOut() + await signOutFn() + } + } finally { + clearAuthSessionToken() + // Clear query cache + localStorage.removeItem(QUERY_PERSIST_KEY) + + // clear local store data + await clearLocalPersistStoreData() + + // Clear local storage + clearStorage() + // Sign out + await tracker.manager.clear() } + window.location.reload() } diff --git a/apps/desktop/layer/renderer/src/store/utils/helper.ts b/apps/desktop/layer/renderer/src/store/utils/helper.ts index 216abdb30bc..28dfe166fc3 100644 --- a/apps/desktop/layer/renderer/src/store/utils/helper.ts +++ b/apps/desktop/layer/renderer/src/store/utils/helper.ts @@ -1,3 +1,4 @@ +import type { Draft } from "immer" import { isDraft, original, produce } from "immer" import type { StateCreator, StoreApi, UseBoundStore } from "zustand" import type { PersistStorage } from "zustand/middleware" @@ -106,9 +107,10 @@ export function createImmerSetter(useStore: UseBoundStore>) { ) } -type MayBeDraft = T +type MayBeDraft = T | Draft +const isImmerDraft = (value: MayBeDraft): value is Draft => isDraft(value) export const toRaw = (draft: MayBeDraft): T => { - return isDraft(draft) ? original(draft)! : draft + return isImmerDraft(draft) ? original(draft) : draft } type SyncOrAsync = T | Promise type ExecutorFn = (snapshot: S, ctx: Ctx) => SyncOrAsync diff --git a/apps/desktop/layer/renderer/src/styles/main.css b/apps/desktop/layer/renderer/src/styles/main.css index 0ca4475ed15..06f770117b2 100644 --- a/apps/desktop/layer/renderer/src/styles/main.css +++ b/apps/desktop/layer/renderer/src/styles/main.css @@ -1,7 +1,16 @@ +@import "tailwindcss" source(none); +@import "@follow/components/tailwind"; @import "./base.css"; @import "./additional.css"; @import "./scrollbar.css"; @import "./cursor.css"; +@config "../../../../tailwind.config.ts"; +@source "../"; +@source "../../index.html"; +@source "../../../../../packages"; +@source inline( + "line-clamp-[1] line-clamp-[2] line-clamp-[3] line-clamp-[4] line-clamp-[5] line-clamp-[6] line-clamp-[7] line-clamp-[8]" +); @media print { * { diff --git a/apps/desktop/layer/renderer/tsconfig.json b/apps/desktop/layer/renderer/tsconfig.json index 0dd569a6210..5ccfa9bfbec 100644 --- a/apps/desktop/layer/renderer/tsconfig.json +++ b/apps/desktop/layer/renderer/tsconfig.json @@ -9,7 +9,6 @@ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "baseUrl": ".", "noImplicitReturns": false, "noEmit": true, "skipLibCheck": true, @@ -20,7 +19,7 @@ "@follow/types/react" ], "paths": { - "~/*": ["src/*"], + "~/*": ["./src/*"], "@pkg": ["../../package.json"], "@locales/*": ["../../../../locales/*"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 90bb614459d..0a27060893f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -42,14 +42,14 @@ "update:main-hash": "tsx plugins/vite/generate-main-hash.ts" }, "devDependencies": { - "@electron-forge/cli": "7.11.1", - "@electron-forge/maker-appx": "7.11.1", - "@electron-forge/maker-dmg": "7.11.1", - "@electron-forge/maker-pkg": "7.11.1", - "@electron-forge/maker-squirrel": "7.11.1", - "@electron-forge/maker-zip": "7.11.1", - "@electron-forge/plugin-fuses": "7.11.1", - "@electron-forge/publisher-github": "7.11.1", + "@electron-forge/cli": "7.11.2", + "@electron-forge/maker-appx": "7.11.2", + "@electron-forge/maker-dmg": "7.11.2", + "@electron-forge/maker-pkg": "7.11.2", + "@electron-forge/maker-squirrel": "7.11.2", + "@electron-forge/maker-zip": "7.11.2", + "@electron-forge/plugin-fuses": "7.11.2", + "@electron-forge/publisher-github": "7.11.2", "@electron-toolkit/tsconfig": "2.0.0", "@follow/components": "workspace:*", "@follow/configs": "workspace:*", @@ -59,42 +59,45 @@ "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", "@pengx17/electron-forge-maker-appimage": "1.2.1", - "@playwright/test": "1.58.2", + "@playwright/test": "1.61.1", + "@tailwindcss/postcss": "4.3.2", "@types/html-minifier-terser": "7.0.2", - "@types/js-yaml": "4.0.9", "@types/semver": "7.7.1", "@vitejs/plugin-legacy": "7.2.1", "@vitejs/plugin-react": "5.1.4", "async-es": "3.2.6", - "autoprefixer": "10.4.24", + "autoprefixer": "10.5.2", "bufferutil": "4.1.0", - "code-inspector-plugin": "1.4.2", - "cssnano": "7.1.2", - "drizzle-orm": "0.45.1", - "electron": "38.3.0", + "code-inspector-plugin": "1.6.6", + "cssnano": "8.0.2", + "drizzle-orm": "0.45.2", + "electron": "43.1.0", "electron-devtools-installer": "4.0.0", "electron-packager-languages": "0.6.0", - "electron-vite": "4.0.1", - "es-toolkit": "1.44.0", + "electron-vite": "5.0.0", + "es-toolkit": "1.49.0", "fake-indexeddb": "6.2.5", - "happy-dom": "20.6.1", + "happy-dom": "20.10.6", "html-minifier-terser": "7.2.0", - "js-yaml": "4.1.1", + "js-yaml": "5.2.1", + "kolorist": "1.8.0", "nbump": "2.1.8", "pathe": "2.0.3", - "react": "19.0.0", - "react-dom": "19.0.0", - "semver": "7.7.4", - "tailwindcss": "3.4.17", - "tailwindcss-content-visibility": "1.0.2", + "react": "19.2.7", + "react-dom": "19.2.7", + "semver": "7.8.5", + "tailwindcss": "4.3.2", + "tailwindcss-content-visibility": "1.0.3", "tailwindcss-multi": "0.4.6", - "tar": "7.5.7", - "unplugin-ast": "0.16.0", - "vite-bundle-analyzer": "1.3.6", - "vite-plugin-mkcert": "1.17.9", - "vite-plugin-pwa": "1.2.0", + "tar": "7.5.19", + "unplugin-ast": "0.17.2", + "vite-bundle-analyzer": "1.3.8", + "vite-plugin-mkcert": "2.1.0", + "vite-plugin-pwa": "1.3.0", "vite-plugin-route-builder": "0.4.1", - "vite-tsconfig-paths": "6.1.1" + "vite-tsconfig-paths": "6.1.1", + "workbox-build": "7.4.1", + "workbox-window": "7.4.1" }, "runtimeVersion": "1.9.0", "productName": "Folo", diff --git a/apps/desktop/postcss.config.cjs b/apps/desktop/postcss.config.cjs index 41ec0c75705..bda33df8faf 100644 --- a/apps/desktop/postcss.config.cjs +++ b/apps/desktop/postcss.config.cjs @@ -1,11 +1,6 @@ -const isWebBuild = !!process.env.WEB_BUILD || !!process.env.VERCEL - module.exports = { plugins: { - tailwindcss: {}, - "tailwindcss/nesting": {}, - - ...(isWebBuild ? { autoprefixer: {} } : {}), + "@tailwindcss/postcss": {}, ...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}), }, } diff --git a/apps/desktop/tailwind.config.ts b/apps/desktop/tailwind.config.ts index 68c2b45c32f..8296a4968c7 100644 --- a/apps/desktop/tailwind.config.ts +++ b/apps/desktop/tailwind.config.ts @@ -12,16 +12,6 @@ export default extendConfig({ "!../../packages/**/node_modules", ], - safelist: [ - "line-clamp-[1]", - "line-clamp-[2]", - "line-clamp-[3]", - "line-clamp-[4]", - "line-clamp-[5]", - "line-clamp-[6]", - "line-clamp-[7]", - "line-clamp-[8]", - ], theme: { extend: { cursor: { diff --git a/apps/landing/eslint.config.mjs b/apps/landing/eslint.config.mjs index 7b431334b32..9122fe330e9 100644 --- a/apps/landing/eslint.config.mjs +++ b/apps/landing/eslint.config.mjs @@ -1,13 +1,96 @@ // @ts-check +import stylistic from '@stylistic/eslint-plugin' import { defineConfig } from 'eslint-config-hyoban' import recursiveSort from './plugins/eslint-recursive-sort.mjs' +const presetUpgradeCompatibilityRules = { + 'antfu/no-top-level-await': 'off', + curly: 'off', + 'dot-notation': 'off', + 'e18e/prefer-array-fill': 'off', + 'e18e/prefer-array-from-map': 'off', + 'e18e/prefer-array-some': 'off', + 'e18e/prefer-nullish-coalescing': 'off', + 'e18e/prefer-object-has-own': 'off', + 'e18e/prefer-static-regex': 'off', + 'e18e/prefer-timer-args': 'off', + 'hyoban/md-one-sentence-per-line': 'off', + 'import/no-mutable-exports': 'off', + 'jsonc/comma-dangle': 'off', + 'jsonc/sort-keys': 'off', + 'no-alert': 'off', + 'no-cond-assign': 'off', + 'no-console': 'off', + 'no-eval': 'off', + 'no-multi-str': 'off', + 'no-new': 'off', + 'no-shadow-restricted-names': 'off', + 'no-throw-literal': 'off', + 'no-undef-init': 'off', + 'no-unmodified-loop-condition': 'off', + 'no-unneeded-ternary': 'off', + 'no-useless-computed-key': 'off', + 'no-useless-rename': 'off', + 'no-useless-return': 'off', + 'node/no-deprecated-api': 'off', + 'node/prefer-global/buffer': 'off', + 'node/prefer-global/process': 'off', + 'object-shorthand': 'off', + 'one-var': 'off', + 'prefer-arrow-callback': 'off', + 'prefer-exponentiation-operator': 'off', + 'prefer-promise-reject-errors': 'off', + 'react-dom/no-flush-sync': 'off', + 'react-dom/no-missing-iframe-sandbox': 'off', + 'react-refresh/only-export-components': 'off', + 'react-web-api/no-leaked-event-listener': 'off', + 'react-web-api/no-leaked-timeout': 'off', + 'react/no-array-index-key': 'off', + 'react/no-children-map': 'off', + 'react/no-children-to-array': 'off', + 'react/no-context-provider': 'off', + 'react/no-missing-key': 'off', + 'react/no-nested-component-definitions': 'off', + 'react/no-use-context': 'off', + 'style/arrow-parens': 'off', + 'style/brace-style': 'off', + 'style/exp-list-style': 'off', + 'style/indent': 'off', + 'style/indent-binary-ops': 'off', + 'style/jsx-curly-brace-presence': 'off', + 'style/jsx-one-expression-per-line': 'off', + 'style/jsx-wrap-multilines': 'off', + 'style/member-delimiter-style': 'off', + 'style/multiline-ternary': 'off', + 'style/operator-linebreak': 'off', + 'style/quote-props': 'off', + 'style/quotes': 'off', + 'test/consistent-test-it': 'off', + 'test/no-identical-title': 'off', + 'test/no-import-node-test': 'off', + 'test/prefer-hooks-in-order': 'off', + 'test/prefer-lowercase-title': 'off', + 'ts/ban-ts-comment': 'off', + 'ts/method-signature-style': 'off', + 'ts/no-empty-object-type': 'off', + 'ts/no-non-null-asserted-optional-chain': 'off', + 'ts/no-redeclare': 'off', + 'ts/no-require-imports': 'off', + 'ts/no-unsafe-function-type': 'off', + 'ts/no-use-before-define': 'off', + 'unicorn/escape-case': 'off', + 'unicorn/number-literal-case': 'off', +} + export default defineConfig( { + formatters: false, formatting: false, lessOpinionated: true, - ignores: ['dist/**'], + stylistic: false, + yaml: false, + ignores: ['dist/**', '**/*.md'], preferESM: false, }, { @@ -17,12 +100,13 @@ export default defineConfig( }, }, rules: { + ...presetUpgradeCompatibilityRules, 'tailwindcss/classnames-order': 'off', 'tailwindcss/no-custom-classname': 'off', 'unicorn/prefer-math-trunc': 'off', 'unicorn/expiring-todo-comments': 0, - '@eslint-react/no-clone-element': 0, - '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0, + 'react/no-clone-element': 0, + 'react-hooks-extra/no-direct-set-state-in-use-effect': 0, // NOTE: Disable this temporarily 'react-compiler/react-compiler': 0, 'no-restricted-syntax': 0, @@ -58,8 +142,11 @@ export default defineConfig( }, { files: ['**/*.tsx'], + plugins: { + style: stylistic, + }, rules: { - '@stylistic/jsx-self-closing-comp': 'error', + 'style/jsx-self-closing-comp': 'error', }, }, { diff --git a/apps/landing/global.d.ts b/apps/landing/global.d.ts index 2378c3a1586..d1f35dd78a7 100644 --- a/apps/landing/global.d.ts +++ b/apps/landing/global.d.ts @@ -1,7 +1,5 @@ /// -/* eslint-disable @typescript-eslint/no-empty-object-type */ -/* eslint-disable @typescript-eslint/method-signature-style */ import type { FC, PropsWithChildren } from 'react' declare global { diff --git a/apps/landing/package.json b/apps/landing/package.json index 0a2ff086e19..a65ba2dd49c 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -14,36 +14,36 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@base-ui-components/react": "1.0.0-beta.4", - "@floating-ui/react-dom": "2.1.6", - "@radix-ui/react-accordion": "1.2.12", + "@base-ui/react": "1.6.0", + "@floating-ui/react-dom": "2.1.8", + "@radix-ui/react-accordion": "1.2.16", "@splinetool/react-spline": "4.1.0", - "@tanstack/query-async-storage-persister": "5.90.7", - "@tanstack/react-query": "5.90.5", - "@tanstack/react-query-persist-client": "5.90.7", + "@tanstack/query-async-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@types/js-cookie": "3.0.6", - "ai": "5.0.68", - "axios": "1.13.0", + "ai": "7.0.18", + "axios": "1.18.1", "clsx": "2.1.1", - "dayjs": "1.11.18", - "es-toolkit": "1.41.0", - "foxact": "0.2.49", - "idb-keyval": "6.2.2", - "immer": "10.2.0", + "dayjs": "1.11.21", + "es-toolkit": "1.49.0", + "foxact": "0.3.8", + "idb-keyval": "6.3.0", + "immer": "11.1.11", "jojoo": "0.3.0", - "jotai": "2.15.0", - "js-cookie": "3.0.5", - "motion": "12.23.24", - "next-intl": "4.4.0", + "jotai": "2.20.1", + "js-cookie": "3.0.8", + "motion": "12.42.2", + "next-intl": "4.13.1", "next-themes": "0.4.6", "ogl": "1.0.11", "progressive-blur": "1.0.0", - "radix-ui": "1.4.3", + "radix-ui": "1.6.2", "re-resizable": "6.11.2", - "react": "19.2.4", - "react-dom": "19.2.4", - "react-error-boundary": "6.0.0", - "react-intersection-observer": "9.16.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-error-boundary": "6.1.2", + "react-intersection-observer": "10.0.3", "react-markdown": "10.1.0", "react-resizable-layout": "0.7.3", "rehype-stringify": "10.0.1", @@ -52,44 +52,46 @@ "remark-rehype": "11.1.2", "rough-notation": "0.5.1", "sonner": "2.0.7", - "tailwind-merge": "3.3.1", + "tailwind-merge": "3.6.0", "unified": "11.0.5", "usehooks-ts": "3.1.1", "vaul": "1.1.2", - "vinext": "0.0.30" + "vinext": "1.0.0-beta.0" }, "devDependencies": { - "@cloudflare/vite-plugin": "1.25.5", - "@egoist/tailwindcss-icons": "1.9.0", - "@iconify-json/lucide": "1.2.71", - "@iconify-json/mingcute": "1.2.5", - "@iconify-json/simple-icons": "1.2.56", + "@cloudflare/vite-plugin": "1.43.1", + "@egoist/tailwindcss-icons": "1.9.2", + "@iconify-json/lucide": "1.2.116", + "@iconify-json/mingcute": "1.2.7", + "@iconify-json/simple-icons": "1.2.89", "@iconify/tailwind": "1.2.0", "@innei/prettier": "1.0.0", - "@tailwindcss/postcss": "4.1.16", - "@tailwindcss/typography": "0.5.19", - "@tanstack/react-query-devtools": "5.90.2", - "@types/node": "24.9.1", - "@types/react": "19.2.14", + "@stylistic/eslint-plugin": "5.10.0", + "@tailwindcss/postcss": "4.3.2", + "@tailwindcss/typography": "0.5.20", + "@tanstack/react-query-devtools": "5.101.2", + "@types/node": "26.1.1", + "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-rsc": "0.5.21", - "autoprefixer": "10.4.21", + "@vitejs/plugin-react": "6.0.3", + "@vitejs/plugin-rsc": "0.5.27", + "autoprefixer": "10.5.2", "babel-plugin-react-compiler": "1.0.0", - "code-inspector-plugin": "1.2.10", + "code-inspector-plugin": "1.6.6", "cross-env": "10.1.0", - "dotenv": "17.2.3", - "eslint": "9.38.0", - "eslint-config-hyoban": "4.0.10", - "postcss": "8.5.6", - "prettier": "3.6.2", - "rimraf": "6.0.1", + "dotenv": "17.4.2", + "eslint": "10.6.0", + "eslint-config-hyoban": "7.0.3", + "postcss": "8.5.16", + "prettier": "3.9.4", + "rimraf": "6.1.3", "tailwind-scrollbar": "4.0.2", - "tailwind-variants": "3.1.1", - "tailwindcss": "4.1.16", + "tailwind-variants": "3.2.2", + "tailwindcss": "4.3.2", "tailwindcss-animate": "1.0.7", - "tailwindcss-safe-area": "1.1.0", + "tailwindcss-safe-area": "1.3.0", "typescript": "catalog:", - "vite": "7.3.1", - "wrangler": "4.68.1" + "vite": "8.1.4", + "wrangler": "4.107.1" } } diff --git a/apps/landing/src/components/ui/theme-switcher/ThemeSwitcher.tsx b/apps/landing/src/components/ui/theme-switcher/ThemeSwitcher.tsx index cfb47966c9e..1d88c1ca079 100644 --- a/apps/landing/src/components/ui/theme-switcher/ThemeSwitcher.tsx +++ b/apps/landing/src/components/ui/theme-switcher/ThemeSwitcher.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/dom/no-flush-sync */ 'use client' import { useTheme } from 'next-themes' diff --git a/apps/landing/src/components/ui/tooltip/index.tsx b/apps/landing/src/components/ui/tooltip/index.tsx index 4e0e618b292..e75bd4b2f70 100644 --- a/apps/landing/src/components/ui/tooltip/index.tsx +++ b/apps/landing/src/components/ui/tooltip/index.tsx @@ -1,4 +1,4 @@ -import { Tooltip as TooltipPrimitive } from '@base-ui-components/react/tooltip' +import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip' import { m } from 'motion/react' import * as React from 'react' diff --git a/apps/landing/src/hooks/common/use-click-away.ts b/apps/landing/src/hooks/common/use-click-away.ts index fdcef577527..48cb2732f86 100644 --- a/apps/landing/src/hooks/common/use-click-away.ts +++ b/apps/landing/src/hooks/common/use-click-away.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ // @copy https://github.com/streamich/react-use/blob/master/src/useClickAway.ts import type { RefObject } from 'react' import { useEffect, useRef } from 'react' @@ -17,8 +16,7 @@ function on( function off( obj: T | null, ...args: - | Parameters - | [string, Function | null, ...any] + Parameters | [string, Function | null, ...any] ): void { if (obj && obj.removeEventListener) { obj.removeEventListener( diff --git a/apps/landing/src/lib/datetime.ts b/apps/landing/src/lib/datetime.ts index 3fcfa8de3e4..190f29a624e 100644 --- a/apps/landing/src/lib/datetime.ts +++ b/apps/landing/src/lib/datetime.ts @@ -11,7 +11,7 @@ dayjs.locale('zh-cn') export enum DateFormat { 'MMM DD YYYY', 'HH:mm', - 'LLLL', + LLLL, 'H:mm:ss A', 'YYYY-MM-DD', 'YYYY-MM-DD dddd', diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json index 5dcf097e733..a0ce66eb6b8 100644 --- a/apps/landing/tsconfig.json +++ b/apps/landing/tsconfig.json @@ -9,11 +9,10 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "baseUrl": ".", "incremental": true, "plugins": [ { diff --git a/apps/mobile/app.config.base.ts b/apps/mobile/app.config.base.ts index 7ef8b3211de..90586337050 100644 --- a/apps/mobile/app.config.base.ts +++ b/apps/mobile/app.config.base.ts @@ -144,6 +144,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { "expo-build-properties", { ios: { + deploymentTarget: "16.4", // Expo SDK 55 archive builds regress with use_frameworks + prebuilt RN core. buildReactNativeFromSource: true, useFrameworks: "static", @@ -161,6 +162,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { }, ], "expo-apple-authentication", + "expo-status-bar", "expo-web-browser", "expo-image", "expo-sharing", diff --git a/apps/mobile/e2e/flows/ios/content.yaml b/apps/mobile/e2e/flows/ios/content.yaml index e0179019555..57c4ad66650 100644 --- a/apps/mobile/e2e/flows/ios/content.yaml +++ b/apps/mobile/e2e/flows/ios/content.yaml @@ -1,6 +1,8 @@ appId: is.follow name: iOS content journey --- +- runFlow: + file: ./dismiss-overlays.yaml - runFlow: file: ./ensure-onboarding-unfollowed.yaml - runFlow: diff --git a/apps/mobile/e2e/flows/ios/dismiss-overlays.yaml b/apps/mobile/e2e/flows/ios/dismiss-overlays.yaml index 09674814d95..dda87277051 100644 --- a/apps/mobile/e2e/flows/ios/dismiss-overlays.yaml +++ b/apps/mobile/e2e/flows/ios/dismiss-overlays.yaml @@ -21,3 +21,9 @@ appId: is.follow - tapOn: point: "50%,18%" - waitForAnimationToEnd +- runFlow: + when: + visible: Not Now + commands: + - tapOn: Not Now + - waitForAnimationToEnd diff --git a/apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml b/apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml index fb0dad290ae..4c1f7381a63 100644 --- a/apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml +++ b/apps/mobile/e2e/flows/ios/ensure-onboarding-unfollowed.yaml @@ -1,5 +1,7 @@ appId: is.follow --- +- runFlow: + file: ./dismiss-overlays.yaml - tapOn: text: Discover - tapOn: @@ -8,7 +10,8 @@ appId: is.follow - inputText: folo://onboarding - pressKey: Enter - extendedWaitUntil: - visible: Welcome to Folo + visible: + id: discover-feed-follow-action timeout: 20000 - tapOn: id: discover-feed-follow-action diff --git a/apps/mobile/e2e/flows/ios/follow-onboarding.yaml b/apps/mobile/e2e/flows/ios/follow-onboarding.yaml index 6e154e73de1..f416c792d66 100644 --- a/apps/mobile/e2e/flows/ios/follow-onboarding.yaml +++ b/apps/mobile/e2e/flows/ios/follow-onboarding.yaml @@ -1,5 +1,7 @@ appId: is.follow --- +- runFlow: + file: ./dismiss-overlays.yaml - tapOn: text: Discover - tapOn: @@ -8,7 +10,8 @@ appId: is.follow - inputText: folo://onboarding - pressKey: Enter - extendedWaitUntil: - visible: Welcome to Folo + visible: + id: discover-feed-follow-action timeout: 20000 - tapOn: id: discover-feed-follow-action diff --git a/apps/mobile/e2e/flows/ios/sign-out.yaml b/apps/mobile/e2e/flows/ios/sign-out.yaml index 302d750780f..8e34b057713 100644 --- a/apps/mobile/e2e/flows/ios/sign-out.yaml +++ b/apps/mobile/e2e/flows/ios/sign-out.yaml @@ -42,6 +42,10 @@ appId: is.follow timeout: 10000 - tapOn: text: Sign Out +- launchApp: + stopApp: false +- waitForAnimationToEnd: + timeout: 15000 - extendedWaitUntil: visible: id: login-provider-credential diff --git a/apps/mobile/e2e/flows/ios/timeline-entry.yaml b/apps/mobile/e2e/flows/ios/timeline-entry.yaml index a8e086f9f29..6824169276a 100644 --- a/apps/mobile/e2e/flows/ios/timeline-entry.yaml +++ b/apps/mobile/e2e/flows/ios/timeline-entry.yaml @@ -1,5 +1,7 @@ appId: is.follow --- +- runFlow: + file: ./dismiss-overlays.yaml - tapOn: text: Home - tapOn: diff --git a/apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml b/apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml index 6b884cabca2..1309ed18629 100644 --- a/apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml +++ b/apps/mobile/e2e/flows/ios/unfollow-onboarding.yaml @@ -1,5 +1,7 @@ appId: is.follow --- +- runFlow: + file: ./dismiss-overlays.yaml - tapOn: text: Discover - tapOn: @@ -8,7 +10,8 @@ appId: is.follow - inputText: folo://onboarding - pressKey: Enter - extendedWaitUntil: - visible: Welcome to Folo + visible: + id: discover-feed-follow-action timeout: 20000 - tapOn: id: discover-feed-follow-action diff --git a/apps/mobile/e2e/flows/shared/follow-onboarding.yaml b/apps/mobile/e2e/flows/shared/follow-onboarding.yaml index 9c3ac78945b..8af895e7f4f 100644 --- a/apps/mobile/e2e/flows/shared/follow-onboarding.yaml +++ b/apps/mobile/e2e/flows/shared/follow-onboarding.yaml @@ -7,7 +7,8 @@ appId: is.follow - inputText: folo://onboarding - pressKey: Enter - extendedWaitUntil: - visible: Welcome to Folo + visible: + id: discover-feed-follow-action timeout: 20000 - tapOn: id: discover-feed-follow-action diff --git a/apps/mobile/e2e/run-maestro.sh b/apps/mobile/e2e/run-maestro.sh index aafdb9335d7..d1b1bdc5f99 100755 --- a/apps/mobile/e2e/run-maestro.sh +++ b/apps/mobile/e2e/run-maestro.sh @@ -137,7 +137,7 @@ build_ios_simulator_app() { -destination "id=${device_id}" \ build \ "$@" - ) + ) >&2 find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1 } diff --git a/apps/mobile/ios/Folo.xcodeproj/project.pbxproj b/apps/mobile/ios/Folo.xcodeproj/project.pbxproj index bbef7f2fa66..724db008e3f 100644 --- a/apps/mobile/ios/Folo.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Folo.xcodeproj/project.pbxproj @@ -292,10 +292,16 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Folo/Pods-Folo-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoModulesJSI/ExpoModulesJSI.framework/ExpoModulesJSI", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoModulesJSI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -332,19 +338,19 @@ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Mute/Mute.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-timing/React-timing_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit_Privacy.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle", + "${PODS_ROOT}/openiap/openiap-versions.json", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -370,19 +376,19 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Mute.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-timing_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SnapKit_Privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/openiap-versions.json", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -458,7 +464,7 @@ "FB_SONARKIT_ENABLED=1", ); INFOPLIST_FILE = Folo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -492,7 +498,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 492J8Q67PF; INFOPLIST_FILE = Folo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -582,8 +588,10 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + "${PODS_ROOT}/React-featureflags", + "${PODS_ROOT}/React-renderercss", ); - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -591,8 +599,15 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_CFLAGS = "$(inherited)"; - OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -663,16 +678,25 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", + "${PODS_ROOT}/React-featureflags", + "${PODS_ROOT}/React-renderercss", ); - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_CFLAGS = "$(inherited)"; - OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ENABLE_EXPLICIT_MODULES = NO; diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile index 5bef41b620e..81cc95728e3 100644 --- a/apps/mobile/ios/Podfile +++ b/apps/mobile/ios/Podfile @@ -19,7 +19,7 @@ ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NET ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' ENV['RCT_HERMES_V1_ENABLED'] ||= '1' if podfile_properties['expo.useHermesV1'] == 'true' -platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' +platform :ios, podfile_properties['ios.deploymentTarget'] || '16.4' prepare_react_native_project! diff --git a/apps/mobile/ios/Podfile.properties.json b/apps/mobile/ios/Podfile.properties.json index b1510c06f1f..25ef77ae8a3 100644 --- a/apps/mobile/ios/Podfile.properties.json +++ b/apps/mobile/ios/Podfile.properties.json @@ -1,6 +1,7 @@ { "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", + "ios.deploymentTarget": "16.4", "ios.buildReactNativeFromSource": "true", "ios.useFrameworks": "static", "ios.forceStaticLinking": "[\"RNFBApp\",\"RNFBAnalytics\",\"RNFBMessaging\",\"RNFBAppCheck\"]", diff --git a/apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift b/apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift index ac3c9f5b97b..fe4429dfb02 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift @@ -183,6 +183,7 @@ enum WebViewManager { @discardableResult static func getOrCreate() -> WKWebView { if let webView = shared { return webView } + state.setInitialContentHeightIfNeeded() let webView = FOWebView(frame: .zero, state: state) shared = webView return webView diff --git a/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift b/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift index 967f133f16a..1bdcd2577c6 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift @@ -23,15 +23,12 @@ final class WebViewState: ObservableObject { @Published public var audioSeekEvent: AudioSeekEvent? public init() { - if Thread.isMainThread { - self.contentHeight = UIScreen.main.bounds.height - } else { - var height: CGFloat = 0 - DispatchQueue.main.sync { - height = UIScreen.main.bounds.height - } - self.contentHeight = height - } + self.contentHeight = 0 + } + + public func setInitialContentHeightIfNeeded() { + guard Thread.isMainThread, contentHeight == 0 else { return } + contentHeight = UIScreen.main.bounds.height } public func previewImages(urls: [String], index: Int) { diff --git a/apps/mobile/native/ios/Modules/StoreKitTestHelper/StoreKitTestHelperModule.swift b/apps/mobile/native/ios/Modules/StoreKitTestHelper/StoreKitTestHelperModule.swift index 3f0c2e59978..a45666f106a 100644 --- a/apps/mobile/native/ios/Modules/StoreKitTestHelper/StoreKitTestHelperModule.swift +++ b/apps/mobile/native/ios/Modules/StoreKitTestHelper/StoreKitTestHelperModule.swift @@ -1,11 +1,12 @@ import ExpoModulesCore import Foundation -#if canImport(StoreKitTest) +#if STOREKIT_TESTING && canImport(StoreKitTest) +import StoreKit import StoreKitTest #endif public class StoreKitTestHelperModule: Module { - #if canImport(StoreKitTest) + #if STOREKIT_TESTING && canImport(StoreKitTest) private static var session: SKTestSession? #endif @@ -13,7 +14,7 @@ public class StoreKitTestHelperModule: Module { Name("StoreKitTestHelper") AsyncFunction("prepareLocalSubscriptions") { () -> [String: Any] in - #if canImport(StoreKitTest) + #if STOREKIT_TESTING && canImport(StoreKitTest) let moduleFileURL = URL(fileURLWithPath: #filePath) let appRootURL = moduleFileURL .deletingLastPathComponent() @@ -45,16 +46,22 @@ public class StoreKitTestHelperModule: Module { } AsyncFunction("buyProduct") { (productId: String) async throws -> [String: Any] in - #if canImport(StoreKitTest) + #if STOREKIT_TESTING && canImport(StoreKitTest) guard let session = Self.session else { throw NSError(domain: "StoreKitTestHelper", code: 1, userInfo: [NSLocalizedDescriptionKey: "SKTestSession not prepared"]) } - let transaction = try await session.buyProduct(identifier: productId) - return [ - "success": true, - "productId": productId, - "jwsRepresentation": transaction.jwsRepresentation, - ] + if #available(iOS 17.0, *) { + _ = try await session.buyProduct(identifier: productId) + guard let transaction = await Transaction.latest(for: productId) else { + throw NSError(domain: "StoreKitTestHelper", code: 2, userInfo: [NSLocalizedDescriptionKey: "StoreKit transaction not found"]) + } + return [ + "success": true, + "productId": productId, + "jwsRepresentation": transaction.jwsRepresentation, + ] + } + throw NSError(domain: "StoreKitTestHelper", code: 3, userInfo: [NSLocalizedDescriptionKey: "StoreKitTest purchases require iOS 17 or newer"]) #else return [ "success": false, diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift index 28e2c5c18cc..b5a09cd4984 100644 --- a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift @@ -122,13 +122,16 @@ class TabBarRootView: ExpoView { tabViewControllers.append(screenVC) tabBarController.viewControllers = tabViewControllers tabBarController.didMove(toParent: vc) + return } if let tabBarPortalView = subview as? TabBarPortalView { let tabBarView = tabBarController.view! tabBarView.addSubview(tabBarPortalView) + return } + super.insertSubview(subview, at: atIndex) } override func willRemoveSubview(_ subview: UIView) { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6716a87cd6d..0383e8c1dac 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,8 +26,9 @@ "web": "expo start --web" }, "dependencies": { - "@better-auth/expo": "1.5.5", - "@expo/metro-runtime": "55.0.9", + "@better-auth/expo": "1.6.23", + "@expo/dom-webview": "57.0.0", + "@expo/metro-runtime": "57.0.3", "@expo/react-native-action-sheet": "4.1.1", "@follow-app/client-sdk": "catalog:", "@follow/components": "workspace:*", @@ -41,124 +42,123 @@ "@follow/types": "workspace:*", "@follow/utils": "workspace:*", "@gorhom/portal": "1.0.14", - "@hookform/resolvers": "5.2.2", + "@hookform/resolvers": "5.4.0", "@react-native-community/image-editor": "4.3.0", - "@react-native-firebase/analytics": "24.0.0", - "@react-native-firebase/app": "24.0.0", - "@react-native-firebase/app-check": "24.0.0", - "@react-native-firebase/messaging": "24.0.0", + "@react-native-firebase/analytics": "25.1.0", + "@react-native-firebase/app": "25.1.0", + "@react-native-firebase/app-check": "25.1.0", + "@react-native-firebase/messaging": "25.1.0", "@react-native-masked-view/masked-view": "0.3.2", "@react-native-menu/menu": "2.0.0", "@react-native-picker/picker": "2.11.4", - "@shikijs/themes": "3.22.0", - "@shopify/flash-list": "2.0.2", - "@tanstack/query-sync-storage-persister": "5.90.22", - "@tanstack/react-query": "5.90.21", - "@tanstack/react-query-persist-client": "5.90.22", + "@shikijs/themes": "4.3.1", + "@shopify/flash-list": "2.3.2", + "@tanstack/query-sync-storage-persister": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@types/qrcode": "1.5.6", - "better-auth": "1.5.5", + "better-auth": "1.6.23", "camelcase-keys": "10.0.2", - "dayjs": "1.11.19", + "dayjs": "1.11.21", "dnum": "2.17.0", - "es-toolkit": "1.44.0", - "expo": "~55.0.15", - "expo-apple-authentication": "55.0.13", - "expo-application": "55.0.14", - "expo-background-task": "~55.0.16", - "expo-blur": "55.0.14", - "expo-build-properties": "55.0.13", - "expo-clipboard": "55.0.13", - "expo-constants": "~55.0.14", - "expo-dev-client": "~55.0.27", - "expo-device": "~55.0.15", - "expo-document-picker": "55.0.13", - "expo-file-system": "55.0.16", - "expo-glass-effect": "55.0.10", - "expo-haptics": "55.0.14", - "expo-iap": "2.5.0", - "expo-image": "55.0.8", - "expo-image-manipulator": "55.0.15", - "expo-image-picker": "~55.0.18", - "expo-linear-gradient": "55.0.13", - "expo-linking": "~55.0.13", - "expo-localization": "55.0.13", - "expo-media-library": "55.0.14", - "expo-network": "55.0.13", - "expo-notifications": "~55.0.19", - "expo-secure-store": "55.0.13", - "expo-sharing": "55.0.18", - "expo-splash-screen": "~55.0.18", - "expo-sqlite": "55.0.15", - "expo-status-bar": "55.0.5", - "expo-store-review": "~55.0.13", - "expo-symbols": "55.0.7", - "expo-system-ui": "55.0.15", - "expo-task-manager": "55.0.14", - "expo-updates": "55.0.20", - "expo-video": "55.0.15", - "expo-web-browser": "55.0.14", - "foxact": "0.2.52", + "es-toolkit": "1.49.0", + "expo": "~57.0.4", + "expo-apple-authentication": "57.0.0", + "expo-application": "57.0.0", + "expo-background-task": "~57.0.2", + "expo-blur": "57.0.0", + "expo-build-properties": "57.0.3", + "expo-clipboard": "57.0.0", + "expo-constants": "~57.0.3", + "expo-dev-client": "~57.0.5", + "expo-device": "~57.0.0", + "expo-document-picker": "57.0.0", + "expo-file-system": "57.0.0", + "expo-glass-effect": "57.0.0", + "expo-haptics": "57.0.0", + "expo-iap": "4.3.6", + "expo-image": "57.0.0", + "expo-image-manipulator": "57.0.2", + "expo-image-picker": "~57.0.2", + "expo-linear-gradient": "57.0.0", + "expo-linking": "~57.0.2", + "expo-localization": "57.0.0", + "expo-media-library": "57.0.1", + "expo-network": "57.0.0", + "expo-notifications": "~57.0.3", + "expo-secure-store": "57.0.0", + "expo-sharing": "57.0.3", + "expo-splash-screen": "~57.0.2", + "expo-sqlite": "57.0.0", + "expo-status-bar": "57.0.0", + "expo-store-review": "~57.0.0", + "expo-symbols": "57.0.0", + "expo-system-ui": "57.0.0", + "expo-task-manager": "57.0.2", + "expo-updates": "57.0.6", + "expo-video": "57.0.0", + "expo-web-browser": "57.0.0", + "foxact": "0.3.8", "franc-min": "6.2.0", - "i18next": "25.8.6", - "jotai": "2.17.1", - "lru-cache": "11.2.6", - "nanoid": "5.1.6", - "nativewind": "4.2.1", + "i18next": "26.3.5", + "jotai": "2.20.1", + "lru-cache": "11.5.2", + "nanoid": "5.1.16", + "nativewind": "4.2.6", "ofetch": "1.5.1", - "posthog-react-native": "4.34.0", + "posthog-react-native": "4.54.4", "qrcode": "1.5.4", - "react": "19.2.0", - "react-dom": "19.2.0", - "react-error-boundary": "6.1.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-error-boundary": "6.1.2", "react-freeze": "1.0.4", - "react-hook-form": "7.71.1", - "react-i18next": "16.5.4", - "react-native": "0.83.4", + "react-hook-form": "7.81.0", + "react-i18next": "17.0.8", + "react-native": "0.86.0", "react-native-awesome-slider": "2.9.0", "react-native-bouncy-checkbox": "4.1.4", "react-native-color-matrix-image-filters": "8.0.2", - "react-native-css-interop": "0.2.1", - "react-native-device-info": "15.0.1", - "react-native-edge-to-edge": "1.7.0", - "react-native-gesture-handler": "2.30.1", - "react-native-image-colors": "2.5.1", + "react-native-css-interop": "0.2.6", + "react-native-device-info": "15.0.2", + "react-native-edge-to-edge": "1.8.1", + "react-native-gesture-handler": "3.0.2", + "react-native-image-colors": "2.6.0", "react-native-ios-context-menu": "3.2.1", "react-native-ios-utilities": "5.2.0", - "react-native-keyboard-controller": "1.20.7", - "react-native-otp-entry": "1.8.5", - "react-native-pager-view": "8.0.0", - "react-native-reanimated": "4.2.1", + "react-native-keyboard-controller": "1.22.0", + "react-native-otp-entry": "1.8.6", + "react-native-pager-view": "8.0.3", + "react-native-reanimated": "4.5.1", "react-native-root-siblings": "5.0.1", - "react-native-safe-area-context": "5.6.2", - "react-native-screens": "4.23.0", + "react-native-safe-area-context": "5.8.0", + "react-native-screens": "4.25.2", "react-native-sheet-transitions": "0.1.2", - "react-native-svg": "15.15.3", - "react-native-track-player": "4.1.1", - "react-native-uikit-colors": "0.6.2", + "react-native-svg": "15.15.5", + "react-native-track-player": "4.1.2", + "react-native-uikit-colors": "1.0.0", "react-native-volume-manager": "2.0.8", "react-native-web": "0.21.2", - "react-native-webview": "13.16.0", - "react-native-worklets": "0.7.4", - "shiki": "3.22.0", + "react-native-webview": "14.0.1", + "react-native-worklets": "0.10.2", + "shiki": "4.3.1", "tailwindcss": "3.4.17", "title-case": "4.3.2", "use-sync-external-store": "1.6.0", "usehooks-ts": "3.1.1", "zeego": "3.0.6", - "zod": "3.25.76", - "zustand": "5.0.11" + "zod": "4.4.3", + "zustand": "5.0.14" }, "devDependencies": { "@babel/core": "7.29.0", "@follow/configs": "workspace:*", - "@tailwindcss/typography": "0.5.19", - "@types/react": "19.2.14", + "@tailwindcss/typography": "0.5.20", + "@types/react": "19.2.17", "babel-plugin-inline-import": "3.0.0", - "eas-cli": "16.26.0", "expo-drizzle-studio-plugin": "0.2.1", "nbump": "2.1.8", "pathe": "2.0.3", - "postcss": "8.5.6", + "postcss": "8.5.16", "sf-symbols-typescript": "2.2.0", "typescript": "catalog:" }, @@ -166,6 +166,16 @@ "expo": { "install": { "exclude": [ + "@shopify/flash-list", + "react", + "react-dom", + "react-native-keyboard-controller", + "react-native-gesture-handler", + "react-native-pager-view", + "react-native-reanimated", + "react-native-safe-area-context", + "react-native-svg", + "react-native-webview", "react-native-worklets" ] }, diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 0fffc3a02ff..f58cb1f06ac 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -19,7 +19,7 @@ import { ReviewPromptProvider } from "./modules/review-prompt/provider" export function App({ children }: { children: React.ReactNode }) { return ( <> - + diff --git a/apps/mobile/src/components/common/BlurEffect.tsx b/apps/mobile/src/components/common/BlurEffect.tsx index a86ce93ebad..9b3d05453b8 100644 --- a/apps/mobile/src/components/common/BlurEffect.tsx +++ b/apps/mobile/src/components/common/BlurEffect.tsx @@ -13,7 +13,7 @@ export const BlurEffect = () => { const styles = StyleSheet.create({ fill: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, overflow: "hidden", backgroundColor: "transparent", }, diff --git a/apps/mobile/src/components/common/SwipeableItem.tsx b/apps/mobile/src/components/common/SwipeableItem.tsx index 6766e267138..340d982819a 100644 --- a/apps/mobile/src/components/common/SwipeableItem.tsx +++ b/apps/mobile/src/components/common/SwipeableItem.tsx @@ -35,7 +35,7 @@ const getActionKey = (side: "left" | "right", action: Action) => { } const styles = StyleSheet.create({ absoluteFill: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, actionContainer: { flex: 1, diff --git a/apps/mobile/src/components/common/ThemedBlurView.tsx b/apps/mobile/src/components/common/ThemedBlurView.tsx index add3e39f4f0..681a2fea21f 100644 --- a/apps/mobile/src/components/common/ThemedBlurView.tsx +++ b/apps/mobile/src/components/common/ThemedBlurView.tsx @@ -45,9 +45,7 @@ export const ThemedBlurView = ({ tint={colorScheme === "light" ? "systemChromeMaterialLight" : "systemChromeMaterialDark"} {...rest} /> - {tintColor && ( - - )} + {tintColor && } ) : ( { - const lastScrollY = useRef(0) - - const largeDefaultHeaderHeightRef = useRef( + const lastScrollY = useSharedValue(0) + const largeDefaultHeaderHeight = useSharedValue( + originalDefaultHeaderHeight + (hideableBottomHeight || 0), + ) + const largeHeaderHeight = useSharedValue( originalDefaultHeaderHeight + (hideableBottomHeight || 0), ) - const largeHeaderHeight = useSharedValue(largeDefaultHeaderHeightRef.current) const [hideableBottomRef, setHideableBottomRef] = useState() useEffect(() => { hideableBottomRef?.measure((x, y, width, height) => { const largeHeight = height + originalDefaultHeaderHeight - largeDefaultHeaderHeightRef.current = largeHeight + largeDefaultHeaderHeight.value = largeHeight largeHeaderHeight.value = largeHeight }) - }, [hideableBottomRef, largeHeaderHeight, originalDefaultHeaderHeight]) + }, [hideableBottomRef, largeDefaultHeaderHeight, largeHeaderHeight, originalDefaultHeaderHeight]) const { reAnimatedScrollY } = use(ScreenItemContext)! useAnimatedReaction( @@ -92,24 +93,24 @@ const useHideableBottom = ( return } - const largeDefaultHeaderHeight = largeDefaultHeaderHeightRef.current + const expandedHeaderHeight = largeDefaultHeaderHeight.value if (value <= 100) { - largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight) - } else if (value > lastScrollY.current + HideableThreshold) { + largeHeaderHeight.value = withTiming(expandedHeaderHeight) + } else if (value > lastScrollY.value + HideableThreshold) { largeHeaderHeight.value = withTiming(originalDefaultHeaderHeight) - } else if (value < lastScrollY.current - HideableThreshold) { - largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight) + } else if (value < lastScrollY.value - HideableThreshold) { + largeHeaderHeight.value = withTiming(expandedHeaderHeight) } - lastScrollY.current = value + lastScrollY.value = value }, ) useEffect(() => { - EventBus.subscribe("SELECT_TIMELINE", () => { - largeHeaderHeight.value = withTiming(largeDefaultHeaderHeightRef.current) + return EventBus.subscribe("SELECT_TIMELINE", () => { + largeHeaderHeight.value = withTiming(largeDefaultHeaderHeight.value) }) - }, [largeHeaderHeight]) + }, [largeDefaultHeaderHeight, largeHeaderHeight]) const layoutHeightOnceRef = useRef(false) const onLayout = useCallback( @@ -126,16 +127,21 @@ const useHideableBottom = ( layoutHeightOnceRef.current = true - largeDefaultHeaderHeightRef.current = height + originalDefaultHeaderHeight - largeHeaderHeight.value = largeDefaultHeaderHeightRef.current + const expandedHeaderHeight = height + originalDefaultHeaderHeight + largeDefaultHeaderHeight.value = expandedHeaderHeight + largeHeaderHeight.value = expandedHeaderHeight }, - [hideableBottomHeight, largeHeaderHeight, originalDefaultHeaderHeight], + [ + hideableBottomHeight, + largeDefaultHeaderHeight, + largeHeaderHeight, + originalDefaultHeaderHeight, + ], ) return { hideableBottomRef, setHideableBottomRef, largeHeaderHeight, - largeDefaultHeaderHeightRef, onLayout, } } @@ -217,7 +223,7 @@ export const InternalNavigationHeader = ({ const blurStyle = useAnimatedStyle(() => ({ opacity: opacityAnimated.value, - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: border, })) diff --git a/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx b/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx index a296124bc92..e2bfa659a2c 100644 --- a/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx +++ b/apps/mobile/src/components/layouts/tabbar/Tabbar.tsx @@ -238,7 +238,7 @@ const styles = StyleSheet.create({ fontSize: 10, }, blurEffect: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, overflow: "hidden", backgroundColor: "transparent", }, diff --git a/apps/mobile/src/components/native/PagerView/index.tsx b/apps/mobile/src/components/native/PagerView/index.tsx index 8bcddc8cd55..865f02d12a2 100644 --- a/apps/mobile/src/components/native/PagerView/index.tsx +++ b/apps/mobile/src/components/native/PagerView/index.tsx @@ -102,6 +102,6 @@ export const PagerView: FC = ({ const styles = StyleSheet.create({ pageContainer: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, }) diff --git a/apps/mobile/src/components/native/webview/native-webview.android.tsx b/apps/mobile/src/components/native/webview/native-webview.android.tsx index 716410444c6..11ba1922773 100644 --- a/apps/mobile/src/components/native/webview/native-webview.android.tsx +++ b/apps/mobile/src/components/native/webview/native-webview.android.tsx @@ -6,7 +6,7 @@ import type { RefObject } from "react" import { useCallback, useRef } from "react" import type { ViewProps } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" -import type { WebViewNavigation } from "react-native-webview" +import type { WebViewNavigation, WebViewProps } from "react-native-webview" import WebView from "react-native-webview" import { openLink } from "@/src/lib/native" @@ -15,9 +15,11 @@ import { useLightboxControls } from "../../ui/lightbox/lightboxState" import { htmlUrl } from "./constants" import { atEnd, atStart } from "./injected-js" -const webviewAtom = atom(null) +type WebViewRef = WebView -const setWebview = (webview: WebView | null) => { +const webviewAtom = atom(null) + +const setWebview = (webview: WebViewRef | null) => { jotaiStore.set(webviewAtom, webview) } @@ -41,12 +43,12 @@ export const NativeWebView: React.ComponentType< url?: string } > = ({ onContentHeightChange, onSeekAudio }) => { - const webViewRef = useRef(null) + const webViewRef = useRef(null) const { onNavigationStateChange } = useWebViewNavigation({ webViewRef }) const { openLightbox } = useLightboxControls() return ( - ref={(webview) => { setWebview(webview) }} @@ -120,7 +122,7 @@ export const NativeWebView: React.ComponentType< ) } -const useWebViewNavigation = ({ webViewRef }: { webViewRef: RefObject }) => { +const useWebViewNavigation = ({ webViewRef }: { webViewRef: RefObject }) => { const onNavigationStateChange = useCallback( (newNavState: WebViewNavigation) => { const { url: urlStr } = newNavState diff --git a/apps/mobile/src/components/ui/form/FormProvider.tsx b/apps/mobile/src/components/ui/form/FormProvider.tsx index 978cffe6c0e..7f4b49c0b95 100644 --- a/apps/mobile/src/components/ui/form/FormProvider.tsx +++ b/apps/mobile/src/components/ui/form/FormProvider.tsx @@ -1,19 +1,27 @@ import { createContext, use } from "react" import type { FieldValues, UseFormReturn } from "react-hook-form" -const FormContext = createContext | null>(null) +const FormContext = createContext | null>(null) -export function FormProvider(props: { - form: UseFormReturn +export function FormProvider< + TFieldValues extends FieldValues, + TContext = any, + TTransformedValues = TFieldValues, +>(props: { + form: UseFormReturn children: React.ReactNode }) { return {props.children} } -export function useFormContext() { +export function useFormContext< + TFieldValues extends FieldValues = FieldValues, + TContext = any, + TTransformedValues = TFieldValues, +>() { const context = use(FormContext) if (!context) { throw new Error("useFormContext must be used within a FormProvider") } - return context as UseFormReturn + return context as UseFormReturn } diff --git a/apps/mobile/src/components/ui/form/PickerIos.tsx b/apps/mobile/src/components/ui/form/PickerIos.tsx index 50b6400a192..7d64011f5ee 100644 --- a/apps/mobile/src/components/ui/form/PickerIos.tsx +++ b/apps/mobile/src/components/ui/form/PickerIos.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/no-array-index-key */ import { cn } from "@follow/utils" import { Portal } from "@gorhom/portal" import { Picker } from "@react-native-picker/picker" diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index eace607c775..c27efe979de 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -23,6 +23,7 @@ import { GROUPED_SECTION_BOTTOM_MARGIN, GROUPED_SECTION_TOP_MARGIN, } from "./constants" +import { getGroupedListChildKey } from "./grouped-list-key" import { GroupedInsetListCardItemStyle } from "./GroupedInsetListCardItemStyle" const GROUPED_TABLET_MAX_WIDTH = 760 @@ -84,7 +85,6 @@ export const GroupedInsetListCard: FC< if (child === null) return null const isNavigationLink = React.isValidElement(child) && - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type ((child.type as Function).name === GroupedInsetListNavigationLink.name || (child.type as any).itemStyle === GroupedInsetListCardItemStyle.NavigationLink) const NextSeparatorComponent = @@ -95,7 +95,7 @@ export const GroupedInsetListCard: FC< : NextSeparatorComponent : NextSeparatorComponent return ( - + {child} {!isLast && (NextSeparatorElement ?? ( diff --git a/apps/mobile/src/components/ui/grouped/grouped-list-key.test.ts b/apps/mobile/src/components/ui/grouped/grouped-list-key.test.ts new file mode 100644 index 00000000000..d6d1bc1f60d --- /dev/null +++ b/apps/mobile/src/components/ui/grouped/grouped-list-key.test.ts @@ -0,0 +1,20 @@ +import { createElement } from "react" +import { describe, expect, test } from "vitest" + +import { getGroupedListChildKey } from "./grouped-list-key" + +describe("getGroupedListChildKey", () => { + test("falls back to the child index when element keys are missing", () => { + const children = [createElement("first"), createElement("second")] + + expect(children.map(getGroupedListChildKey)).toEqual([0, 1]) + }) + + test("preserves an explicit element key", () => { + expect(getGroupedListChildKey(createElement("child", { key: "account" }), 3)).toBe("account") + }) + + test("uses the child index for non-elements", () => { + expect(getGroupedListChildKey("label", 2)).toBe(2) + }) +}) diff --git a/apps/mobile/src/components/ui/grouped/grouped-list-key.ts b/apps/mobile/src/components/ui/grouped/grouped-list-key.ts new file mode 100644 index 00000000000..2ab5c43c161 --- /dev/null +++ b/apps/mobile/src/components/ui/grouped/grouped-list-key.ts @@ -0,0 +1,6 @@ +import type { Key, ReactNode } from "react" +import { isValidElement } from "react" + +export const getGroupedListChildKey = (child: ReactNode, index: number): Key => { + return isValidElement(child) ? (child.key ?? index) : index +} diff --git a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index eedc98172ff..3b1fd22f22e 100644 --- a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { useState } from "react" import { ActivityIndicator, StyleSheet } from "react-native" -import type { PanGesture } from "react-native-gesture-handler" +import type { LegacyPanGesture } from "react-native-gesture-handler" import { Gesture, GestureDetector } from "react-native-gesture-handler" import type { SharedValue } from "react-native-reanimated" import Animated, { @@ -47,7 +47,7 @@ type Props = { } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - dismissSwipePan: PanGesture + dismissSwipePan: LegacyPanGesture transforms: Readonly< SharedValue<{ scaleAndMoveTransform: Transform diff --git a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index 790cc1aafac..e80a901528d 100644 --- a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -9,7 +9,7 @@ import * as React from "react" import { useState } from "react" import { ActivityIndicator, StyleSheet } from "react-native" -import type { PanGesture } from "react-native-gesture-handler" +import type { LegacyPanGesture } from "react-native-gesture-handler" import { Gesture, GestureDetector } from "react-native-gesture-handler" import type { SharedValue } from "react-native-reanimated" import Animated, { @@ -46,7 +46,7 @@ type Props = { } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - dismissSwipePan: PanGesture + dismissSwipePan: LegacyPanGesture transforms: Readonly< SharedValue<{ scaleAndMoveTransform: Transform diff --git a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index f257615dd90..1bc9e5bd7c8 100644 --- a/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/apps/mobile/src/components/ui/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { View } from "react-native" -import type { PanGesture } from "react-native-gesture-handler" +import type { LegacyPanGesture } from "react-native-gesture-handler" import type { SharedValue } from "react-native-reanimated" import type { @@ -28,7 +28,7 @@ type Props = { } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - dismissSwipePan: PanGesture + dismissSwipePan: LegacyPanGesture transforms: Readonly< SharedValue<{ scaleAndMoveTransform: Transform diff --git a/apps/mobile/src/components/ui/tabview/TabView.tsx b/apps/mobile/src/components/ui/tabview/TabView.tsx index 9fdde24f374..c4fa14c45c6 100644 --- a/apps/mobile/src/components/ui/tabview/TabView.tsx +++ b/apps/mobile/src/components/ui/tabview/TabView.tsx @@ -20,7 +20,6 @@ type Tab = { value: string } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export type TabComponent = FC< { isSelected: boolean; tab: Tab } & T & Pick > diff --git a/apps/mobile/src/components/ui/video/PlayerAction.tsx b/apps/mobile/src/components/ui/video/PlayerAction.tsx index ab5546d0bda..f77c01b9c6c 100644 --- a/apps/mobile/src/components/ui/video/PlayerAction.tsx +++ b/apps/mobile/src/components/ui/video/PlayerAction.tsx @@ -54,7 +54,7 @@ export function PlayerAction({ diff --git a/apps/mobile/src/hooks/useBackHandler.ts b/apps/mobile/src/hooks/useBackHandler.ts index 56e4ee54148..6be9a6f76f8 100644 --- a/apps/mobile/src/hooks/useBackHandler.ts +++ b/apps/mobile/src/hooks/useBackHandler.ts @@ -13,7 +13,6 @@ export const useBackHandler = () => { useEffect(() => { if (!isAndroid) return - // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- listener.remove() handles cleanup const listener = BackHandler.addEventListener("hardwareBackPress", () => { const lightboxWasActive = closeLightbox() if (lightboxWasActive) { diff --git a/apps/mobile/src/lib/auth-cookie-sync.test.ts b/apps/mobile/src/lib/auth-cookie-sync.test.ts new file mode 100644 index 00000000000..8ad27ed7195 --- /dev/null +++ b/apps/mobile/src/lib/auth-cookie-sync.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" + +import { extractSetCookieHeaderValues, mergeStoredAuthCookies } from "./auth-cookie-sync" + +describe("mobile auth cookie sync", () => { + it("keeps the two-factor cookie from a React Native style combined Set-Cookie header", () => { + const previousCookie = JSON.stringify({ + "better-auth.session_token": { + value: "old-session", + expires: null, + }, + }) + const combinedSetCookie = [ + "better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax", + "better-auth.session_data=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax", + "better-auth.two_factor=signed-two-factor; Max-Age=600; Path=/; HttpOnly; SameSite=Lax", + "better-auth.last_used_login_method=email; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax", + ].join(", ") + + const storedCookie = mergeStoredAuthCookies([combinedSetCookie], previousCookie) + const parsed = JSON.parse(storedCookie) + + expect(parsed["better-auth.session_token"]).toBeUndefined() + expect(parsed["better-auth.session_data"]).toBeUndefined() + expect(parsed["better-auth.two_factor"]?.value).toBe("signed-two-factor") + expect(parsed["better-auth.last_used_login_method"]?.value).toBe("email") + }) + + it("reads Set-Cookie values from standard and React Native header shapes", () => { + const headers = new Headers({ + "set-cookie": "better-auth.two_factor=signed-two-factor; Path=/; HttpOnly", + }) as Headers & { + _headers?: Record + } + headers._headers = { + "set-cookie": ["better-auth.last_used_login_method=email; Path=/; HttpOnly"], + } + + expect(extractSetCookieHeaderValues(headers)).toEqual([ + "better-auth.two_factor=signed-two-factor; Path=/; HttpOnly", + "better-auth.last_used_login_method=email; Path=/; HttpOnly", + ]) + }) + + it("ignores unrelated cookies", () => { + const storedCookie = mergeStoredAuthCookies( + ["analytics_id=third-party; Max-Age=60; Path=/"], + "{}", + ) + + expect(storedCookie).toBe("{}") + }) +}) diff --git a/apps/mobile/src/lib/auth-cookie-sync.ts b/apps/mobile/src/lib/auth-cookie-sync.ts new file mode 100644 index 00000000000..3c9894a9b12 --- /dev/null +++ b/apps/mobile/src/lib/auth-cookie-sync.ts @@ -0,0 +1,289 @@ +import type { BetterAuthClientPlugin } from "better-auth/client" +import { parseSetCookieHeader } from "better-auth/cookies" + +type AuthCookieStorage = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => unknown +} + +type StoredCookie = Record< + string, + { + expires: string | null + value: string + } +> + +const parseStoredCookie = (cookie: string | null | undefined): StoredCookie => { + if (!cookie) { + return {} + } + + try { + const parsed = JSON.parse(cookie) + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +const getCookieHeaderFromStoredCookie = (cookie: string) => + Object.entries(parseStoredCookie(cookie)).reduce((acc, [key, value]) => { + if (value.expires && new Date(value.expires) < new Date()) { + return acc + } + + return acc ? `${acc}; ${key}=${value.value}` : `${key}=${value.value}` + }, "") + +const mergeSetCookieHeader = (header: string, previousCookie?: string | null) => { + const parsed = parseSetCookieHeader(header) + const nextCookie = parseStoredCookie(previousCookie) + + parsed.forEach((cookie, key) => { + const expiresAt = cookie.expires + const maxAge = cookie["max-age"] + if (maxAge !== undefined && Number(maxAge) <= 0) { + delete nextCookie[key] + return + } + + const expires = maxAge + ? new Date(Date.now() + Number(maxAge) * 1000) + : expiresAt + ? new Date(String(expiresAt)) + : null + if (expires && expires.getTime() <= Date.now()) { + delete nextCookie[key] + return + } + + nextCookie[key] = { + value: cookie.value, + expires: expires ? expires.toISOString() : null, + } + }) + + return JSON.stringify(nextCookie) +} + +const normalizeCookieName = (name: string) => name.replace(/:/g, "_") + +const storageValueLimit = 1800 +const chunkMarker = "\u0001ba-chunks:" + +const createCookieStorage = (storage: AuthCookieStorage) => ({ + getItem(name: string) { + const key = normalizeCookieName(name) + const stored = storage.getItem(key) + if (!stored?.startsWith(chunkMarker)) { + return stored + } + + const count = Number(stored.slice(chunkMarker.length)) + if (!Number.isInteger(count) || count < 1) { + return null + } + + let value = "" + for (let index = 0; index < count; index++) { + const chunk = storage.getItem(`${key}.${index}`) + if (chunk == null) { + return null + } + value += chunk + } + + return value + }, + async setItem(name: string, value: string) { + const key = normalizeCookieName(name) + if (value.length <= storageValueLimit) { + await storage.setItem(key, value) + return + } + + await storage.setItem(key, "") + const count = Math.ceil(value.length / storageValueLimit) + for (let index = 0; index < count; index++) { + const start = index * storageValueLimit + await storage.setItem(`${key}.${index}`, value.slice(start, start + storageValueLimit)) + } + await storage.setItem(key, `${chunkMarker}${count}`) + }, +}) + +const managedAuthCookieNames = new Set([ + "two_factor", + "dont_remember", + "trust_device", + "better-auth.two_factor", + "better-auth.dont_remember", + "better-auth.trust_device", + "better-auth.session_token", + "better-auth.session_data", + "better-auth.last_used_login_method", + "__Secure-better-auth.two_factor", + "__Secure-better-auth.dont_remember", + "__Secure-better-auth.trust_device", + "__Secure-better-auth.session_token", + "__Secure-better-auth.session_data", +]) + +const getHeaderValues = (value: unknown): string[] => { + if (!value) { + return [] + } + + if (Array.isArray(value)) { + return value.flatMap(getHeaderValues) + } + + if (typeof value === "string") { + return value ? [value] : [] + } + + return [] +} + +const getHeaderObjectValue = (source: unknown) => { + if (!source || typeof source !== "object") { + return [] + } + + const record = source as Record + return [ + ...getHeaderValues(record["set-cookie"]), + ...getHeaderValues(record["Set-Cookie"]), + ...getHeaderValues(record["Set-cookie"]), + ] +} + +export const extractSetCookieHeaderValues = (headers: Headers) => { + const values = new Set() + const push = (value: unknown) => { + for (const header of getHeaderValues(value)) { + values.add(header) + } + } + + const withGetSetCookie = headers as Headers & { + getSetCookie?: () => string[] + } + if (typeof withGetSetCookie.getSetCookie === "function") { + push(withGetSetCookie.getSetCookie()) + } + + push(headers.get("set-cookie")) + + headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") { + push(value) + } + }) + + const internals = headers as Headers & { + _headers?: unknown + headers?: unknown + map?: unknown + } + + for (const source of [internals.map, internals._headers, internals.headers]) { + push(getHeaderObjectValue(source)) + } + + return Array.from(values) +} + +const getCookieName = (setCookieHeader: string) => { + const trimmed = setCookieHeader.trim() + const firstPart = trimmed.split(";", 1)[0] ?? "" + const equalsIndex = firstPart.indexOf("=") + if (equalsIndex <= 0) { + return null + } + + return firstPart.slice(0, equalsIndex) +} + +const isManagedAuthSetCookieHeader = (setCookieHeader: string) => { + const cookieName = getCookieName(setCookieHeader) + return cookieName ? managedAuthCookieNames.has(cookieName) : false +} + +export const mergeStoredAuthCookies = ( + setCookieHeaders: string[], + previousCookie: string | null | undefined, +) => { + let nextCookie = previousCookie || "{}" + + for (const setCookieHeader of setCookieHeaders) { + if (!isManagedAuthSetCookieHeader(setCookieHeader)) { + continue + } + nextCookie = mergeSetCookieHeader(setCookieHeader, nextCookie) + } + + return nextCookie +} + +export const createMobileAuthCookieSyncPlugin = ({ + cookieKey, + storage, +}: { + cookieKey: string + storage: AuthCookieStorage +}): BetterAuthClientPlugin => { + const cookieStorage = createCookieStorage(storage) + const getStoredCookieHeader = () => + getCookieHeaderFromStoredCookie(cookieStorage.getItem(cookieKey) || "{}") + + const syncResponseCookies = async (response: Response) => { + const setCookieHeaders = extractSetCookieHeaderValues(response.headers) + if (setCookieHeaders.length === 0) { + return + } + + const previousCookie = cookieStorage.getItem(cookieKey) + const nextCookie = mergeStoredAuthCookies(setCookieHeaders, previousCookie) + if (nextCookie !== (previousCookie || "{}")) { + await cookieStorage.setItem(cookieKey, nextCookie) + } + } + + return { + id: "mobile-auth-cookie-sync", + fetchPlugins: [ + { + id: "mobile-auth-cookie-sync", + name: "Mobile Auth Cookie Sync", + hooks: { + async onResponse(context) { + await syncResponseCookies(context.response) + }, + }, + async init(url, options) { + if (!url.includes("/two-factor/verify-totp")) { + return { url, options } + } + + const cookie = getStoredCookieHeader() + if (!cookie) { + return { url, options } + } + + return { + url, + options: { + ...options, + headers: { + ...options?.headers, + cookie, + }, + }, + } + }, + }, + ], + } +} diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index fb5da25e720..17736eea24a 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -1,10 +1,12 @@ import { expoClient } from "@better-auth/expo/client" +import type { BaseAuthPlugins } from "@follow/shared/auth" import { baseAuthPlugins } from "@follow/shared/auth" import { isNewUserQueryKey } from "@follow/store/user/constants" import { whoamiQueryKey } from "@follow/store/user/hooks" import { userActions } from "@follow/store/user/store" import { createMobileAPIHeaders } from "@follow/utils/headers" import { useQuery } from "@tanstack/react-query" +import type { BetterAuthClientOptions } from "better-auth/client" import { createAuthClient } from "better-auth/react" import { nativeApplicationVersion } from "expo-application" import * as FileSystem from "expo-file-system/legacy" @@ -14,6 +16,7 @@ import DeviceInfo from "react-native-device-info" import { getDbPath } from "@/src/database" +import { createMobileAuthCookieSyncPlugin } from "./auth-cookie-sync" import { getClientId, getSessionId } from "./client-session" import { getUserAgent } from "./native/user-agent" import { Navigation } from "./navigation/Navigation" @@ -51,70 +54,85 @@ const refreshSessionQueries = () => queryClient.resetQueries({ queryKey: ["owned", "lists"] }), ]) +type MobileAuthPlugins = [ + ...BaseAuthPlugins, + ReturnType, + ReturnType, +] +type MobileAuthClientOptions = Omit & { + plugins: MobileAuthPlugins +} + +const authCookieStorage = { + setItem(key: string, value: string) { + const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null + try { + safeSecureStore.setItem(key, value) + } catch (e) { + console.warn("SecureStore.setItem failed:", e) + return + } + + if (key === cookieKey) { + if (__DEV__) { + const env = getEnvProfile() + try { + safeSecureStore.setItem(`${cookieKey}_${env}`, value) + } catch { + // Keychain may be unavailable in background + } + } + bumpAuthStateRevision() + const authStateChanged = previousValue !== value + if (authStateChanged) { + void refreshSessionQueries() + } + } + }, + getItem(key: string) { + try { + return safeSecureStore.getItem(key) + } catch (e) { + console.warn("SecureStore.getItem failed:", e) + return null + } + }, + removeItem(key: string) { + const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null + try { + safeSecureStore.removeItem(key) + } catch (e) { + console.warn("SecureStore.removeItem failed:", e) + return + } + + if (key === cookieKey) { + if (__DEV__) { + const env = getEnvProfile() + safeSecureStore.removeItem(`${cookieKey}_${env}`) + } + bumpAuthStateRevision() + if (previousValue) { + void refreshSessionQueries() + } + } + }, +} + const plugins = [ ...baseAuthPlugins, + createMobileAuthCookieSyncPlugin({ + cookieKey, + storage: authCookieStorage, + }), expoClient({ scheme: "folo", storagePrefix, - storage: { - setItem(key: string, value: string) { - const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null - try { - safeSecureStore.setItem(key, value) - } catch (e) { - console.warn("SecureStore.setItem failed:", e) - return - } - - if (key === cookieKey) { - if (__DEV__) { - const env = getEnvProfile() - try { - safeSecureStore.setItem(`${cookieKey}_${env}`, value) - } catch { - // Keychain may be unavailable in background - } - } - bumpAuthStateRevision() - const authStateChanged = previousValue !== value - if (authStateChanged) { - void refreshSessionQueries() - } - } - }, - getItem(key: string) { - try { - return safeSecureStore.getItem(key) - } catch (e) { - console.warn("SecureStore.getItem failed:", e) - return null - } - }, - removeItem(key: string) { - const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null - try { - safeSecureStore.removeItem(key) - } catch (e) { - console.warn("SecureStore.removeItem failed:", e) - return - } - - if (key === cookieKey) { - if (__DEV__) { - const env = getEnvProfile() - safeSecureStore.removeItem(`${cookieKey}_${env}`) - } - bumpAuthStateRevision() - if (previousValue) { - void refreshSessionQueries() - } - } - }, - } as any, + storage: authCookieStorage, }), -] +] as MobileAuthPlugins -export const authClient = createAuthClient({ +export const authClient = createAuthClient({ baseURL: `${proxyEnv.API_URL}/better-auth`, sessionOptions: { refetchInterval: sessionCookieRefreshIntervalSeconds, diff --git a/apps/mobile/src/lib/loading.tsx b/apps/mobile/src/lib/loading.tsx index 0fe95f3b35d..1a6aedbfc1f 100644 --- a/apps/mobile/src/lib/loading.tsx +++ b/apps/mobile/src/lib/loading.tsx @@ -50,9 +50,9 @@ const LoadingContainer: FC<{ return ( {/* Pressable to prevent the overlay from being clicked */} - + - + {Platform.OS === "ios" ? ( ) : ( diff --git a/apps/mobile/src/lib/navigation/types.ts b/apps/mobile/src/lib/navigation/types.ts index b75586c9740..9e2e6ce3e54 100644 --- a/apps/mobile/src/lib/navigation/types.ts +++ b/apps/mobile/src/lib/navigation/types.ts @@ -35,6 +35,6 @@ export interface NavigationControllerViewExtraProps extends Pick< */ transparent?: boolean } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type + export type NavigationControllerView

= FC

& NavigationControllerViewExtraProps export type NavigationControllerViewType = StackPresentationTypes diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx index 6645dcf2181..85be7322e00 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx @@ -9,18 +9,9 @@ import { followClient } from "@/src/lib/api-client" import { useSearchPageContext } from "../ctx" import { ItemSeparator } from "./__base" import { useDataSkeleton } from "./hooks" -import type { SearchFeedCardItem } from "./SearchFeedCard" +import { resolveSearchFeedItems } from "./search-feed-items" import { SearchFeedCard } from "./SearchFeedCard" -const isDirectFeedInput = (value: string) => value.includes("://") - -const createDirectFeedItem = (value: string): SearchFeedCardItem => ({ - feed: { - title: value, - url: value, - }, -}) - export const SearchFeed = () => { const { t } = useTranslation("common") const { searchValueAtom } = useSearchPageContext() @@ -33,18 +24,12 @@ export const SearchFeed = () => { }, enabled: !!searchValue, }) - const skeleton = useDataSkeleton(isLoading, data) + const discoveredItems = data?.data ?? [] + const items = resolveSearchFeedItems(discoveredItems, searchValue) + const skeleton = useDataSkeleton(isLoading, data === undefined ? undefined : items.length) if (skeleton) return skeleton if (data === undefined) return null - const discoveredItems = data.data ?? [] - const items = - discoveredItems.length > 0 - ? discoveredItems - : searchValue && isDirectFeedInput(searchValue) - ? [createDirectFeedItem(searchValue)] - : [] - const resultCount = items.length const resultLabel = resultCount === 0 diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx index 6e27ac51f9d..9cd236eac9c 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchList.tsx @@ -36,7 +36,7 @@ export const SearchList = () => { queryFn: () => followClient.api.discover.discover({ keyword: searchValue, target: "lists" }), enabled: !!searchValue, }) - const skeleton = useDataSkeleton(isLoading, data) + const skeleton = useDataSkeleton(isLoading, data?.data.length) if (skeleton) return skeleton if (data === undefined) return null const resultCount = data.data?.length ?? 0 diff --git a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx index 36153c11087..406ea2ca088 100644 --- a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx @@ -9,7 +9,7 @@ import { useColor } from "@/src/theme/colors" import { BaseSearchPageRootView } from "./__base" -export const useDataSkeleton = (isLoading: boolean, data: any) => { +export const useDataSkeleton = (isLoading: boolean, itemCount: number | undefined) => { const { t } = useTranslation("common") const textColor = useColor("text") return useMemo(() => { @@ -20,7 +20,7 @@ export const useDataSkeleton = (isLoading: boolean, data: any) => { ) } - if (data?.data.length === 0) { + if (itemCount === 0) { return ( @@ -29,5 +29,5 @@ export const useDataSkeleton = (isLoading: boolean, data: any) => { ) } return null - }, [isLoading, data, t, textColor]) + }, [isLoading, itemCount, t, textColor]) } diff --git a/apps/mobile/src/modules/discover/search-tabs/search-feed-items.test.ts b/apps/mobile/src/modules/discover/search-tabs/search-feed-items.test.ts new file mode 100644 index 00000000000..7e0bde88991 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/search-feed-items.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest" + +import { resolveSearchFeedItems } from "./search-feed-items" + +describe("resolveSearchFeedItems", () => { + test.each(["folo://onboarding", "https://example.com/feed.xml"])( + "creates a direct feed item for %s when discovery is empty", + (url) => { + expect(resolveSearchFeedItems([], url)).toEqual([ + { + feed: { + title: url, + url, + }, + }, + ]) + }, + ) + + test("keeps an empty result for a keyword", () => { + expect(resolveSearchFeedItems([], "technology news")).toEqual([]) + }) + + test("prefers discovery results over a direct feed item", () => { + const discoveredItems = [ + { + feed: { + id: "feed-id", + title: "Discovered feed", + url: "https://example.com/discovered.xml", + }, + }, + ] + + expect(resolveSearchFeedItems(discoveredItems, "https://example.com/feed.xml")).toEqual( + discoveredItems, + ) + }) +}) diff --git a/apps/mobile/src/modules/discover/search-tabs/search-feed-items.ts b/apps/mobile/src/modules/discover/search-tabs/search-feed-items.ts new file mode 100644 index 00000000000..273f6329434 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/search-feed-items.ts @@ -0,0 +1,25 @@ +import type { SearchFeedCardItem } from "./SearchFeedCard" + +const isDirectFeedInput = (value: string) => value.includes("://") + +export const resolveSearchFeedItems = ( + discoveredItems: readonly T[], + searchValue: string, +): readonly (T | SearchFeedCardItem)[] => { + if (discoveredItems.length > 0) { + return discoveredItems + } + + if (!searchValue || !isDirectFeedInput(searchValue)) { + return [] + } + + return [ + { + feed: { + title: searchValue, + url: searchValue, + }, + }, + ] +} diff --git a/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts b/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts index 9f34709a3ea..c50e6220204 100644 --- a/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts +++ b/apps/mobile/src/modules/entry-content/pull-up-navigation/types.ts @@ -1,6 +1,6 @@ import type { FC, PropsWithChildren } from "react" import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" -import type { ComposedGesture, GestureType } from "react-native-gesture-handler" +import type { GestureType, LegacyComposedGesture } from "react-native-gesture-handler" import type { SharedValue } from "react-native-reanimated" import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" @@ -18,7 +18,7 @@ export interface UsePullUpToNextProps { interface GestureWrapperProps { enabled?: boolean - gesture?: ComposedGesture | GestureType + gesture?: GestureType | LegacyComposedGesture } export interface UsePullUpToNextReturn { diff --git a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx index 2c4080ec191..35a67162d4b 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx @@ -50,7 +50,7 @@ export const EntryListContentSocial = ({ const ref = useRef>(null) useImperativeHandle(forwardRef, () => ref.current!) - // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback + // eslint-disable-next-line react/no-unnecessary-use-callback const renderItem = useCallback( ({ item: id, extraData }: ListRenderItemInfo) => ( diff --git a/apps/mobile/src/modules/feed/FollowFeed.tsx b/apps/mobile/src/modules/feed/FollowFeed.tsx index b8ec45b1d3c..d993d6f0649 100644 --- a/apps/mobile/src/modules/feed/FollowFeed.tsx +++ b/apps/mobile/src/modules/feed/FollowFeed.tsx @@ -43,6 +43,8 @@ const formSchema = z.object({ hideFromTimeline: z.boolean().optional(), title: z.string().optional(), }) +type FollowFeedFormInput = z.input +type FollowFeedFormOutput = z.output export function FollowFeed(props: { id: string }) { const { id } = props const feed = useFeedById(id as string) @@ -93,7 +95,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { view: subscription?.view ?? defaultView, } }, [subscription, defaultView]) - const form = useForm>({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: defaultFormValues, }) @@ -105,10 +107,9 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { const [isLoading, setIsLoading] = useState(false) const navigate = useNavigation() const canDismiss = useCanDismiss() - const submit = async () => { + const submit = async (values: FollowFeedFormOutput) => { if (isLoading) return setIsLoading(true) - const values = form.getValues() const body: SubscriptionForm = { url: feed?.url, view: values.view, @@ -331,7 +332,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { name="view" control={form.control} render={({ field: { onChange, value } }) => ( - + )} /> diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index 6829b8dda74..7387976d8bd 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -63,7 +63,6 @@ async function signInWithEmail( throw new Error(res.error.message) } - // @ts-expect-error better-auth response type omits twoFactorRedirect if (res.data?.twoFactorRedirect) { Navigation.rootNavigation.presentControllerView(TwoFactorAuthScreen) return false diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index 4fbba659444..1f3726a0181 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -10,17 +10,16 @@ import DeviceInfo from "react-native-device-info" import { Image } from "@/src/components/ui/image/Image" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" import { Text } from "@/src/components/ui/typography/Text" +import type { AuthProvider } from "@/src/lib/auth" import { signIn, useAuthProviders } from "@/src/lib/auth" import { loginWithSocialProvider } from "./social-login" -type SocialProviderSignInInput = Parameters[0] - export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmail: () => void }) { const { data: authProviders, isLoading } = useAuthProviders() const { colorScheme } = useColorScheme() const [pendingProviderId, setPendingProviderId] = useState(null) - const providers = Object.entries(authProviders || {}) + const providers = Object.entries((authProviders || {}) as Record) const credentialProvider = providers.find(([, provider]) => provider.id === "credential")?.[1] const socialProviders = providers.filter(([, provider]) => { if (provider.id === "credential") return false @@ -80,7 +79,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai setPendingProviderId, signInWithProvider: async (providerId, { callbackURL }) => { await signIn.social({ - provider: providerId as SocialProviderSignInInput["provider"], + provider: providerId as any, callbackURL, }) }, diff --git a/apps/mobile/src/modules/player/GlassPlayerTabBar.tsx b/apps/mobile/src/modules/player/GlassPlayerTabBar.tsx index 704ca0fc6d2..e9b3b5d41cc 100644 --- a/apps/mobile/src/modules/player/GlassPlayerTabBar.tsx +++ b/apps/mobile/src/modules/player/GlassPlayerTabBar.tsx @@ -65,6 +65,6 @@ export function GlassPlayerTabBar({ className }: { className?: string }) { const styles = StyleSheet.create({ glass: { borderRadius: 99, - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, }) diff --git a/apps/mobile/src/modules/player/TtsStreamProvider.tsx b/apps/mobile/src/modules/player/TtsStreamProvider.tsx index d4e138ac674..987b5948090 100644 --- a/apps/mobile/src/modules/player/TtsStreamProvider.tsx +++ b/apps/mobile/src/modules/player/TtsStreamProvider.tsx @@ -1,12 +1,13 @@ import { useEffect, useRef } from "react" import { StyleSheet, View } from "react-native" +import type { WebViewProps } from "react-native-webview" import { WebView } from "react-native-webview" import { ttsStreamController } from "./tts-stream-controller" import { TTS_STREAM_WEBVIEW_HTML } from "./tts-stream-webview-html" export const TtsStreamProvider = () => { - const webViewRef = useRef(null) + const webViewRef = useRef>(null) useEffect(() => { ttsStreamController.attachWebView(webViewRef.current) @@ -18,7 +19,7 @@ export const TtsStreamProvider = () => { return ( - ref={webViewRef} allowsInlineMediaPlayback androidLayerType="software" diff --git a/apps/mobile/src/modules/player/tts-stream-controller.ts b/apps/mobile/src/modules/player/tts-stream-controller.ts index 74fb83553ef..7afbe71c38b 100644 --- a/apps/mobile/src/modules/player/tts-stream-controller.ts +++ b/apps/mobile/src/modules/player/tts-stream-controller.ts @@ -1,4 +1,8 @@ -import type { WebView as WebViewType, WebViewMessageEvent } from "react-native-webview" +import type { + WebView as WebViewType, + WebViewMessageEvent, + WebViewProps, +} from "react-native-webview" export type TtsPlaybackStatus = "idle" | "loading" | "paused" | "playing" @@ -55,9 +59,9 @@ class TtsStreamController { private queuedCommands: string[] = [] private ready = false private readyWaiters = new Set<() => void>() - private webView: WebViewType | null = null + private webView: WebViewType | null = null - attachWebView = (webView: WebViewType | null) => { + attachWebView = (webView: WebViewType | null) => { this.webView = webView if (!webView) { this.ready = false diff --git a/apps/mobile/src/modules/review-prompt/provider.tsx b/apps/mobile/src/modules/review-prompt/provider.tsx index 5fabcac99bc..5a78c4f5b38 100644 --- a/apps/mobile/src/modules/review-prompt/provider.tsx +++ b/apps/mobile/src/modules/review-prompt/provider.tsx @@ -91,7 +91,7 @@ export const ReviewPromptProvider = () => { recordActiveDay() // React Native AppState subscriptions are cleaned up with subscription.remove(). - // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener + const subscription = AppState.addEventListener("change", handleAppStateChange) return () => { diff --git a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx index a119411ebb9..10b1e8ad4ec 100644 --- a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx +++ b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx @@ -201,14 +201,10 @@ export const UserHeaderBanner = ({ x: 1, y: 1, }} - style={StyleSheet.absoluteFillObject} + style={StyleSheet.absoluteFill} /> {gradientColors && ( - + )} diff --git a/apps/mobile/src/modules/settings/components/OTPWindow.tsx b/apps/mobile/src/modules/settings/components/OTPWindow.tsx index 027dba92651..1d86a199130 100644 --- a/apps/mobile/src/modules/settings/components/OTPWindow.tsx +++ b/apps/mobile/src/modules/settings/components/OTPWindow.tsx @@ -105,7 +105,7 @@ export const OTPWindow = ({ onSuccess, verifyFn, onDismiss }: OTPWindowProps { - return useQuery({ + return useQuery({ queryKey: accountInfoKey, - queryFn: () => getAccountInfo(), + queryFn: async () => (await getAccountInfo()) as AccountInfoResponse, }) } export const AccountScreen = () => { @@ -145,7 +153,7 @@ const AccountLinker: FC<{ onPress={() => { if (!account) { linkSocial({ provider: provider as any }) - .then((res) => { + .then((res: LinkSocialResponse) => { if (!res.data?.url) { toast.error(t("profile.link_social.link_failed")) return @@ -159,7 +167,7 @@ const AccountLinker: FC<{ }) }) }) - .catch((error) => { + .catch((error: unknown) => { toast.error( error instanceof Error ? error.message : t("profile.link_social.link_failed"), ) @@ -196,7 +204,9 @@ const AuthenticationSection = () => { const providerToAccountMap = useMemo(() => { const providerMap: Record = {} for (const provider of Object.keys(providers || {})) { - providerMap[provider] = accounts?.data?.find((account) => account.provider === provider) + providerMap[provider] = accounts?.data?.find( + (account: Account) => account.provider === provider, + ) } return providerMap }, [accounts?.data, providers]) @@ -224,7 +234,7 @@ const AuthenticationSection = () => { const SecuritySection = () => { const { t } = useTranslation(["settings", "common"]) const { data: account } = useAccount() - const hasPassword = account?.data?.find((account) => account.provider === "credential") + const hasPassword = account?.data?.find((account: Account) => account.provider === "credential") const whoAmI = useWhoami() const twoFactorEnabled = whoAmI?.twoFactorEnabled const navigation = useNavigation() diff --git a/apps/mobile/src/modules/settings/routes/Plan.tsx b/apps/mobile/src/modules/settings/routes/Plan.tsx index 4d5e599a2f9..b6a2e8bf13b 100644 --- a/apps/mobile/src/modules/settings/routes/Plan.tsx +++ b/apps/mobile/src/modules/settings/routes/Plan.tsx @@ -4,7 +4,7 @@ import { cn } from "@follow/utils" import type { StatusConfigs } from "@follow-app/client-sdk" import { useMutation, useQuery } from "@tanstack/react-query" import dayjs from "dayjs" -import type { SubscriptionProduct } from "expo-iap" +import type { ProductSubscription } from "expo-iap" import { openURL } from "expo-linking" import type { TFunction } from "i18next" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -333,8 +333,8 @@ export const PlanScreen: NavigationControllerView = () => { ) const appleProductsById = useMemo( () => - new Map( - appleSubscriptions.map((product: SubscriptionProduct) => [product.id, product]), + new Map( + appleSubscriptions.map((product: ProductSubscription) => [product.id, product]), ), [appleSubscriptions], ) diff --git a/apps/mobile/src/providers/AppleIAPProvider.tsx b/apps/mobile/src/providers/AppleIAPProvider.tsx index a5b75cec045..da6fdc21398 100644 --- a/apps/mobile/src/providers/AppleIAPProvider.tsx +++ b/apps/mobile/src/providers/AppleIAPProvider.tsx @@ -1,8 +1,8 @@ import { whoamiQueryKey } from "@follow/store/user/hooks" import { userSyncService } from "@follow/store/user/store" import { requireNativeModule } from "expo" -import type { ProductPurchase, SubscriptionProduct } from "expo-iap" -import { getTransactionJws, useIAP } from "expo-iap" +import type { ProductSubscription, Purchase } from "expo-iap" +import { ErrorCode, getTransactionJwsIOS, useIAP } from "expo-iap" import type { PropsWithChildren } from "react" import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -26,9 +26,14 @@ type BillingSubscriptionResponse = { canManage: boolean } +type IAPPurchaseError = { + code?: ErrorCode | string + message?: string +} + type AppleIAPContextValue = { connected: boolean - subscriptions: SubscriptionProduct[] + subscriptions: ProductSubscription[] isPurchasing: boolean isProcessingPurchase: boolean isRestoring: boolean @@ -66,11 +71,13 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { return ids }, [serverConfigs?.PAYMENT_PLAN_LIST]) - const availablePurchasesRef = useRef([]) + const availablePurchasesRef = useRef([]) const processedTransactionsRef = useRef(new Set()) const [isPurchasing, setIsPurchasing] = useState(false) const [isProcessingPurchase, setIsProcessingPurchase] = useState(false) const [isRestoring, setIsRestoring] = useState(false) + const [currentPurchase, setCurrentPurchase] = useState(null) + const [currentPurchaseError, setCurrentPurchaseError] = useState(null) const storeKitTestHelper = useMemo(() => { if (Platform.OS !== "ios" || !proxyEnv.API_URL.startsWith("http://localhost")) { @@ -95,15 +102,19 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { connected, subscriptions, availablePurchases, - currentPurchase, - currentPurchaseError, + fetchProducts, finishTransaction, - getSubscriptions, requestPurchase, restorePurchases, validateReceipt, } = useIAP({ - shouldAutoSyncPurchases: false, + onPurchaseError: (error) => { + setCurrentPurchaseError({ + code: error.code, + message: error.message, + }) + }, + onPurchaseSuccess: setCurrentPurchase, }) useEffect(() => { @@ -111,15 +122,13 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { }, [availablePurchases]) const verifyPurchase = useCallback( - async (purchase: ProductPurchase) => { + async (purchase: Purchase) => { const productId = purchase.id - const signedTransactionInfoFromPurchase = - "jwsRepresentationIos" in purchase ? purchase.jwsRepresentationIos : undefined const jwsRepresentation = - signedTransactionInfoFromPurchase || - (await getTransactionJws(productId).catch(() => null)) || - (await validateReceipt(productId) - .then((result) => result?.jwsRepresentation as string | undefined) + purchase.purchaseToken || + (await getTransactionJwsIOS(productId).catch(() => null)) || + (await validateReceipt({ apple: { sku: productId } }) + .then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined)) .catch(() => {})) if (!jwsRepresentation) { @@ -154,8 +163,8 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { const transactionKey = currentPurchase.transactionId || - ("originalTransactionIdentifierIos" in currentPurchase - ? currentPurchase.originalTransactionIdentifierIos + ("originalTransactionIdentifierIOS" in currentPurchase + ? currentPurchase.originalTransactionIdentifierIOS : undefined) || `${currentPurchase.id}:${currentPurchase.transactionDate}` @@ -191,7 +200,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { setIsPurchasing(false) setIsProcessingPurchase(false) - if (currentPurchaseError.code === "E_USER_CANCELLED") { + if (currentPurchaseError.code === ErrorCode.UserCancelled) { return } @@ -204,9 +213,9 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { return } - await getSubscriptions(skus) + await fetchProducts({ skus, type: "subs" }) }, - [getSubscriptions], + [fetchProducts], ) const requestSubscriptionPurchase = useCallback( @@ -245,9 +254,11 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { await requestPurchase({ type: "subs", request: { - sku, - appAccountToken: appAccountToken ?? undefined, - andDangerouslyFinishTransactionAutomaticallyIOS: false, + apple: { + sku, + appAccountToken: appAccountToken ?? undefined, + andDangerouslyFinishTransactionAutomatically: false, + }, }, }) } catch (error) { @@ -278,11 +289,11 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { const sortedPurchases = [...restoredPurchases].sort((left, right) => { const leftExpires = - ("expirationDateIos" in left ? left.expirationDateIos : undefined) ?? + ("expirationDateIOS" in left ? left.expirationDateIOS : undefined) ?? left.transactionDate ?? 0 const rightExpires = - ("expirationDateIos" in right ? right.expirationDateIos : undefined) ?? + ("expirationDateIOS" in right ? right.expirationDateIOS : undefined) ?? right.transactionDate ?? 0 return rightExpires - leftExpires diff --git a/apps/mobile/src/providers/index.tsx b/apps/mobile/src/providers/index.tsx index a41c088ff60..960391ed24d 100644 --- a/apps/mobile/src/providers/index.tsx +++ b/apps/mobile/src/providers/index.tsx @@ -25,7 +25,6 @@ import { FontScalingProvider } from "./FontScalingProvider" import { MigrationProvider } from "./migration" import { ServerConfigsLoader } from "./ServerConfigsLoader" -/* eslint-disable @eslint-react/no-missing-key */ const contexts = [ , , diff --git a/apps/mobile/src/screens/(modal)/ListScreen.tsx b/apps/mobile/src/screens/(modal)/ListScreen.tsx index f55271d85bb..1dc480f2f3c 100644 --- a/apps/mobile/src/screens/(modal)/ListScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ListScreen.tsx @@ -41,19 +41,21 @@ const listSchema = z.object({ .transform((val) => (val === "" ? null : val)), view: z.number().int(), }) +type ListFormInput = z.input +type ListFormOutput = z.output const defaultValues = { title: "", description: null, image: null, view: FeedViewType.Articles, -} satisfies z.infer +} satisfies ListFormInput export const ListScreen: NavigationControllerView<{ listId?: string }> = ({ listId }) => { const { t } = useTranslation("settings") const list = useListById(listId || "") - const form = useForm>({ + const form = useForm({ defaultValues: list ? { title: list.title ?? "", diff --git a/apps/mobile/src/screens/(modal)/TwoFactorAuthScreen.tsx b/apps/mobile/src/screens/(modal)/TwoFactorAuthScreen.tsx index 266d3436a11..a685a8049fb 100644 --- a/apps/mobile/src/screens/(modal)/TwoFactorAuthScreen.tsx +++ b/apps/mobile/src/screens/(modal)/TwoFactorAuthScreen.tsx @@ -22,6 +22,7 @@ export const TwoFactorAuthScreen: NavigationControllerView = () => { const label = useColor("label") const tertiaryLabel = useColor("tertiaryLabel") const otpInputRef = useRef(null) + const submittedCodeRef = useRef(null) const navigation = useNavigation() const submitMutation = useMutation({ mutationFn: async (value: string) => { @@ -36,12 +37,21 @@ export const TwoFactorAuthScreen: NavigationControllerView = () => { }) }, onError(error) { + submittedCodeRef.current = null toast.error(`Failed to verify: ${error.message}`) }, onSuccess() { navigation.popToRoot() }, }) + const submitCode = (code: string) => { + if (!isAuthCodeValid(code)) return + if (submitMutation.isPending) return + if (submittedCodeRef.current === code) return + + submittedCodeRef.current = code + submitMutation.mutate(code) + } return ( { borderColor: accentColor, }, }} - onFilled={(code) => { - if (isAuthCodeValid(code)) { - submitMutation.mutate(code) - } - }} + onTextChange={submitCode} + onFilled={submitCode} /> diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx index cf02efca9cf..26a9280054e 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/FeedScreen.tsx @@ -50,7 +50,7 @@ export const FeedScreen: NavigationControllerView<{ > {t("words.follow")} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 7c92b860624..b1be1f97486 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -6,7 +6,6 @@ "skipLibCheck": true, "noImplicitOverride": true, "allowImportingTsExtensions": true, - "baseUrl": ".", "paths": { "@/*": ["./*"], "@locales/*": ["../../locales/*"] diff --git a/apps/mobile/web-app/html-renderer/package.json b/apps/mobile/web-app/html-renderer/package.json index c2329e88202..0a873e39d69 100644 --- a/apps/mobile/web-app/html-renderer/package.json +++ b/apps/mobile/web-app/html-renderer/package.json @@ -9,32 +9,35 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@emotion/is-prop-valid": "1.3.1", + "@emotion/is-prop-valid": "1.4.0", "@follow/components": "workspace:*", "@follow/configs": "workspace:*", "@follow/hooks": "workspace:*", "@follow/shared": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", - "@shikijs/transformers": "3.22.0", + "@shikijs/transformers": "4.3.1", "clsx": "2.1.1", - "foxact": "0.2.52", - "jotai": "2.17.1", - "katex": "0.16.28", - "motion": "12.34.0", - "react": "19.0.0", + "foxact": "0.3.8", + "jotai": "2.20.1", + "katex": "0.17.0", + "motion": "12.42.2", + "react": "19.2.7", "react-blurhash": "0.3.0", - "react-dom": "19.0.0", - "shiki": "3.22.0", + "react-dom": "19.2.7", + "shiki": "4.3.1", "usehooks-ts": "3.1.1" }, "devDependencies": { - "@types/hast": "3.0.4", - "@vitejs/plugin-react": "5.1.4", + "@tailwindcss/postcss": "4.3.2", + "@types/hast": "3.0.5", + "@vitejs/plugin-react": "6.0.3", + "autoprefixer": "10.5.2", "hast-util-to-jsx-runtime": "2.3.6", "pathe": "2.0.3", + "tailwindcss": "4.3.2", "typescript": "catalog:", - "vite": "7.3.1", - "vitest": "3.2.4" + "vite": "8.1.4", + "vitest": "4.1.10" } } diff --git a/apps/mobile/web-app/html-renderer/postcss.config.cjs b/apps/mobile/web-app/html-renderer/postcss.config.cjs index 6b436fea45f..343fc7a5d02 100644 --- a/apps/mobile/web-app/html-renderer/postcss.config.cjs +++ b/apps/mobile/web-app/html-renderer/postcss.config.cjs @@ -1,8 +1,6 @@ module.exports = { plugins: { - tailwindcss: { - config: "./tailwind.config.ts", - }, + "@tailwindcss/postcss": {}, autoprefixer: {}, }, } diff --git a/apps/mobile/web-app/html-renderer/src/components/shiki/shared.ts b/apps/mobile/web-app/html-renderer/src/components/shiki/shared.ts index 0b071f67160..771418866a1 100644 --- a/apps/mobile/web-app/html-renderer/src/components/shiki/shared.ts +++ b/apps/mobile/web-app/html-renderer/src/components/shiki/shared.ts @@ -4,7 +4,8 @@ import { transformerNotationHighlight, } from "@shikijs/transformers" import type { ShikiTransformer } from "shiki" -import { createHighlighterCoreSync, createJavaScriptRegexEngine } from "shiki" +import { createHighlighterCoreSync } from "shiki/core" +import { createJavaScriptRegexEngine } from "shiki/engine/javascript" export const shikiTransformers: ShikiTransformer[] = [ transformerMetaHighlight(), diff --git a/apps/mobile/web-app/html-renderer/src/components/shiki/shiki.module.css b/apps/mobile/web-app/html-renderer/src/components/shiki/shiki.module.css index 318354cc312..b12f00944d4 100644 --- a/apps/mobile/web-app/html-renderer/src/components/shiki/shiki.module.css +++ b/apps/mobile/web-app/html-renderer/src/components/shiki/shiki.module.css @@ -1,3 +1,5 @@ +@reference "../../index.css"; + .shiki-wrapper { @apply overflow-hidden rounded-md; diff --git a/apps/mobile/web-app/html-renderer/src/index.css b/apps/mobile/web-app/html-renderer/src/index.css index e69de29bb2d..6f6f9682851 100644 --- a/apps/mobile/web-app/html-renderer/src/index.css +++ b/apps/mobile/web-app/html-renderer/src/index.css @@ -0,0 +1,6 @@ +@import "tailwindcss" source(none); +@import "@follow/components/assets/colors-media.css"; +@import "@follow/components/assets/tailwind.css"; +@config "../tailwind.config.ts"; +@source "./"; +@source "../../../../../packages"; diff --git a/apps/mobile/web-app/html-renderer/src/index.ts b/apps/mobile/web-app/html-renderer/src/index.ts index 2dbbd52a282..ce12877ac27 100644 --- a/apps/mobile/web-app/html-renderer/src/index.ts +++ b/apps/mobile/web-app/html-renderer/src/index.ts @@ -1,5 +1,3 @@ -import "@follow/components/assets/colors-media.css" -import "@follow/components/assets/tailwind.css" import "./index.css" import { createElement } from "react" diff --git a/apps/mobile/web-app/html-renderer/vite.config.mts b/apps/mobile/web-app/html-renderer/vite.config.mts index 8071376f921..d9301e598e7 100644 --- a/apps/mobile/web-app/html-renderer/vite.config.mts +++ b/apps/mobile/web-app/html-renderer/vite.config.mts @@ -8,9 +8,19 @@ export default defineConfig({ outDir: path.resolve(import.meta.dirname, "../../../../out/rn-web/html-renderer"), }, resolve: { - alias: { - "~": path.resolve(__dirname, "./src"), - }, + alias: [ + { + find: /^tailwindcss$/, + replacement: path.resolve( + import.meta.dirname, + "../../../../node_modules/tailwindcss/index.css", + ), + }, + { + find: "~", + replacement: path.resolve(__dirname, "./src"), + }, + ], }, define: { ELECTRON: "false", diff --git a/apps/ota/package.json b/apps/ota/package.json index 094f6ebee9a..edc9131b2c5 100644 --- a/apps/ota/package.json +++ b/apps/ota/package.json @@ -11,16 +11,16 @@ }, "dependencies": { "fzstd": "0.1.1", - "hono": "4.12.1", + "hono": "4.12.28", "ofetch": "1.5.1", - "tar-stream": "2.2.0", - "zod": "3.25.76" + "tar-stream": "3.2.0", + "zod": "4.4.3" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260405.0", + "@cloudflare/workers-types": "5.20260708.1", "@types/tar-stream": "3.1.4", "typescript": "catalog:", - "vitest": "3.2.4", - "wrangler": "4.67.0" + "vitest": "4.1.10", + "wrangler": "4.107.1" } } diff --git a/apps/ota/src/lib/schema.ts b/apps/ota/src/lib/schema.ts index 06a253a85ce..e7db36b0c14 100644 --- a/apps/ota/src/lib/schema.ts +++ b/apps/ota/src/lib/schema.ts @@ -100,7 +100,9 @@ const desktopPolicySchema = z.object({ required: z.boolean(), minSupportedBinaryVersion: semver, message: z.string().nullable(), - distributions: z.record(desktopDistributionSchema, desktopPolicyDistributionSchema).default({}), + distributions: z + .partialRecord(desktopDistributionSchema, desktopPolicyDistributionSchema) + .default({}), }) const desktopReleaseInputSchema = z diff --git a/apps/ota/tsconfig.json b/apps/ota/tsconfig.json index d31d94bef87..afa3e6e22dd 100644 --- a/apps/ota/tsconfig.json +++ b/apps/ota/tsconfig.json @@ -6,8 +6,7 @@ "moduleResolution": "Bundler", "lib": ["ES2022", "WebWorker"], "types": ["@cloudflare/workers-types", "vitest/globals"], - "jsx": "react-jsx", - "baseUrl": "." + "jsx": "react-jsx" }, "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/apps/ssr/client/components/items/picture.tsx b/apps/ssr/client/components/items/picture.tsx index af45e46ffb5..23b28e7998f 100644 --- a/apps/ssr/client/components/items/picture.tsx +++ b/apps/ssr/client/components/items/picture.tsx @@ -178,7 +178,7 @@ export const PictureList: FC<{

{isInitLayout && ( - {/* eslint-disable-next-line @eslint-react/no-context-provider */} + {}
diff --git a/apps/ssr/client/index.tsx b/apps/ssr/client/index.tsx index 7ab06cfcd90..5958df14430 100644 --- a/apps/ssr/client/index.tsx +++ b/apps/ssr/client/index.tsx @@ -1,4 +1,3 @@ -import "@follow/components/tailwind" import "@follow/components/dayjs" import "./styles/index.css" diff --git a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx index 1df3324b324..cbb3ef9fc16 100644 --- a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx +++ b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx @@ -119,7 +119,7 @@ const UserHero = ({ user }: { user: UserProfile }) => {
- + {user.name?.slice(0, 2)} @@ -278,7 +278,7 @@ const Subscriptions = ({ userId }: { userId: string }) => { {category}
- + {subscriptions.data?.[category]?.length || 0} > + +type DaisyUIOptions = { + darkTheme?: string + logs?: boolean + themes?: DaisyUITheme[] +} + +type TailwindConfigWithDaisyUI = Config & { + daisyui?: DaisyUIOptions +} + /** @type {import('tailwindcss').Config} */ -export default withUIKit( - extendConfig({ - content: [ - "./client/**/*.{ts,tsx}", - "./index.html", - "./node_modules/@follow/components/**/*.{ts,tsx}", - "./node_modules/rc-modal-sheet/**/*.{js,ts,tsx}", - "../../node_modules/rc-modal-sheet/**/*.{js,ts,tsx}", - "../../packages/**/*.{ts,tsx}", - ], - plugins: [daisyui as unknown as NonNullable[number]], - daisyui: { - logs: false, - darkTheme: "dark", - themes: [ - { - light: { - "color-scheme": "light", +const config: TailwindConfigWithDaisyUI = { + content: [ + "./client/**/*.{ts,tsx}", + "./index.html", + "./node_modules/@follow/components/**/*.{ts,tsx}", + "./node_modules/rc-modal-sheet/**/*.{js,ts,tsx}", + "../../node_modules/rc-modal-sheet/**/*.{js,ts,tsx}", + "../../packages/**/*.{ts,tsx}", + ], + plugins: [daisyui], + daisyui: { + logs: false, + darkTheme: "dark", + themes: [ + { + light: { + "color-scheme": "light", - primary: "#007AFF", //#0A84FF - accent: "#FF5C00", - "accent-content": "#fff", - neutral: "#212427", + primary: "#007AFF", //#0A84FF + accent: "#FF5C00", + "accent-content": "#fff", + neutral: "#212427", - "base-100": "#fff", - "base-content": "#0C0A09", + "base-100": "#fff", + "base-content": "#0C0A09", - info: "#32ADE6", // 50 173 230 - success: "#34C759", - warning: "#ff9f0a", - error: "#ff453a", // 255 59 48 + info: "#32ADE6", // 50 173 230 + success: "#34C759", + warning: "#ff9f0a", + error: "#ff453a", // 255 59 48 - "--rounded-btn": "1.9rem", - "--tab-border": "2px", - "--tab-radius": ".5rem", - }, - dark: { - "color-scheme": "dark", + "--rounded-btn": "1.9rem", + "--tab-border": "2px", + "--tab-radius": ".5rem", + }, + dark: { + "color-scheme": "dark", - primary: "#0A84FF", - accent: "#FF5C00", - "accent-content": "#fff", - neutral: "#2a2a2a", - "base-100": "#121212", - "base-content": "#FAFAF9", + primary: "#0A84FF", + accent: "#FF5C00", + "accent-content": "#fff", + neutral: "#2a2a2a", + "base-100": "#121212", + "base-content": "#FAFAF9", - info: "#32ADE6", // 50 173 230 - success: "#34C759", - warning: "#ff9f0a", - error: "#ff453a", // 255 59 48 + info: "#32ADE6", // 50 173 230 + success: "#34C759", + warning: "#ff9f0a", + error: "#ff453a", // 255 59 48 - "--rounded-btn": "1.9rem", - "--tab-border": "2px", - "--tab-radius": ".5rem", - }, + "--rounded-btn": "1.9rem", + "--tab-border": "2px", + "--tab-radius": ".5rem", }, - ], - }, - }), -) + }, + ], + }, +} + +export default withUIKit(extendConfig(config)) diff --git a/apps/ssr/tsconfig.json b/apps/ssr/tsconfig.json index 5ee15d7bc57..caa57fee6e9 100644 --- a/apps/ssr/tsconfig.json +++ b/apps/ssr/tsconfig.json @@ -36,6 +36,7 @@ "tsdown.config.ts", "./client/**/*.ts", "./client/**/*.tsx", + "./types/**/*.d.ts", "vite.config.mts", "./tailwind.config.ts", "./helper/**/*.ts" diff --git a/apps/ssr/tsdown.config.ts b/apps/ssr/tsdown.config.ts index 00ae25fec56..addebe2d0f7 100644 --- a/apps/ssr/tsdown.config.ts +++ b/apps/ssr/tsdown.config.ts @@ -10,8 +10,10 @@ export default defineConfig({ clean: true, format: ["esm"], - external: ["lightningcss", "vite"], - inlineOnly: false, + deps: { + neverBundle: ["lightningcss", "vite"], + onlyBundle: false, + }, treeshake: true, define: { diff --git a/apps/ssr/tsdown.worker.config.ts b/apps/ssr/tsdown.worker.config.ts index a3ce6d0b0ce..6a6b1f5eebe 100644 --- a/apps/ssr/tsdown.worker.config.ts +++ b/apps/ssr/tsdown.worker.config.ts @@ -9,9 +9,11 @@ export default defineConfig({ clean: true, format: ["esm"], - external: ["node:*", /\.wasm$/], - noExternal: ["**"], - inlineOnly: false, + deps: { + neverBundle: ["node:*", /\.wasm$/], + alwaysBundle: ["**"], + onlyBundle: false, + }, treeshake: true, splitting: false, diff --git a/apps/ssr/types/daisyui.d.ts b/apps/ssr/types/daisyui.d.ts new file mode 100644 index 00000000000..ec7fbe2b3f6 --- /dev/null +++ b/apps/ssr/types/daisyui.d.ts @@ -0,0 +1,6 @@ +declare module "daisyui" { + import type { Config } from "tailwindcss" + + const daisyui: NonNullable[number] + export default daisyui +} diff --git a/docs/superpowers/plans/2026-04-10-ota.md b/docs/superpowers/plans/2026-04-10-ota.md index 8ce21abde9d..15bbfd15c1f 100644 --- a/docs/superpowers/plans/2026-04-10-ota.md +++ b/docs/superpowers/plans/2026-04-10-ota.md @@ -1800,9 +1800,9 @@ Expected: ## Manual verification commands curl -H "expo-platform: ios" \ - -H "expo-runtime-version: 0.4.1" \ - -H "expo-channel-name: production" \ - https://ota.folo.is/manifest +-H "expo-runtime-version: 0.4.1" \ +-H "expo-channel-name: production" \ +https://ota.folo.is/manifest curl "https://ota.folo.is/policy?product=mobile&channel=production&installedBinaryVersion=0.4.1" ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 7844a00c409..388a12d1f08 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,6 @@ // @ts-check import { fixupPluginRules } from "@eslint/compat" +import stylistic from "@stylistic/eslint-plugin" import { defineConfig } from "eslint-config-hyoban" import reactNative from "eslint-plugin-react-native" import path from "pathe" @@ -9,10 +10,92 @@ import noDebug from "./plugins/eslint/eslint-no-debug.js" import packageJsonExtend from "./plugins/eslint/eslint-package-json.js" import recursiveSort from "./plugins/eslint/eslint-recursive-sort.js" +const presetUpgradeCompatibilityRules = { + "antfu/no-top-level-await": "off", + curly: "off", + "dot-notation": "off", + "e18e/prefer-array-fill": "off", + "e18e/prefer-array-from-map": "off", + "e18e/prefer-array-some": "off", + "e18e/prefer-nullish-coalescing": "off", + "e18e/prefer-object-has-own": "off", + "e18e/prefer-static-regex": "off", + "e18e/prefer-timer-args": "off", + "hyoban/md-one-sentence-per-line": "off", + "import/no-mutable-exports": "off", + "jsonc/comma-dangle": "off", + "jsonc/sort-keys": "off", + "no-alert": "off", + "no-cond-assign": "off", + "no-console": "off", + "no-eval": "off", + "no-multi-str": "off", + "no-new": "off", + "no-shadow-restricted-names": "off", + "no-throw-literal": "off", + "no-undef-init": "off", + "no-unmodified-loop-condition": "off", + "no-unneeded-ternary": "off", + "no-useless-computed-key": "off", + "no-useless-rename": "off", + "no-useless-return": "off", + "node/no-deprecated-api": "off", + "node/prefer-global/buffer": "off", + "node/prefer-global/process": "off", + "object-shorthand": "off", + "one-var": "off", + "prefer-arrow-callback": "off", + "prefer-exponentiation-operator": "off", + "prefer-promise-reject-errors": "off", + "react-dom/no-flush-sync": "off", + "react-dom/no-missing-iframe-sandbox": "off", + "react-refresh/only-export-components": "off", + "react-web-api/no-leaked-event-listener": "off", + "react-web-api/no-leaked-timeout": "off", + "react/no-array-index-key": "off", + "react/no-children-map": "off", + "react/no-children-to-array": "off", + "react/no-context-provider": "off", + "react/no-missing-key": "off", + "react/no-nested-component-definitions": "off", + "react/no-use-context": "off", + "style/arrow-parens": "off", + "style/brace-style": "off", + "style/exp-list-style": "off", + "style/indent": "off", + "style/indent-binary-ops": "off", + "style/jsx-curly-brace-presence": "off", + "style/jsx-one-expression-per-line": "off", + "style/jsx-wrap-multilines": "off", + "style/member-delimiter-style": "off", + "style/multiline-ternary": "off", + "style/operator-linebreak": "off", + "style/quote-props": "off", + "style/quotes": "off", + "test/consistent-test-it": "off", + "test/no-identical-title": "off", + "test/no-import-node-test": "off", + "test/prefer-hooks-in-order": "off", + "test/prefer-lowercase-title": "off", + "ts/ban-ts-comment": "off", + "ts/method-signature-style": "off", + "ts/no-empty-object-type": "off", + "ts/no-non-null-asserted-optional-chain": "off", + "ts/no-redeclare": "off", + "ts/no-require-imports": "off", + "ts/no-unsafe-function-type": "off", + "ts/no-use-before-define": "off", + "unicorn/escape-case": "off", + "unicorn/number-literal-case": "off", +} + export default defineConfig( { + formatters: false, formatting: false, lessOpinionated: true, + stylistic: false, + yaml: false, ignores: [ ".context/**", "resources/**", @@ -21,6 +104,8 @@ export default defineConfig( "apps/mobile/.expo", "apps/mobile/native/build/**", "**/generated-routes.ts", + // Hyoban v7 adds opinionated Markdown linting. Keep the previous repo lint surface. + "**/*.md", ], preferESM: false, tailwindCSS: { @@ -37,6 +122,7 @@ export default defineConfig( "no-debug": noDebug, }, rules: { + ...presetUpgradeCompatibilityRules, "no-debug/no-debug-stack": "error", "tailwindcss/classnames-order": "off", "tailwindcss/enforces-negative-arbitrary-values": "off", @@ -46,10 +132,9 @@ export default defineConfig( "tailwindcss/no-contradicting-classname": "off", "tailwindcss/no-custom-classname": "off", "tailwindcss/no-unnecessary-arbitrary-value": "off", - "@eslint-react/no-clone-element": 0, - "@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": 0, - "@eslint-react/dom/no-flush-sync": 1, - "@eslint-react/hooks-extra/no-unnecessary-use-callback": "warn", + "react/no-clone-element": 0, + "react-hooks-extra/no-direct-set-state-in-use-effect": 0, + "react/no-unnecessary-use-callback": "warn", "unicorn/no-array-callback-reference": 0, "no-restricted-syntax": 0, "no-restricted-globals": [ @@ -108,8 +193,11 @@ export default defineConfig( }, { files: ["**/*.tsx"], + plugins: { + style: stylistic, + }, rules: { - "@stylistic/jsx-self-closing-comp": "error", + "style/jsx-self-closing-comp": "error", }, }, // @ts-expect-error @@ -158,7 +246,7 @@ export default defineConfig( // @ts-expect-error "react-native": fixupPluginRules(reactNative), }, - files: ["apps/mobile/**/*"], + files: ["apps/mobile/**/*.{js,jsx,ts,tsx}"], rules: { "react-native/no-inline-styles": "warn", }, diff --git a/package.json b/package.json index c9a9d136a59..7f3b52cf36e 100644 --- a/package.json +++ b/package.json @@ -33,41 +33,38 @@ "typecheck": "turbo typecheck" }, "devDependencies": { - "@babel/generator": "7.29.1", - "@babel/parser": "7.29.0", - "@babel/traverse": "7.29.0", - "@babel/types": "7.29.0", "@electron-toolkit/tsconfig": "2.0.0", - "@eslint/compat": "1.4.1", - "@tsslint/cli": "2.0.7", - "@tsslint/config": "2.0.7", - "@tsslint/eslint": "2.0.7", - "@types/node": "25.2.3", - "@types/react": "19.2.14", + "@eslint/compat": "2.1.0", + "@stylistic/eslint-plugin": "5.10.0", + "@tsslint/cli": "3.1.4", + "@tsslint/compat-eslint": "3.1.4", + "@tsslint/config": "3.1.4", + "@types/node": "26.1.1", + "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vercel/node": "5.6.3", + "@vercel/node": "5.8.22", "cross-env": "10.1.0", - "eslint": "9.39.1", - "eslint-config-hyoban": "4.0.10", + "eslint": "10.6.0", + "eslint-config-hyoban": "7.0.3", "eslint-plugin-react-native": "5.0.0", "fast-glob": "3.3.3", - "glob": "11.0.3", - "lint-staged": "16.2.7", - "prettier": "3.8.1", - "prettier-plugin-tailwindcss": "0.7.2", + "glob": "13.0.6", + "lint-staged": "17.0.8", + "prettier": "3.9.4", + "prettier-plugin-tailwindcss": "0.8.0", "raw-body": "3.0.2", - "react": "19.2.0", - "react-dom": "19.2.0", - "rimraf": "6.1.2", - "serialize-error": "2.1.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "rimraf": "6.1.3", "simple-git-hooks": "2.13.1", "svg-parser": "2.0.4", - "tar": "7.5.7", - "tsx": "4.21.0", - "turbo": "2.8.7", + "tar": "7.5.19", + "tsl": "1.0.30", + "tsx": "4.23.0", + "turbo": "2.10.4", "typescript": "catalog:", "vite": "7.3.1", - "vitest": "3.2.4" + "vitest": "4.1.10" }, "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged" diff --git a/packages/configs/package.json b/packages/configs/package.json index 339c5391777..0b860d70830 100644 --- a/packages/configs/package.json +++ b/packages/configs/package.json @@ -8,27 +8,28 @@ "./tsconfig.extend.json": "./tsconfig.extend.json" }, "peerDependencies": { - "tailwindcss": ">=3 <4" + "tailwindcss": ">=4 <5" }, "dependencies": { "@egoist/tailwindcss-icons": "1.9.2", - "@iconify-json/logos": "1.2.10", + "@iconify-json/logos": "1.2.11", "@iconify-json/mingcute": "1.2.7", - "@iconify-json/simple-icons": "1.2.70", - "@iconify/tools": "4.1.4", - "@iconify/utils": "3.1.0", + "@iconify-json/simple-icons": "1.2.89", + "@iconify/tools": "5.0.12", + "@iconify/utils": "3.1.4", "@tailwindcss/container-queries": "0.1.1", - "@tailwindcss/typography": "0.5.19", - "es-toolkit": "1.44.0", + "@tailwindcss/typography": "0.5.20", + "es-toolkit": "1.49.0", "tailwindcss-animate": "1.0.7", "tailwindcss-motion": "1.1.1", "tailwindcss-multi": "0.4.6", - "tailwindcss-safe-area": "0.8.0", + "tailwindcss-safe-area": "1.3.0", "tailwindcss-uikit-colors": "catalog:" }, "devDependencies": { - "postcss": "8.5.6", - "postcss-js": "5.0.3", + "postcss": "8.5.16", + "postcss-js": "5.1.0", + "tailwindcss": "4.3.2", "workspace-root": "3.3.1" } } diff --git a/packages/configs/tailwindcss/ratio-mixing-plugin.js b/packages/configs/tailwindcss/ratio-mixing-plugin.js index fed62645ddc..60994f72451 100644 --- a/packages/configs/tailwindcss/ratio-mixing-plugin.js +++ b/packages/configs/tailwindcss/ratio-mixing-plugin.js @@ -48,39 +48,6 @@ const ratioMixingPlugin = plugin.withOptions( function generateDynamicRatioUtilities(addUtilities, config) { const { baseColors, variants, colorSpace } = config - const utilities = {} - - // Generate dynamic utilities that parse ratios from class names - // Pattern: bg-mix-accent/background-7/3 or bg-mix-accent/background-1.5/2 - Object.entries(baseColors).forEach(([color1Name, color1Value]) => { - Object.entries(baseColors).forEach(([color2Name, color2Value]) => { - if (color1Name === color2Name) return // Skip same color mixing - - variants.forEach((variant) => { - const property = getPropertyName(variant) - - // Generate a utility that can accept arbitrary ratio values - const classPattern = `.${variant}-${config.prefix}-${color1Name}\\/${color2Name}-([0-9.]+)\\/([0-9.]+)` - - utilities[classPattern] = (match) => { - const num = Number.parseFloat(match[1]) - const denom = Number.parseFloat(match[2]) - - if (num <= 0 || denom <= 0) return {} - - const percentage1 = Math.round((num / (num + denom)) * 100) - const percentage2 = 100 - percentage1 - - const mixedColor = `color-mix(in ${colorSpace}, ${color1Value} ${percentage1}%, ${color2Value} ${percentage2}%)` - - return { [property]: mixedColor } - } - }) - }) - }) - - // Since we can't use regex patterns directly with addUtilities, - // we'll use a different approach with addComponents const dynamicUtilities = {} // Create utilities for common ratios that can be extended @@ -112,7 +79,7 @@ function generateDynamicRatioUtilities(addUtilities, config) { const percentage2 = 100 - percentage1 variants.forEach((variant) => { - const className = `.${variant}-${config.prefix}-${color1Name}\\/${color2Name}-${num}\\/${denom}` + const className = `.${variant}-${config.prefix}-${color1Name}-${color2Name}-${num}-${denom}` const property = getPropertyName(variant) const mixedColor = `color-mix(in ${colorSpace}, ${color1Value} ${percentage1}%, ${color2Value} ${percentage2}%)` diff --git a/packages/configs/tailwindcss/tailwind-extend.css b/packages/configs/tailwindcss/tailwind-extend.css index d8f120e5d39..eec90fcd4c7 100644 --- a/packages/configs/tailwindcss/tailwind-extend.css +++ b/packages/configs/tailwindcss/tailwind-extend.css @@ -1,7 +1,5 @@ /* This CSS File do not import anywhere, just write atom class for tailwindcss. The tailwindcss intellisense will be work. */ -@tailwind components; - @layer components { .drag-region { -webkit-app-region: drag; @@ -20,7 +18,9 @@ } .center { - @apply flex items-center justify-center; + display: flex; + align-items: center; + justify-content: center; } .shadow-perfect { @@ -90,8 +90,12 @@ rgba(var(--color-background) / 0.95), rgba(var(--color-background) / 0.92) ); - @apply rounded-[4px] px-1 text-[0.5em]; - @apply inline-flex items-center justify-center; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding-inline: 0.25rem; + font-size: 0.5em; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04), @@ -242,8 +246,8 @@ text-decoration: underline; text-underline-offset: 3px; - @apply decoration-accent/30 hover:no-underline; - @apply border-0; + border-width: 0; + text-decoration-color: hsl(var(--fo-a) / 0.3); background-position: left 1.1em; @@ -253,6 +257,7 @@ &:hover { background-size: 100% 1.5px; + text-decoration-line: none; text-shadow: 0.05em 0 theme(colors.background), -0.05em 0 theme(colors.background); diff --git a/packages/configs/tailwindcss/web.ts b/packages/configs/tailwindcss/web.ts index 1eeca7ee14f..7cc271f0ee6 100644 --- a/packages/configs/tailwindcss/web.ts +++ b/packages/configs/tailwindcss/web.ts @@ -2,12 +2,15 @@ import "./tw-css-plugin" import { getIconCollections, iconsPlugin } from "@egoist/tailwindcss-icons" -import { cleanupSVG, importDirectorySync, isEmptyColor, parseColors, runSVGO } from "@iconify/tools" +import { isEmptyColor, parseColors } from "@iconify/tools/lib/colors/parse.js" +import { importDirectorySync } from "@iconify/tools/lib/import/directory.js" +import { runSVGO } from "@iconify/tools/lib/optimise/svgo.js" +import { cleanupSVG } from "@iconify/tools/lib/svg/cleanup.js" import { compareColors, stringToColor } from "@iconify/utils/lib/colors" import { merge } from "es-toolkit/compat" import path, { resolve } from "pathe" -import type { Config } from "tailwindcss/types/config" -import { withUIKit } from "tailwindcss-uikit-colors/src/macos/tailwind" +import type { Config } from "tailwindcss" +import { withUIKit } from "tailwindcss-uikit-colors/macos" import { workspaceRootSync } from "workspace-root" import ratioMixingPlugin from "./ratio-mixing-plugin" diff --git a/packages/internal/atoms/package.json b/packages/internal/atoms/package.json index fbd2a09436d..dea7483e35a 100644 --- a/packages/internal/atoms/package.json +++ b/packages/internal/atoms/package.json @@ -23,6 +23,6 @@ "@follow/store": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", - "jotai": "2.17.1" + "jotai": "2.20.1" } } diff --git a/packages/internal/atoms/tsconfig.json b/packages/internal/atoms/tsconfig.json index bf2ad7fcb57..65c67bb24d2 100644 --- a/packages/internal/atoms/tsconfig.json +++ b/packages/internal/atoms/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@follow/configs/tsconfig.extend.json", "compilerOptions": { - "baseUrl": ".", "declaration": true, "types": ["@follow/types/react", "@follow/types/global", "vite/client"], "paths": { diff --git a/packages/internal/components/assets/tailwind.css b/packages/internal/components/assets/tailwind.css index 78eabc8761a..d58ed6624fc 100644 --- a/packages/internal/components/assets/tailwind.css +++ b/packages/internal/components/assets/tailwind.css @@ -1,7 +1,3 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - @layer base { /* shadcn colors */ diff --git a/packages/internal/components/package.json b/packages/internal/components/package.json index 8a283909222..456f44aed8f 100644 --- a/packages/internal/components/package.json +++ b/packages/internal/components/package.json @@ -23,87 +23,86 @@ }, "dependencies": { "@essentials/request-timeout": "1.3.0", - "@floating-ui/core": "1.7.2", - "@floating-ui/react": "0.27.17", - "@floating-ui/react-dom": "2.1.4", + "@floating-ui/core": "1.7.5", + "@floating-ui/react": "0.27.19", + "@floating-ui/react-dom": "2.1.8", "@follow/hooks": "workspace:*", "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", - "@headlessui/react": "2.2.9", - "@lexical/code": "0.40.0", - "@lexical/link": "0.40.0", - "@lexical/list": "0.40.0", - "@lexical/mark": "0.40.0", - "@lexical/markdown": "0.40.0", - "@lexical/react": "0.40.0", - "@lexical/rich-text": "0.40.0", - "@microflash/remark-callout-directives": "4.4.0", - "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-avatar": "1.1.11", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.8", - "@radix-ui/react-navigation-menu": "1.2.14", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-progress": "1.1.8", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.4", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toast": "1.2.15", - "@radix-ui/react-tooltip": "1.2.8", + "@headlessui/react": "2.2.10", + "@lexical/code": "0.46.0", + "@lexical/link": "0.46.0", + "@lexical/list": "0.46.0", + "@lexical/mark": "0.46.0", + "@lexical/markdown": "0.46.0", + "@lexical/react": "0.46.0", + "@lexical/rich-text": "0.46.0", + "@microflash/remark-callout-directives": "5.0.1", + "@radix-ui/react-accordion": "1.2.16", + "@radix-ui/react-avatar": "1.2.2", + "@radix-ui/react-checkbox": "1.3.7", + "@radix-ui/react-context-menu": "2.3.3", + "@radix-ui/react-dialog": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.20", + "@radix-ui/react-hover-card": "1.1.19", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-navigation-menu": "1.2.18", + "@radix-ui/react-popover": "1.1.19", + "@radix-ui/react-progress": "1.1.12", + "@radix-ui/react-radio-group": "1.4.3", + "@radix-ui/react-scroll-area": "1.2.14", + "@radix-ui/react-select": "2.3.3", + "@radix-ui/react-slider": "1.4.3", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.3", + "@radix-ui/react-tabs": "1.1.17", + "@radix-ui/react-toast": "1.2.19", + "@radix-ui/react-tooltip": "1.2.12", "@react-hook/window-size": "3.1.1", "@rehookify/datepicker": "6.6.8", - "@types/hast": "3.0.4", + "@types/hast": "3.0.5", "@types/unist": "3.0.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", - "dayjs": "1.11.19", - "es-toolkit": "1.44.0", - "foxact": "0.2.52", + "dayjs": "1.11.21", + "es-toolkit": "1.49.0", + "foxact": "0.3.8", "hast-util-to-jsx-runtime": "2.3.6", "hast-util-to-text": "4.0.2", "input-otp": "1.4.2", - "jotai": "2.17.1", - "katex": "0.16.28", - "lexical": "0.40.0", + "jotai": "2.20.1", + "katex": "0.17.0", + "lexical": "0.46.0", "masonic": "4.1.0", - "motion": "12.34.0", + "motion": "12.42.2", "react-blurhash": "0.3.0", "react-fast-marquee": "1.6.5", - "react-hook-form": "7.71.1", - "react-hotkeys-hook": "5.2.4", - "react-i18next": "16.5.4", - "react-router": "7.13.0", + "react-hook-form": "7.81.0", + "react-hotkeys-hook": "5.3.3", + "react-i18next": "17.0.8", + "react-router": "8.2.0", "rehype-infer-description-meta": "2.0.0", "rehype-parse": "9.0.1", "rehype-sanitize": "6.0.0", "rehype-stringify": "10.0.1", "remark-directive": "4.0.0", "remark-gfm": "4.0.1", - "remark-gh-alerts": "0.0.3", + "remark-gh-alerts": "1.0.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "sonner": "2.0.7", "tailwindcss-uikit-colors": "catalog:", "unified": "11.0.5", "unist-util-visit": "5.1.0", - "unist-util-visit-parents": "5.1.3", + "unist-util-visit-parents": "6.0.2", "use-context-selector": "2.0.0", "usehooks-ts": "3.1.1", "vaul": "1.1.2", - "vfile": "5.3.7", - "zustand": "5.0.11" + "vfile": "6.0.3", + "zustand": "5.0.14" }, "devDependencies": { - "@follow/configs": "workspace:*", - "@types/katex": "0.16.8" + "@follow/configs": "workspace:*" } } diff --git a/packages/internal/components/src/providers/stable-router-provider.tsx b/packages/internal/components/src/providers/stable-router-provider.tsx index 2c952c78ff6..9d608d02dda 100644 --- a/packages/internal/components/src/providers/stable-router-provider.tsx +++ b/packages/internal/components/src/providers/stable-router-provider.tsx @@ -4,7 +4,6 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "react-rout import { setNavigate, setRoute } from "../atoms/route" -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface CustomRoute {} export interface GlobalRoute extends CustomRoute { navigate: NavigateFunction @@ -15,6 +14,7 @@ declare global { } } window.router = { + ...window.router, navigate() {}, } as any diff --git a/packages/internal/components/src/ui/context-menu/context-menu.tsx b/packages/internal/components/src/ui/context-menu/context-menu.tsx index 83583cb0343..0f3314a55a4 100644 --- a/packages/internal/components/src/ui/context-menu/context-menu.tsx +++ b/packages/internal/components/src/ui/context-menu/context-menu.tsx @@ -36,7 +36,7 @@ const ContextMenuSubTrigger = ({ (