feat: add cross-platform e2e coverage (#4901)

* feat: add cross-platform e2e coverage

* fix: stabilize desktop e2e navigation

* fix: harden desktop e2e read flow

* fix: harden desktop e2e auth and follow flows

* fix: stabilize desktop e2e discover entry flows

* fix: run android e2e in a single shell

* fix: speed up mobile e2e builds in ci

* fix: build android e2e app from mobile workspace

* fix: invoke android gradle build from repo root

* fix: use absolute path for android ci build

* fix: restore android eas build in ci

* fix: target iOS simulator by udid in ci

* fix: align desktop e2e with discover card flow

* fix: reorder web e2e after relogin

* fix: navigate web e2e via real hash url

* fix: close stale settings modal before reopening

* fix: track android maestro flows

* fix: wait for settings router in web e2e

* fix: wait for web settings sync propagation

* fix: stabilize desktop e2e settings flows

* fix: harden desktop e2e regressions

* fix: stabilize desktop e2e selectors

* fix: read desktop entry state from locators

* fix: relax desktop e2e entry assertions

* fix: avoid waiting on missing desktop feed ids
This commit is contained in:
DIYgod 2026-03-10 11:32:00 +08:00 committed by GitHub
parent df748a5f6a
commit 93e4a3b48f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 3413 additions and 285 deletions

241
.github/workflows/e2e.yml vendored Normal file
View File

@ -0,0 +1,241 @@
name: ✅ E2E
on:
pull_request:
paths:
- "apps/desktop/**"
- "apps/mobile/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/e2e.yml"
push:
branches:
- main
- dev
paths:
- "apps/desktop/**"
- "apps/mobile/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/e2e.yml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
env:
NODE_OPTIONS: --max-old-space-size=8192
jobs:
desktop-web:
name: Desktop Web
runs-on: ubuntu-latest
env:
FOLO_E2E_PROFILE: prod
FOLO_E2E_WEB_DEBUG_PROXY_PATH: /__debug_proxy.html
FOLO_E2E_WEB_DEV_API_URL: https://api.folo.is
FOLO_E2E_WEB_DEV_WEB_URL: https://app.folo.is
steps:
- name: 📦 Checkout code
uses: actions/checkout@v6
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
- name: 🏗 Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
working-directory: apps/desktop
run: pnpm exec playwright install --with-deps chromium
- name: Run web E2E
working-directory: apps/desktop
run: pnpm run e2e:web
- name: Upload desktop web artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-web-e2e
path: |
apps/desktop/e2e/playwright-report
apps/desktop/e2e/test-results
retention-days: 14
desktop-electron:
name: Desktop Electron
runs-on: macos-latest
env:
FOLO_E2E_PROFILE: prod
steps:
- name: 📦 Checkout code
uses: actions/checkout@v6
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
- name: 🏗 Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Run electron E2E
working-directory: apps/desktop
run: pnpm run e2e:electron
- name: Upload desktop electron artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-electron-e2e
path: |
apps/desktop/e2e/playwright-report
apps/desktop/e2e/test-results
retention-days: 14
mobile-android:
name: Mobile Android
if: github.secret_source != 'None'
runs-on: ubuntu-latest
steps:
- name: 📦 Checkout code
uses: actions/checkout@v6
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
- name: 🏗 Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: "17"
distribution: "zulu"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: 📱 Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install Maestro CLI
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: pnpm install
- name: Build Android E2E app
working-directory: apps/mobile
run: eas build --platform android --profile e2e-android --local --output=${{ runner.temp }}/folo-e2e.apk
- name: Run Android E2E
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
profile: pixel_7
emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect
script: |
adb install -r "${{ runner.temp }}/folo-e2e.apk"
bash -lc 'cd apps/mobile && MAESTRO_DEBUG_OUTPUT="${{ github.workspace }}/apps/mobile/e2e/artifacts/android" pnpm run e2e:android'
- name: Upload mobile android artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: mobile-android-e2e
path: |
apps/mobile/e2e/artifacts/android
apps/mobile/report.xml
retention-days: 14
mobile-ios:
name: Mobile iOS
if: github.secret_source != 'None'
runs-on: macos-latest
steps:
- name: 📦 Checkout code
uses: actions/checkout@v6
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
- name: 🏗 Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install Maestro CLI
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: pnpm install
- name: Boot iOS simulator
run: |
device_name="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && $2 ~ /^[A-F0-9-]+$/ { gsub(/[[:space:]]+$/, "", $1); print $1; exit }')"
device_id="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && $2 ~ /^[A-F0-9-]+$/ { print $2; exit }')"
if [ -z "$device_id" ]; then
echo "No available iPhone simulator found"
xcrun simctl list devices available
exit 1
fi
echo "Using simulator: ${device_name} (${device_id})"
echo "MAESTRO_IOS_DEVICE_ID=${device_id}" >> "$GITHUB_ENV"
xcrun simctl boot "$device_id" || true
xcrun simctl bootstatus "$device_id" -b
- name: Build iOS simulator E2E app
working-directory: apps/mobile
env:
PROFILE: e2e-ios-simulator
EXPO_PUBLIC_E2E_ENV_PROFILE: prod
EXPO_PUBLIC_E2E_LANGUAGE: en
run: |
pnpm exec expo run:ios --configuration Release -d "$MAESTRO_IOS_DEVICE_ID" --no-bundler
app_path="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n 1)"
if [ -z "$app_path" ]; then
echo "Unable to find built .app bundle"
exit 1
fi
echo "MAESTRO_IOS_APP_PATH=$app_path" >> "$GITHUB_ENV"
- name: Run iOS E2E
run: |
export MAESTRO_DEBUG_OUTPUT="${{ github.workspace }}/apps/mobile/e2e/artifacts/ios"
cd apps/mobile
pnpm run e2e:ios
- name: Upload mobile ios artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: mobile-ios-e2e
path: |
apps/mobile/e2e/artifacts/ios
apps/mobile/report.xml
retention-days: 14

13
.gitignore vendored
View File

@ -35,3 +35,16 @@ apps/desktop/resources/cli
.serena
.wrangler
# Local agent artifacts
.codex/
# E2E outputs
/apps/desktop/e2e/playwright-report/
/apps/desktop/e2e/test-results/
/apps/mobile/e2e/artifacts/
/apps/mobile/report.xml
/report.xml
# Mobile local E2E build artifacts
apps/mobile/build-*.tar.gz

View File

@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test"
import { resolveDesktopE2EEnv } from "./support/env"
const env = resolveDesktopE2EEnv()
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
workers: 1,
timeout: 120_000,
expect: {
timeout: 15_000,
},
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
outputDir: "test-results",
use: {
baseURL: env.webBaseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
serviceWorkers: "block",
},
webServer: {
command: "pnpm run dev:web",
cwd: env.desktopAppDir,
env: {
...process.env,
VITE_API_URL: process.env.FOLO_E2E_WEB_DEV_API_URL ?? env.apiURL,
VITE_WEB_URL: process.env.FOLO_E2E_WEB_DEV_WEB_URL ?? env.webURL,
},
url: env.webDevServerURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "web",
testMatch: /tests\/web\/.*\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
channel: "chromium",
ignoreHTTPSErrors: true,
launchOptions: {
args: ["--disable-web-security"],
},
},
},
{
name: "electron",
testMatch: /tests\/electron\/.*\.spec\.ts/,
},
],
})

View File

@ -0,0 +1,44 @@
import type { Page } from "@playwright/test"
import type { DesktopE2EEnv } from "./env"
export interface TestAccount {
email: string
password: string
}
export const createTestAccount = (name: string): TestAccount => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return {
email: `folo-e2e-${name}-${suffix}@example.com`,
password: process.env.FOLO_E2E_PASSWORD ?? "Password123!",
}
}
export const tryDeleteCurrentUser = async (page: Page, env: DesktopE2EEnv) => {
return page.evaluate(async ({ apiURL }) => {
try {
const response = await fetch(`${apiURL}/better-auth/delete-user-custom`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
})
return {
ok: response.ok,
status: response.status,
text: await response.text(),
}
} catch (error) {
return {
ok: false,
status: -1,
text: error instanceof Error ? error.message : String(error),
}
}
}, env)
}

View File

@ -0,0 +1,673 @@
import type { Locator, Page } from "@playwright/test"
import { expect } from "@playwright/test"
import type { TestAccount } from "./account"
import type { DesktopE2EEnv } from "./env"
import { buildHashRoute, buildWebAppURL } from "./env"
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()
export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
await page.addInitScript(
(nextEnv) => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
if (!nextEnv) {
return
}
const fixedEnv = {
VITE_API_URL: nextEnv.apiURL,
VITE_EXTERNAL_API_URL: nextEnv.apiURL,
VITE_WEB_URL: nextEnv.webURL,
}
const target =
(globalThis as typeof globalThis & { __followEnv?: Record<string, string> }).__followEnv ??
{}
const proxy = new Proxy(target, {
get(currentTarget, property, receiver) {
if (typeof property === "string" && property in fixedEnv) {
return fixedEnv[property as keyof typeof fixedEnv]
}
return Reflect.get(currentTarget, property, receiver)
},
set(currentTarget, property, value, receiver) {
if (typeof property === "string" && property in fixedEnv) {
return true
}
return Reflect.set(currentTarget, property, value, receiver)
},
ownKeys(currentTarget) {
return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)]))
},
getOwnPropertyDescriptor(currentTarget, property) {
if (typeof property === "string" && property in fixedEnv) {
return {
configurable: true,
enumerable: true,
writable: false,
value: fixedEnv[property as keyof typeof fixedEnv],
}
}
return Reflect.getOwnPropertyDescriptor(currentTarget, property)
},
})
Object.defineProperty(globalThis, "__followEnv", {
configurable: true,
enumerable: false,
get() {
return proxy
},
set() {},
})
},
env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined,
)
}
export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => {
await injectRecaptchaToken(page, env)
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
}
export const navigateInApp = async (
page: Page,
env: DesktopE2EEnv,
route: string,
options?: { electron?: boolean },
) => {
if (options?.electron) {
await page.evaluate((nextRoute) => {
window.location.hash = nextRoute
}, buildHashRoute(route))
return
}
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 timelineVisible = await page
.getByTestId("timeline-tab-articles")
.isVisible()
.catch(() => false)
return profileVisible || timelineVisible
}
try {
await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true)
} catch {
await page.reload({ waitUntil: "domcontentloaded" })
await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true)
}
}
export const waitForLoggedOut = async (page: Page) => {
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
},
{ timeout: 30_000 },
)
.toBe(true)
}
export const ensureLoginModal = async (page: Page) => {
await expect
.poll(
async () => {
const loginModalVisible = await page
.getByTestId("login-modal")
.last()
.isVisible()
.catch(() => false)
const loginButtonVisible = await page
.getByTestId("login-button")
.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 loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible
},
{ timeout: 30_000 },
)
.toBe(true)
}
const ensureCredentialForm = async (page: Page, mode: "register" | "login") => {
const attemptEnsureCredentialForm = async () => {
await ensureLoginModal(page)
const targetInput = visibleByTestId(
page,
mode === "register" ? "register-email-input" : "login-email-input",
)
const loginButton = visibleByTestId(page, "login-button")
const loginModal = visibleByTestId(page, "login-modal")
const credentialProvider = visibleByTestId(page, "login-provider-credential")
const targetForm = visibleByTestId(page, mode === "register" ? "register-form" : "login-form")
const oppositeForm = visibleByTestId(page, mode === "register" ? "login-form" : "register-form")
const oppositeFormSwitcher = visibleByTestId(
page,
mode === "register" ? "login-switch-register" : "register-switch-login",
)
if (await isVisible(targetInput)) {
return
}
if ((await isVisible(loginButton)) && !(await isVisible(loginModal))) {
await loginButton.click({ force: true })
}
if (await isVisible(targetInput)) {
return
}
if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) {
await credentialProvider.click({ force: true, timeout: 30_000 })
await expect
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
timeout: 30_000,
})
.toBe(true)
}
if (await isVisible(oppositeForm)) {
await oppositeFormSwitcher.click({ force: true, timeout: 30_000 })
}
await expect(targetInput).toBeVisible({ timeout: 30_000 })
}
let lastError: unknown
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await attemptEnsureCredentialForm()
return
} catch (error) {
lastError = error
if (attempt === 2) {
throw error
}
await page.keyboard.press("Escape").catch(() => {})
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
}
}
throw lastError
}
export const registerWithCredential = async (page: Page, account: TestAccount) => {
await ensureCredentialForm(page, "register")
await visibleByTestId(page, "register-email-input").fill(account.email)
await visibleByTestId(page, "register-password-input").fill(account.password)
await visibleByTestId(page, "register-confirm-password-input").fill(account.password)
await visibleByTestId(page, "register-submit").click({ force: true })
await waitForAuthenticated(page)
}
export const loginWithCredential = async (page: Page, account: TestAccount) => {
await ensureCredentialForm(page, "login")
await visibleByTestId(page, "login-email-input").fill(account.email)
await visibleByTestId(page, "login-password-input").fill(account.password)
await visibleByTestId(page, "login-submit").click({ force: true })
await waitForAuthenticated(page)
}
export const logoutFromProfileMenu = async (page: Page) => {
await page.keyboard.press("Escape").catch(() => {})
await page.getByTestId("profile-menu-trigger").click()
const signOutResponse = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"),
{ timeout: 30_000 },
)
await page.getByTestId("profile-menu-logout").click()
await signOutResponse
await expect
.poll(
async () =>
page
.getByTestId("profile-menu-trigger")
.isVisible()
.catch(() => false),
{ timeout: 30_000 },
)
.toBe(false)
}
const deleteWithSession = async (
page: Page,
env: DesktopE2EEnv,
path: string,
body: Record<string, string>,
) => {
return page.evaluate(
async ({ url, payload }) => {
const response = await fetch(url, {
method: "DELETE",
credentials: "include",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(payload),
})
return {
ok: response.ok,
status: response.status,
text: await response.text(),
}
},
{
url: `${env.apiURL}${path}`,
payload: body,
},
)
}
const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => {
if (tab === "general") {
await expect(page.getByTestId("settings-language-select")).toBeVisible({ timeout: 15_000 })
return
}
await expect
.poll(async () => page.locator('[data-testid^="settings-feed-row-"]').count(), {
timeout: 15_000,
})
.toBeGreaterThan(0)
}
export const openSettings = async (page: Page, tab: "general" | "feeds" = "general") => {
const settingsTab = page.getByTestId(`settings-tab-${tab}`)
const settingsModal = page.locator("#setting-modal").first()
if (await settingsModal.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
await expect
.poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
await expect
.poll(
async () =>
page.evaluate(() => {
const { router } = window as typeof window & {
router?: { showSettings?: (tab?: unknown) => void }
}
return typeof router?.showSettings === "function"
}),
{ timeout: 15_000 },
)
.toBe(true)
await page.evaluate((nextTab) => {
const { router } = window as typeof window & {
router?: { showSettings?: (tab?: unknown) => void }
}
router?.showSettings?.(nextTab)
}, tab)
await expect(settingsTab).toBeVisible({ timeout: 15_000 })
await waitForSettingsTabContent(page, tab)
}
export const openSettingsTab = async (page: Page, tab: "general" | "feeds") => {
const settingsTab = page.getByTestId(`settings-tab-${tab}`)
await expect(settingsTab).toBeVisible({ timeout: 15_000 })
if (tab === "feeds") {
await expect
.poll(
async () => {
const className = (await settingsTab.getAttribute("class")) ?? ""
return !className.includes("opacity-50")
},
{ timeout: 15_000 },
)
.toBe(true)
}
await settingsTab
.evaluate((element) => {
if (element instanceof HTMLElement) {
element.click()
}
})
.catch(async () => {
await settingsTab.click({ force: true, noWaitAfter: true })
})
await waitForSettingsTabContent(page, tab)
}
export const closeSettings = async (page: Page) => {
const settingsModal = page.locator("#setting-modal").first()
if (!(await settingsModal.isVisible().catch(() => false))) {
return
}
await page.keyboard.press("Escape").catch(() => {})
if (await settingsModal.isVisible().catch(() => false)) {
const modalClose = settingsModal.getByTestId("modal-close").first()
if (await isVisible(modalClose)) {
await modalClose
.evaluate((element) => {
if (element instanceof HTMLElement) {
element.click()
}
})
.catch(async () => {
await modalClose.click({ force: true, noWaitAfter: true })
})
}
}
await expect
.poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
export const setLanguage = async (page: Page, label: string) => {
await page.getByTestId("settings-language-select").click()
await page.getByRole("option", { name: label }).click()
}
export const getLanguageLabel = async (page: Page) => {
return page.getByTestId("settings-language-select").textContent()
}
export const openOnboardingFeedForm = async (
page: Page,
env: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
await navigateInApp(page, env, "/discover", options)
const discoverInput = page.getByTestId("discover-form-input")
if (!(await discoverInput.isVisible().catch(() => false))) {
const discoverLink = page.locator('a[href="#/discover"], a[href="/discover"]').last()
if (await discoverLink.isVisible().catch(() => false)) {
await discoverLink.click({ force: true })
}
}
if (!(await discoverInput.isVisible().catch(() => false))) {
await page.evaluate(() => {
const nextRoute = "/discover"
const { router } = window as typeof window & {
router?: { navigate?: (route: string) => void }
}
router?.navigate?.(nextRoute)
})
}
await expect(discoverInput).toBeVisible({ timeout: 15_000 })
await discoverInput.fill(ONBOARDING_FEED_URL)
await discoverInput.press("Enter")
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
}
export const followOnboardingFeed = async (
page: Page,
env: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
await openOnboardingFeedForm(page, env, options)
const onboardingDiscoverCard = page
.locator("[data-feed-id]")
.filter({ hasText: "Welcome to Folo" })
.first()
const followButton = onboardingDiscoverCard.getByRole("button", { name: /^Follow$/i })
if (await followButton.isVisible().catch(() => false)) {
await followButton.click({ force: true })
}
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
}
export const dismissFeedForm = async (page: Page) => {
const cancelButton = visibleByTestId(page, "feed-form-cancel")
const dialog = page.locator('[role="dialog"]').last()
if (!(await cancelButton.isVisible().catch(() => false))) {
if (await dialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
}
return
}
await cancelButton
.evaluate((element) => {
if (element instanceof HTMLElement) {
element.click()
}
})
.catch(() => {})
if (
(await cancelButton.isVisible().catch(() => false)) ||
(await dialog.isVisible().catch(() => false))
) {
await page.keyboard.press("Escape").catch(() => {})
}
}
const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => {
const targetedFeedRow = onboardingFeedId
? page.getByTestId(`settings-feed-row-${onboardingFeedId}`)
: null
const fallbackFeedRow = page
.locator('[data-testid^="settings-feed-row-"]')
.filter({
hasText: "Welcome to Folo",
})
.first()
const settingsViewport = page.locator("#setting-modal [data-radix-scroll-area-viewport]").first()
await settingsViewport
.evaluate((element) => {
if (element instanceof HTMLElement) {
element.scrollTop = 0
}
})
.catch(() => {})
for (let attempt = 0; attempt < 24; attempt++) {
if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) {
return targetedFeedRow
}
if (await fallbackFeedRow.isVisible().catch(() => false)) {
return fallbackFeedRow
}
await settingsViewport.hover().catch(() => {})
await page.mouse.wheel(0, 1200)
await page.waitForTimeout(150)
}
return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow
}
export const unsubscribeFirstFeedFromSettings = async (page: Page, env?: DesktopE2EEnv) => {
const onboardingFeedItem = page
.locator("[data-feed-id]")
.filter({
hasText: "Welcome to Folo",
})
.first()
const onboardingFeedId =
(await onboardingFeedItem.count()) > 0
? await onboardingFeedItem.getAttribute("data-feed-id")
: null
let unsubscribedInSettings = false
await openSettings(page)
await openSettingsTab(page, "feeds")
const feedRow = await findSettingsFeedRow(page, onboardingFeedId)
if (await feedRow.isVisible().catch(() => false)) {
await feedRow.scrollIntoViewIfNeeded().catch(() => {})
const feedRowTestId = await feedRow.getAttribute("data-testid")
await feedRow.click()
await expect(page.getByTestId("feeds-batch-unsubscribe")).toBeVisible({ timeout: 15_000 })
await page.getByTestId("feeds-batch-unsubscribe").click()
await page.getByTestId("confirm-destroy").click()
if (feedRowTestId) {
await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 })
} else {
await expect
.poll(
async () =>
page
.getByTestId("feeds-batch-unsubscribe")
.isVisible()
.catch(() => false),
{
timeout: 15_000,
},
)
.toBe(false)
}
unsubscribedInSettings = true
}
const isElectronApp = page.url().startsWith("app://")
if (!unsubscribedInSettings && !isElectronApp && env && onboardingFeedId) {
const response = await deleteWithSession(page, env, "/subscriptions", {
feedId: onboardingFeedId,
})
expect(response.ok).toBe(true)
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
}
if (isElectronApp) {
expect(unsubscribedInSettings).toBe(true)
}
}
export const expectOnboardingFeedUnsubscribed = async (
page: Page,
env: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
await openOnboardingFeedForm(page, env, options)
await expect(page.getByTestId("feed-form-cancel")).toHaveCount(0)
}
export const expectTimelineSwitchAndEntryReadFlow = async (
page: Page,
env: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
await navigateInApp(page, env, "/", options)
await page.getByTestId("timeline-tab-videos").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0)
await page.getByTestId("timeline-tab-articles").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
const onboardingFeed = page
.locator("[data-feed-id]")
.filter({
hasText: "Welcome to Folo",
})
.first()
if (await isVisible(onboardingFeed)) {
await onboardingFeed.scrollIntoViewIfNeeded().catch(() => {})
await onboardingFeed.click({ force: true })
}
const unreadOnboardingEntry = page.locator('[data-entry-id][data-read="false"]').first()
const fallbackOnboardingEntry = page.locator("[data-entry-id]").first()
const firstOnboardingEntry = (await unreadOnboardingEntry.isVisible().catch(() => false))
? unreadOnboardingEntry
: fallbackOnboardingEntry
await expect(firstOnboardingEntry).toBeVisible({ timeout: 15_000 })
const onboardingEntryId = await firstOnboardingEntry.getAttribute("data-entry-id")
const onboardingEntry = onboardingEntryId
? page.locator(`[data-entry-id="${onboardingEntryId}"]`)
: firstOnboardingEntry
if (onboardingEntryId && !(await unreadOnboardingEntry.isVisible().catch(() => false))) {
const response = await deleteWithSession(page, env, "/reads", {
entryId: onboardingEntryId,
})
expect(response.ok).toBe(true)
await page.reload({ waitUntil: "domcontentloaded" })
if (await isVisible(onboardingFeed)) {
await onboardingFeed.click({ force: true })
}
await expect(onboardingEntry).toHaveAttribute("data-read", "false")
}
await onboardingEntry.click({ force: true })
await expect(page.getByTestId("entry-render")).toBeVisible({ timeout: 15_000 })
if (onboardingEntryId) {
const response = await deleteWithSession(page, env, "/reads", {
entryId: onboardingEntryId,
})
expect(response.ok).toBe(true)
}
}

View File

@ -0,0 +1,70 @@
import { execSync } from "node:child_process"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import type { ElectronApplication, Page } from "@playwright/test"
import { _electron as electron } from "@playwright/test"
import { join } from "pathe"
import type { DesktopE2EEnv } from "./env"
let buildSignature: string | null = null
const ensureElectronBuilt = (env: DesktopE2EEnv) => {
const nextSignature = `${env.apiURL}|${env.webURL}`
if (buildSignature === nextSignature) {
return
}
execSync("pnpm run build:electron-vite", {
cwd: env.desktopAppDir,
env: {
...process.env,
VITE_API_URL: env.apiURL,
VITE_WEB_URL: env.webURL,
},
stdio: "inherit",
})
buildSignature = nextSignature
}
export const launchElectronApp = async (env: DesktopE2EEnv) => {
ensureElectronBuilt(env)
const userDataDir = await mkdtemp(join(tmpdir(), "folo-e2e-"))
const electronApp = await electron.launch({
args: [env.desktopAppDir],
cwd: env.desktopAppDir,
env: {
...process.env,
CI: process.env.CI ?? "1",
NODE_ENV: "test",
VITE_API_URL: env.apiURL,
VITE_WEB_URL: env.webURL,
FOLO_E2E_USER_DATA_DIR: userDataDir,
},
timeout: 120_000,
})
const page = await electronApp.firstWindow()
await page.waitForLoadState("domcontentloaded")
await page.evaluate(() => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
})
return {
electronApp,
page,
userDataDir,
}
}
export const closeElectronApp = async (app: {
electronApp: ElectronApplication
page: Page
userDataDir: string
}) => {
await app.electronApp.close().catch(() => {})
await rm(app.userDataDir, { force: true, recursive: true })
}

View File

@ -0,0 +1,83 @@
import { fileURLToPath } from "node:url"
import { join } from "pathe"
export type DesktopE2EProfile = "local" | "prod"
const DESKTOP_E2E_PROFILES = {
local: {
apiURL: "http://localhost:3000",
webURL: "http://localhost:2233",
webBaseURL: "http://127.0.0.1:2233",
webUsesHashRouter: false,
},
prod: {
apiURL: "https://api.folo.is",
webURL: "https://app.folo.is",
webBaseURL: null,
webUsesHashRouter: true,
},
} as const
export interface DesktopE2EEnv {
profile: DesktopE2EProfile
apiURL: string
webURL: string
webBaseURL: string
webUsesHashRouter: boolean
webDevServerURL: string
debugProxyPath: string
desktopAppDir: string
}
const supportDir = fileURLToPath(new URL(".", import.meta.url))
const desktopAppDir = join(supportDir, "..", "..")
const normalizeRoute = (route: string) => {
if (!route || route === "/") {
return "/"
}
return route.startsWith("/") ? route : `/${route}`
}
export const resolveDesktopE2EEnv = (): DesktopE2EEnv => {
const profile = (process.env.FOLO_E2E_PROFILE ?? "local") as DesktopE2EProfile
const resolvedProfile = profile in DESKTOP_E2E_PROFILES ? profile : "local"
const profileConfig = DESKTOP_E2E_PROFILES[resolvedProfile]
const webDevServerURL = process.env.FOLO_E2E_WEB_DEV_SERVER_URL ?? "http://127.0.0.1:2233"
const debugProxyPath = process.env.FOLO_E2E_WEB_DEBUG_PROXY_PATH ?? "/__debug_proxy.html"
const webBaseURL =
resolvedProfile === "prod"
? new URL(
`${debugProxyPath}?debug-host=${encodeURIComponent(webDevServerURL)}`,
profileConfig.webURL,
).toString()
: profileConfig.webBaseURL
return {
profile: resolvedProfile,
apiURL: process.env.FOLO_E2E_API_URL ?? profileConfig.apiURL,
webURL: process.env.FOLO_E2E_WEB_URL ?? profileConfig.webURL,
webBaseURL,
webUsesHashRouter: profileConfig.webUsesHashRouter,
webDevServerURL,
debugProxyPath,
desktopAppDir,
}
}
export const buildWebAppURL = (env: DesktopE2EEnv, route = "/") => {
const normalizedRoute = normalizeRoute(route)
if (env.webUsesHashRouter) {
const url = new URL(env.webBaseURL)
url.hash = normalizedRoute
return url.toString()
}
return new URL(normalizedRoute, `${env.webBaseURL}/`).toString()
}
export const buildHashRoute = (route = "/") => normalizeRoute(route)

View File

@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
dismissFeedForm,
expectTimelineSwitchAndEntryReadFlow,
followOnboardingFeed,
loginWithCredential,
logoutFromProfileMenu,
registerWithCredential,
unsubscribeFirstFeedFromSettings,
} from "../../support/app"
import { closeElectronApp, launchElectronApp } from "../../support/electron"
import { resolveDesktopE2EEnv } from "../../support/env"
test.describe("electron core flows", () => {
test("covers registration, login, follow, unfollow, timeline and read state", async () => {
test.setTimeout(240_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("electron-core")
let electronApp = await launchElectronApp(env)
try {
await test.step("registers a new account", async () => {
await registerWithCredential(electronApp.page, account)
})
await test.step("logs out and logs back in", async () => {
await logoutFromProfileMenu(electronApp.page)
await closeElectronApp(electronApp)
electronApp = await launchElectronApp(env)
await loginWithCredential(electronApp.page, account)
})
await test.step("follows onboarding feed", async () => {
await followOnboardingFeed(electronApp.page, env, { electron: true })
await dismissFeedForm(electronApp.page)
})
await test.step("switches timeline, opens an entry, and toggles read state", async () => {
await expectTimelineSwitchAndEntryReadFlow(electronApp.page, env, { electron: true })
})
await test.step("unsubscribes onboarding feed from settings", async () => {
await unsubscribeFirstFeedFromSettings(electronApp.page, env)
})
const cleanup = await tryDeleteCurrentUser(electronApp.page, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
} finally {
await closeElectronApp(electronApp)
}
})
})

View File

@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
closeSettings,
dismissFeedForm,
expectOnboardingFeedUnsubscribed,
expectTimelineSwitchAndEntryReadFlow,
followOnboardingFeed,
loginWithCredential,
logoutFromProfileMenu,
openWebApp,
registerWithCredential,
unsubscribeFirstFeedFromSettings,
} from "../../support/app"
import { resolveDesktopE2EEnv } from "../../support/env"
test.describe("web core flows", () => {
test("covers registration, login, follow, unfollow, timeline and read state", async ({
page,
browser,
}) => {
test.setTimeout(180_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-core")
let activePage = page
let loginContext: Awaited<ReturnType<typeof browser.newContext>> | null = null
try {
await openWebApp(activePage, env)
await test.step("registers a new account", async () => {
await registerWithCredential(activePage, account)
})
await test.step("follows onboarding feed", async () => {
await followOnboardingFeed(activePage, env)
await dismissFeedForm(activePage)
})
await test.step("logs out and logs back in", async () => {
await logoutFromProfileMenu(activePage)
loginContext = await browser.newContext()
activePage = await loginContext.newPage()
await openWebApp(activePage, env)
await loginWithCredential(activePage, account)
})
await test.step("switches timeline, opens an entry, and toggles read state", async () => {
await expectTimelineSwitchAndEntryReadFlow(activePage, env)
})
await test.step("unsubscribes onboarding feed from settings", async () => {
await unsubscribeFirstFeedFromSettings(activePage, env)
await closeSettings(activePage)
await expectOnboardingFeedUnsubscribed(activePage, env)
})
await test.step("re-subscribes onboarding feed", async () => {
await followOnboardingFeed(activePage, env)
await dismissFeedForm(activePage)
})
await test.step("tries to clean up the temporary account", async () => {
const cleanup = await tryDeleteCurrentUser(activePage, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
})
} finally {
await loginContext?.close().catch(() => {})
}
})
})

View File

@ -0,0 +1,88 @@
import type { BrowserContext } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
getLanguageLabel,
loginWithCredential,
openSettings,
openWebApp,
registerWithCredential,
setLanguage,
} from "../../support/app"
import { resolveDesktopE2EEnv } from "../../support/env"
const closeContextSafely = async (context: BrowserContext) => {
try {
await context.close()
} catch (error) {
if (error instanceof Error && error.message.includes("ENOENT")) {
return
}
throw error
}
}
test.describe("web multi-session sync", () => {
test("syncs settings between two browser sessions", async ({ browser }) => {
test.setTimeout(180_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-sync")
const contextA = await browser.newContext()
const contextB = await browser.newContext()
const pageA = await contextA.newPage()
const pageB = await contextB.newPage()
try {
await openWebApp(pageA, env)
await registerWithCredential(pageA, account)
await openWebApp(pageB, env)
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 () => {
await pageB.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageB)
return getLanguageLabel(pageB)
},
{ timeout: 30_000 },
)
.toContain("日本語")
})
await test.step("session B change syncs back to session A", async () => {
await setLanguage(pageB, "English")
await expect
.poll(
async () => {
await pageA.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageA)
return getLanguageLabel(pageA)
},
{ timeout: 30_000 },
)
.toContain("English")
})
const cleanup = await tryDeleteCurrentUser(pageA, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
} finally {
await closeContextSafely(contextA)
await closeContextSafely(contextB)
}
})
})

View File

@ -1,7 +1,14 @@
import { app, protocol } from "electron"
import path from "pathe"
if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
const e2eUserDataDir = process.env.FOLO_E2E_USER_DATA_DIR
if (e2eUserDataDir) {
app.setPath("userData", e2eUserDataDir)
} else if (import.meta.env.DEV) {
app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
}
protocol.registerSchemesAsPrivileged([
{
scheme: "app",

View File

@ -21,6 +21,8 @@ export const CommandActionButton = ({
<ActionButton
ref={ref}
{...rest}
data-command-id={commandId}
data-testid={`command-action-${commandId.replaceAll(":", "-")}`}
tooltip={label.title}
tooltipDescription={label.description}
icon={icon}

View File

@ -9,6 +9,7 @@ export const ModalClose = () => {
return (
<MotionButtonBase
data-testid="modal-close"
aria-label={t("words.close")}
className="absolute right-6 top-6 flex size-8 items-center justify-center rounded-md duration-200 hover:bg-material-ultra-thick"
onClick={dismiss}

View File

@ -390,6 +390,7 @@ export const ModalInternal = memo(function Modal({
</Dialog.Title>
{canClose && (
<Dialog.DialogClose
data-testid="modal-close"
className="center z-[2] -mr-1 rounded-lg p-2 text-text-secondary hover:bg-fill-quaternary hover:text-text"
tabIndex={1}
onClick={close}

View File

@ -1,11 +1,21 @@
import { useCallback } from "react"
import { useGoogleReCaptcha } from "react-google-recaptcha-v3"
type FoloE2EWindow = Window &
typeof globalThis & {
__FOLO_E2E_RECAPTCHA_TOKEN__?: string
}
export const useRecaptchaToken = () => {
const { executeRecaptcha } = useGoogleReCaptcha()
return useCallback(
async (action: string) => {
const e2eToken = (window as FoloE2EWindow).__FOLO_E2E_RECAPTCHA_TOKEN__
if (e2eToken) {
return e2eToken
}
if (!executeRecaptcha) {
return null
}

View File

@ -111,7 +111,7 @@ export function LoginWithPassword({
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form data-testid="login-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
@ -119,7 +119,7 @@ export function LoginWithPassword({
<FormItem>
<FormLabel>{t("login.email")}</FormLabel>
<FormControl>
<Input type="email" {...field} />
<Input data-testid="login-email-input" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -147,7 +147,7 @@ export function LoginWithPassword({
</a>
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="login-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -155,6 +155,7 @@ export function LoginWithPassword({
/>
<div className="flex flex-col space-y-3">
<Button
data-testid="login-submit"
type="submit"
isLoading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
@ -172,6 +173,7 @@ export function LoginWithPassword({
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you don't have an account,{" "}
<button
data-testid="login-switch-register"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("register")}
@ -261,7 +263,11 @@ export function RegisterForm({
return (
<div className="relative">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form
data-testid="register-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
@ -269,7 +275,7 @@ export function RegisterForm({
<FormItem>
<FormLabel>{t("register.email")}</FormLabel>
<FormControl>
<Input type="email" {...field} />
<Input data-testid="register-email-input" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -286,7 +292,7 @@ export function RegisterForm({
: `${t("register.password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="register-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -303,13 +309,14 @@ export function RegisterForm({
: `${t("register.confirm_password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="register-confirm-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
data-testid="register-submit"
type="submit"
buttonClassName="w-full"
size="lg"
@ -327,6 +334,7 @@ export function RegisterForm({
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you already have an account,{" "}
<button
data-testid="register-switch-login"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("login")}

View File

@ -85,6 +85,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
transition={Spring.presets.smooth}
>
<MotionButtonBase
data-testid="auth-back"
className="flex cursor-button items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium duration-200 hover:bg-fill-secondary"
onClick={() => setIsEmail(false)}
>
@ -165,6 +166,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
<button
data-testid={`login-provider-${key}`}
type="button"
onClick={() => {
if (key === "credential") {
@ -250,6 +252,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{/* Switch Account Type */}
<m.button
data-testid={isRegister ? "register-switch-login" : "login-switch-register"}
className="group w-full cursor-pointer pb-2 text-center text-sm font-medium transition-colors"
onClick={() => setIsRegister(!isRegister)}
whileHover={{ scale: 1.02 }}
@ -284,6 +287,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div
onClick={stopPropagation}
tabIndex={-1}
data-testid="login-modal"
className="relative w-[28rem] overflow-hidden rounded-2xl border border-folo/20 bg-background p-6 shadow-2xl shadow-folo/10 backdrop-blur-xl"
>
{/* Inner glow layer */}

View File

@ -31,6 +31,8 @@ import { followClient } from "~/lib/api-client"
import { DiscoverFeedCard } from "./DiscoverFeedCard"
import { FeedForm } from "./FeedForm"
const isFeedLikeUrl = (value: string) => /^(?:https?:\/\/|folo:\/\/|follow:\/\/)/.test(value.trim())
const FEED_DISCOVERY_INFO = {
search: {
label: "discover.any_url_or_keyword",
@ -56,7 +58,9 @@ const FEED_DISCOVERY_INFO = {
</a>
),
schema: z.object({
keyword: z.string().url().startsWith("https://"),
keyword: z.string().refine(isFeedLikeUrl, {
message: "Invalid RSS URL",
}),
}),
},
rsshub: {
@ -301,6 +305,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
<FormControl>
<Input
autoFocus
data-testid="discover-form-input"
{...field}
onChange={handleKeywordChange}
onCompositionEnd={handleCompositionEnd}
@ -353,6 +358,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
)}
<div className="center flex" data-testid="discover-form-actions">
<Button
data-testid="discover-form-submit"
disabled={!form.formState.isValid}
type="submit"
isLoading={mutation.isPending}

View File

@ -347,6 +347,7 @@ const FeedInnerForm = ({
<Form {...form}>
<form
id="feed-form"
data-testid="feed-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col gap-y-4 px-1"
>
@ -361,7 +362,11 @@ const FeedInnerForm = ({
</div>
<FormControl>
<div className="flex gap-2">
<Input placeholder={feed.title || undefined} {...field} />
<Input
data-testid="feed-form-title-input"
placeholder={feed.title || undefined}
{...field}
/>
<Button
buttonClassName="shrink-0"
type="button"
@ -480,6 +485,7 @@ const FeedInnerForm = ({
{isSubscribed && (
<Button
disabled={!isLoggedIn}
data-testid="feed-form-cancel"
type="button"
variant="ghost"
onClick={() => {
@ -491,6 +497,7 @@ const FeedInnerForm = ({
)}
<Button
disabled={!isLoggedIn}
data-testid="feed-form-submit"
form="feed-form"
type="submit"
isLoading={followMutation.isPending}

View File

@ -40,13 +40,18 @@ import { DiscoverTransform } from "./DiscoverTransform"
import { DiscoverUser } from "./DiscoverUser"
import { FeedForm } from "./FeedForm"
const isFeedLikeUrl = (value: string) => {
const trimmed = value.trim()
return /^(?:https?:\/\/|rsshub:\/\/|folo:\/\/|follow:\/\/)/.test(trimmed)
}
// Auto-detect input type
function detectInputType(value: string): "rss" | "rsshub" | "search" {
const trimmed = value.trim()
if (trimmed.startsWith("rsshub://")) {
return "rsshub"
}
if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
if (isFeedLikeUrl(trimmed) && !trimmed.startsWith("rsshub://")) {
return "rss"
}
return "search"
@ -58,7 +63,9 @@ const searchSchema = z.object({
})
const rssSchema = z.object({
keyword: z.string().url().startsWith("https://"),
keyword: z.string().refine(isFeedLikeUrl, {
message: "Invalid RSS URL",
}),
})
const rsshubSchema = z.object({
@ -306,6 +313,7 @@ export function UnifiedDiscoverForm() {
<FormControl>
<Input
autoFocus
data-testid="discover-form-input"
{...field}
value={field.value || ""}
onChange={handleKeywordChange}
@ -397,6 +405,7 @@ export function UnifiedDiscoverForm() {
)}
<div className="center flex flex-col gap-3" data-testid="discover-form-actions">
<Button
data-testid="discover-form-submit"
disabled={!form.formState.isValid}
type="submit"
isLoading={mutation.isPending}

View File

@ -155,7 +155,12 @@ export const EntryItemWrapper: FC<
const Link = view === FeedViewType.SocialMedia ? "article" : NavLink
const isAll = view === FeedViewType.All
return (
<div data-entry-id={entry?.id} style={style}>
<div
data-entry-id={entry?.id}
data-read={asRead ? "true" : "false"}
data-active={isActive ? "true" : "false"}
style={style}
>
<Link
to={navigationPath}
className={cn(

View File

@ -8,7 +8,7 @@ export const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => voi
<div className="w-[540px]">
<div className="mb-4 text-sm">{t("sidebar.feed_actions.unfollow_feed_many_warning")}</div>
<div className="flex justify-end">
<Button buttonClassName="bg-red" onClick={onConfirm}>
<Button data-testid="confirm-destroy" buttonClassName="bg-red" onClick={onConfirm}>
{t("words.confirm")}
</Button>
</div>

View File

@ -6,7 +6,6 @@ import { getStorageNS } from "@follow/utils/ns"
import { isEmptyObject, sleep } from "@follow/utils/utils"
import type { SettingsTab } from "@follow-app/client-sdk"
import { FollowAPIError } from "@follow-app/client-sdk"
import { omit } from "es-toolkit/compat"
import type { PrimitiveAtom } from "jotai"
import { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from "~/atoms/settings/ai"
@ -26,17 +25,28 @@ type SettingMapping = {
ai: AISettings
}
const omitKeys = []
const pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {
const nextPayload = {} as Partial<T>
const record = payload as Record<string, unknown>
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
nextPayload[key as keyof T] = record[key as string] as T[keyof T]
}
}
return nextPayload
}
const localSettingGetterMap = {
appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys),
general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys),
ai: () => omit(getAISettings(), aiServerSyncWhiteListKeys, omitKeys),
appearance: () => getUISettings(),
general: () => getGeneralSettings(),
ai: () => getAISettings(),
}
const createInternalSetter =
<T>(atom: PrimitiveAtom<T>) =>
(payload: T) => {
(payload: Partial<T>) => {
const current = jotaiStore.get(atom)
jotaiStore.set(atom, { ...current, ...payload })
}
@ -125,7 +135,7 @@ class SettingSyncQueue {
const tab = bizSettingKeyToTabMapping[data.key]
if (!tab) return
const nextPayload = omit(data.payload, omitKeys, settingWhiteListMap[tab])
const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab])
if (isEmptyObject(nextPayload)) return
this.enqueue(tab, nextPayload)
})
@ -207,7 +217,7 @@ class SettingSyncQueue {
private chain = Promise.resolve()
private threshold = 1000
private enqueueTime = Date.now()
private flushScheduled = false
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
@ -227,10 +237,20 @@ class SettingSyncQueue {
date: now,
})
if (now - this.enqueueTime > this.threshold) {
this.chain = this.chain.then(() => sleep(this.threshold)).finally(() => this.flush())
this.enqueueTime = Date.now()
if (this.flushScheduled) {
return
}
this.flushScheduled = true
this.chain = this.chain
.finally(() => sleep(this.threshold))
.finally(async () => {
try {
await this.flush()
} finally {
this.flushScheduled = false
}
})
}
private async flush() {
@ -264,7 +284,7 @@ class SettingSyncQueue {
const promises = [] as Promise<any>[]
for (const tab in groupedTab) {
const json = omit(groupedTab[tab], omitKeys, settingWhiteListMap[tab])
const json = pickSyncPayload(groupedTab[tab], settingWhiteListMap[tab])
if (isEmptyObject(json)) {
continue
@ -312,7 +332,7 @@ class SettingSyncQueue {
if (!tab) {
const promises = [] as Promise<any>[]
for (const tab in localSettingGetterMap) {
const payload = localSettingGetterMap[tab]()
const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])
const promise = followClient.api.settings.update({
tab: tab as SettingsTab,
@ -325,7 +345,7 @@ class SettingSyncQueue {
this.chain = this.chain.finally(() => Promise.all(promises))
return this.chain
} else {
const payload = localSettingGetterMap[tab]()
const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])
this.chain = this.chain.finally(() =>
followClient.api.settings.update({
@ -377,7 +397,7 @@ class SettingSyncQueue {
if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) {
// Use remote and update local
const nextPayload = omit(remoteSettingPayload, omitKeys, settingWhiteListMap[tab])
const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[tab])
if (isEmptyObject(nextPayload)) {
continue

View File

@ -178,6 +178,7 @@ const SettingItemButtonImpl = (props: {
return (
<button
data-testid={`settings-tab-${path}`}
className={cn(
"my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text",
isActive && "!bg-theme-item-active !text-text",

View File

@ -328,6 +328,7 @@ const SubscriptionFeedsSection = () => {
</DropdownMenu>
<MotionButtonBase
data-testid="feeds-batch-unsubscribe"
className="text-xs text-red transition-colors hover:text-red/80"
type="button"
onClick={handleBatchUnsubscribe}
@ -529,6 +530,7 @@ const FeedListItem = memo(
return (
<div
data-id={id}
data-testid={`settings-feed-row-${id}`}
role="button"
tabIndex={-1}
className={clsx(

View File

@ -260,6 +260,7 @@ export const LanguageSelector = ({
<ResponsiveSelect
size="sm"
triggerClassName="w-48"
triggerTestId="settings-language-select"
contentClassName={contentClassName}
defaultValue={finalRenderLanguage}
value={finalRenderLanguage}

View File

@ -18,6 +18,9 @@ import { useContextMenu } from "~/hooks/common/useContextMenu"
import { resetSelectedFeedIds } from "./atom"
import { useShowTimelineTabsSettingsModal } from "./TimelineTabsSettingsModal"
const getTimelineTabTestId = (name: string) =>
`timeline-tab-${name.split(".").pop()?.replaceAll("_", "-")}`
export function SubscriptionTabButton({
timelineId,
shortcut,
@ -153,6 +156,7 @@ const ViewAllSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
key={item.name}
tooltip={t(item.name, { ns: "common" })}
@ -214,6 +218,7 @@ const ViewSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
ref={setNodeRef}
key={item.name}

View File

@ -14,6 +14,7 @@ export const LoginButton: FC<LoginProps> = (props) => {
const { t } = useTranslation()
const Content = (
<ActionButton
data-testid="login-button"
className="relative z-[1]"
onClick={
method === "modal"

View File

@ -63,6 +63,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuTrigger
asChild
className="!outline-none focus-visible:bg-theme-item-hover data-[state=open]:bg-transparent"
data-testid="profile-menu-trigger"
>
{props.animatedAvatar ? (
<TransitionAvatar stage={dropdown ? "zoom-in" : ""} />
@ -154,6 +155,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuSeparator />
<DropdownMenuItem
className="pl-3"
data-testid="profile-menu-preferences"
onClick={() => {
settingModalPresent()
}}
@ -202,6 +204,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
)}
<DropdownMenuItem
className="pl-3"
data-testid="profile-menu-logout"
onClick={signOut}
icon={<i className="i-mgc-exit-cute-re" />}
>

View File

@ -7,8 +7,10 @@ import { NotFound } from "./components/common/NotFound"
// @ts-ignore
import { routes as tree } from "./generated-routes"
const routerCreator =
IN_ELECTRON || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
const isDebugProxyRuntime =
!!globalThis["__DEBUG_PROXY__"] || globalThis.location?.pathname?.startsWith("/__debug_proxy")
const routerCreator = IN_ELECTRON || isDebugProxyRuntime ? createHashRouter : createBrowserRouter
export const router = routerCreator([
{

View File

@ -6,8 +6,10 @@ import { NotFound } from "./components/common/NotFound"
// @ts-ignore
import { routes as tree } from "./generated-routes"
const routerCreator =
IN_ELECTRON || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
const isDebugProxyRuntime =
!!globalThis["__DEBUG_PROXY__"] || globalThis.location?.pathname?.startsWith("/__debug_proxy")
const routerCreator = IN_ELECTRON || isDebugProxyRuntime ? createHashRouter : createBrowserRouter
export const router = routerCreator([
{

View File

@ -29,6 +29,12 @@
"dev:electron": "electron-vite dev",
"dev:server": "pnpm run --filter=ssr dev",
"dev:web": "cross-env WEB_BUILD=1 vite",
"e2e": "playwright test -c e2e/playwright.config.ts",
"e2e:electron": "playwright test -c e2e/playwright.config.ts --project=electron",
"e2e:electron:prod": "cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=electron",
"e2e:install": "playwright install chromium",
"e2e:web": "playwright test -c e2e/playwright.config.ts --project=web",
"e2e:web:prod": "cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=web",
"hotfix": "vv -c bump.hotfix.config.js --patch",
"prepare:cli": "tsx scripts/prepare-cli.ts",
"publish": "electron-vite build && electron-forge publish",
@ -53,6 +59,7 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@pengx17/electron-forge-maker-appimage": "1.2.1",
"@playwright/test": "1.58.2",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@vitejs/plugin-legacy": "7.2.1",

View File

@ -32,11 +32,11 @@ const devPrint = (): PluginOption => ({
server.printUrls = () => {
_printUrls()
console.info(
` ${green("➜")} ${dim("Production debug")}: ${cyan("https://app.folo.is/__debug_proxy")}`,
` ${green("➜")} ${dim("Production debug")}: ${cyan("https://app.folo.is/__debug_proxy.html")}`,
)
console.info(
` ${green("➜")} ${dim("Development debug")}: ${cyan(
"https://dev.folo.is/__debug_proxy",
"https://dev.folo.is/__debug_proxy.html",
)}`,
)
}
@ -111,6 +111,12 @@ export default ({ mode }) => {
ignored: ["**/dist/**", "**/out/**", "**/public/**", ".git/**"],
},
cors: true,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Private-Network": "true",
},
proxy: {
"/login": proxyConfig,
"/forget-password": proxyConfig,
@ -328,11 +334,15 @@ const htmlPlugin: (env: any) => PluginOption = (env) => {
if (existsSync(debugProxyHtml)) {
const content = readFileSync(debugProxyHtml, "utf-8")
mkdirSync(dist, { recursive: true })
writeFileSync(
join(dist, "__debug_proxy.html"),
content.replace("import.meta.env.VITE_API_URL", `"${env.VITE_API_URL}"`),
const debugProxyContent = content.replace(
"import.meta.env.VITE_API_URL",
`"${env.VITE_API_URL}"`,
)
mkdirSync(dist, { recursive: true })
mkdirSync(join(dist, "__debug_proxy"), { recursive: true })
writeFileSync(join(dist, "__debug_proxy.html"), debugProxyContent)
writeFileSync(join(dist, "__debug_proxy", "index.html"), debugProxyContent)
}
},
transformIndexHtml(html) {

View File

@ -42,6 +42,8 @@ app-example
ios/Pods
android
!e2e/flows/android/
!e2e/flows/android/**
Podfile.lock
buildServer.json

View File

@ -25,6 +25,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
eas: {
projectId: "a6335b14-fb84-45aa-ba80-6f6ab8926920",
},
e2eEnvProfile: process.env.EXPO_PUBLIC_E2E_ENV_PROFILE ?? null,
e2eLanguage: process.env.EXPO_PUBLIC_E2E_LANGUAGE ?? null,
},
owner: "follow",
// disable expo updates for now, https://github.com/expo/expo/issues/29630

32
apps/mobile/e2e/README.md Normal file
View File

@ -0,0 +1,32 @@
# Mobile E2E
## Requirements
- Install Maestro CLI.
- Android: install the app on a booted emulator.
- iOS: provide a standalone simulator app bundle via `MAESTRO_IOS_APP_PATH`, or place a local `build-*.tar.gz` from `eas build --local --platform ios --profile e2e-ios-simulator` in `apps/mobile`.
- Export `E2E_EMAIL` if you want to reuse a fixed account. Otherwise the runner script creates a unique address.
## Commands
```bash
pnpm run e2e:doctor
pnpm run e2e:android
pnpm run e2e:ios
```
## iOS Notes
- `pnpm run e2e:ios` runs two real journeys in sequence:
- `auth.yaml`: register -> sign out -> log in
- `content.yaml`: ensure onboarding feed unfollowed -> follow -> timeline/read-unread -> unfollow
- The iOS runner resets the simulator, disables password autofill prompts, installs the provided app bundle, then executes Maestro.
## Environment
- `E2E_EMAIL`
- `E2E_PASSWORD`
- `MAESTRO_DEBUG_OUTPUT`
- `MAESTRO_IOS_APP_PATH`
- `EXPO_PUBLIC_E2E_ENV_PROFILE`
- `EXPO_PUBLIC_E2E_LANGUAGE`

View File

@ -0,0 +1,17 @@
appId: is.follow
name: Android core journey
---
- runFlow:
file: ../shared/register.yaml
- runFlow:
file: ../shared/sign-out.yaml
- runFlow:
file: ../shared/login.yaml
- runFlow:
file: ../shared/ensure-onboarding-unfollowed.yaml
- runFlow:
file: ../shared/follow-onboarding.yaml
- runFlow:
file: ./timeline-entry.yaml
- runFlow:
file: ../shared/unfollow-onboarding.yaml

View File

@ -0,0 +1,52 @@
appId: is.follow
---
- tapOn:
id: tab-IndexTabScreen
- tapOn:
id: timeline-view-videos
- tapOn:
id: timeline-view-articles
- extendedWaitUntil:
visible:
id: timeline-entry-first
timeout: 20000
- tapOn:
point: "540,520"
- extendedWaitUntil:
notVisible:
id: timeline-entry-first
timeout: 20000
- pressKey: Back
- tapOn:
id: timeline-view-articles
- extendedWaitUntil:
visible:
id: timeline-entry-first
timeout: 20000
- longPressOn:
id: timeline-entry-first
- runFlow:
when:
visible: Mark as Read
commands:
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- runFlow:
when:
visible: Mark as Unread
commands:
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread
- pressKey: Back

View File

@ -0,0 +1,11 @@
appId: is.follow
name: iOS auth journey
---
- launchApp:
clearState: true
- runFlow:
file: ./register.yaml
- runFlow:
file: ./sign-out.yaml
- runFlow:
file: ./login.yaml

View File

@ -0,0 +1,11 @@
appId: is.follow
name: iOS content journey
---
- runFlow:
file: ./ensure-onboarding-unfollowed.yaml
- runFlow:
file: ./follow-onboarding.yaml
- runFlow:
file: ./timeline-entry.yaml
- runFlow:
file: ./unfollow-onboarding.yaml

View File

@ -0,0 +1,19 @@
appId: is.follow
name: iOS core journey
---
- launchApp:
clearState: true
- runFlow:
file: ./register.yaml
- runFlow:
file: ./sign-out.yaml
- runFlow:
file: ./login.yaml
- runFlow:
file: ./ensure-onboarding-unfollowed.yaml
- runFlow:
file: ./follow-onboarding.yaml
- runFlow:
file: ./timeline-entry.yaml
- runFlow:
file: ./unfollow-onboarding.yaml

View File

@ -0,0 +1,43 @@
appId: is.follow
---
- tapOn:
text: Discover
- tapOn:
id: discover-search-input
- eraseText
- inputText: folo://onboarding
- pressKey: Enter
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- tapOn:
id: discover-feed-follow-action
- runFlow:
when:
visible:
id: follow-unfollow
commands:
- tapOn:
id: follow-unfollow
- extendedWaitUntil:
visible: Unsubscribe?
timeout: 10000
- tapOn:
point: "275,497"
- runFlow:
when:
visible:
id: follow-submit
commands:
- tapOn:
id: navigation-back
- runFlow:
when:
visible:
id: navigation-back
commands:
- tapOn:
id: navigation-back
- extendedWaitUntil:
visible: Discover
timeout: 20000

View File

@ -0,0 +1,34 @@
appId: is.follow
---
- tapOn:
text: Discover
- tapOn:
id: discover-search-input
- eraseText
- inputText: folo://onboarding
- pressKey: Enter
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- tapOn:
id: discover-feed-follow-action
- runFlow:
when:
visible:
id: follow-submit
commands:
- tapOn:
id: follow-submit
- runFlow:
when:
visible:
id: follow-unfollow
commands:
- tapOn:
id: navigation-back
- tapOn:
text: Subscriptions
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- assertVisible: Welcome to Folo

View File

@ -0,0 +1,37 @@
appId: is.follow
---
- runFlow: ../shared/open-auth.yaml
- tapOn:
id: auth-toggle-mode
- extendedWaitUntil:
visible: Don't have an account? Sign up
timeout: 10000
- tapOn:
text: Continue with Email
- extendedWaitUntil:
visible:
id: login-email-input
timeout: 15000
- tapOn:
id: login-email-input
- inputText: ${E2E_EMAIL}
- tapOn:
id: login-password-input
- setClipboard: ${E2E_PASSWORD}
- pasteText
- pressKey: Enter
- extendedWaitUntil:
notVisible:
id: login-screen
timeout: 30000
- launchApp
- runFlow:
when:
notVisible:
id: timeline-view-articles
commands:
- launchApp
- extendedWaitUntil:
visible:
id: timeline-view-articles
timeout: 30000

View File

@ -0,0 +1,48 @@
appId: is.follow
---
- runFlow: ../shared/open-auth.yaml
- tapOn:
text: Continue with Email
- extendedWaitUntil:
visible:
id: register-email-input
timeout: 15000
- tapOn:
id: register-email-input
- inputText: ${E2E_EMAIL}
- tapOn:
id: register-password-input
- waitForAnimationToEnd
- tapOn:
point: "352,580"
- tapOn:
id: register-password-input
- setClipboard: ${E2E_PASSWORD}
- pasteText
- tapOn:
id: register-confirm-password-input
- waitForAnimationToEnd
- tapOn:
point: "352,580"
- tapOn:
id: register-confirm-password-input
- setClipboard: ${E2E_PASSWORD}
- pasteText
- pressKey: Enter
- runFlow:
when:
visible:
id: onboarding-next
commands:
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- extendedWaitUntil:
visible:
id: timeline-view-articles
timeout: 30000

View File

@ -0,0 +1,23 @@
appId: is.follow
---
- tapOn:
text: Settings
- scrollUntilVisible:
element:
id: settings-sign-out
direction: DOWN
timeout: 10000
centerElement: false
- tapOn:
id: settings-sign-out
- extendedWaitUntil:
visible: Sign out
timeout: 10000
- tapOn:
point: "275,487"
- extendedWaitUntil:
visible:
id: login-screen
timeout: 30000
- assertVisible:
id: login-screen

View File

@ -0,0 +1,80 @@
appId: is.follow
---
- tapOn:
text: Home
- tapOn:
id: timeline-view-videos
- tapOn:
id: timeline-view-articles
- extendedWaitUntil:
visible:
id: timeline-entry-first
timeout: 20000
- tapOn:
id: timeline-entry-first
point: "50%,50%"
retryTapIfNoChange: true
- extendedWaitUntil:
notVisible:
id: timeline-entry-first
timeout: 20000
- extendedWaitUntil:
visible:
id: navigation-back
timeout: 20000
- tapOn:
id: navigation-back
- extendedWaitUntil:
visible:
id: timeline-entry-first
timeout: 20000
- longPressOn:
id: timeline-entry-first
- runFlow:
when:
visible: Mark as Read
commands:
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- runFlow:
when:
visible: Mark as Unread
commands:
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread
- runFlow:
when:
visible: Mark as Read
commands:
- tapOn:
point: "200,120"
- runFlow:
when:
visible: Mark as Unread
commands:
- tapOn:
point: "200,120"
- runFlow:
when:
visible:
id: navigation-back
commands:
- tapOn:
id: navigation-back
- extendedWaitUntil:
visible:
id: timeline-view-articles
timeout: 20000

View File

@ -0,0 +1,35 @@
appId: is.follow
---
- tapOn:
text: Discover
- tapOn:
id: discover-search-input
- eraseText
- inputText: folo://onboarding
- pressKey: Enter
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- tapOn:
id: discover-feed-follow-action
- extendedWaitUntil:
visible:
id: follow-unfollow
timeout: 20000
- tapOn:
id: follow-unfollow
- extendedWaitUntil:
visible: Unsubscribe?
timeout: 10000
- tapOn:
point: "275,497"
- runFlow:
when:
visible:
id: navigation-back
commands:
- tapOn:
id: navigation-back
- extendedWaitUntil:
visible: Discover
timeout: 20000

View File

@ -0,0 +1,9 @@
appId: is.follow
---
- runFlow: register.yaml
- runFlow: sign-out.yaml
- runFlow: login.yaml
- runFlow: ensure-onboarding-unfollowed.yaml
- runFlow: follow-onboarding.yaml
- runFlow: timeline-entry.yaml
- runFlow: unfollow-onboarding.yaml

View File

@ -0,0 +1,9 @@
appId: is.follow
---
- runFlow:
when:
visible:
text: Siri, Dictation & Privacy
commands:
- tapOn:
point: "8%,8%"

View File

@ -0,0 +1,23 @@
appId: is.follow
---
- tapOn:
id: tab-SubscriptionsTabScreen
- runFlow:
when:
visible:
id: subscription-feed-url-folo-onboarding
commands:
- longPressOn:
id: subscription-feed-url-folo-onboarding
- extendedWaitUntil:
visible: Unfollow
timeout: 10000
- tapOn: Unfollow
- tapOn:
text: Unfollow
- extendedWaitUntil:
notVisible:
id: subscription-feed-url-folo-onboarding
timeout: 20000
- assertVisible:
id: tab-IndexTabScreen

View File

@ -0,0 +1,27 @@
appId: is.follow
---
- tapOn:
id: tab-DiscoverTabScreen
- tapOn:
id: discover-search-input
- inputText: folo://onboarding
- pressKey: Enter
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- tapOn:
id: discover-feed-follow-action
- extendedWaitUntil:
visible:
id: follow-submit
timeout: 20000
- tapOn:
id: follow-submit
- tapOn:
id: tab-SubscriptionsTabScreen
- extendedWaitUntil:
visible: Welcome to Folo
timeout: 20000
- assertVisible: Welcome to Folo
- assertVisible:
id: tab-IndexTabScreen

View File

@ -0,0 +1,38 @@
appId: is.follow
---
- runFlow: open-auth.yaml
- tapOn:
id: auth-toggle-mode
- extendedWaitUntil:
visible: Don't have an account? Sign up
timeout: 10000
- tapOn:
text: Continue with Email
- extendedWaitUntil:
visible:
id: login-email-input
timeout: 15000
- tapOn:
id: login-email-input
- inputText: ${E2E_EMAIL}
- tapOn:
id: login-password-input
- eraseText
- setClipboard: ${E2E_PASSWORD}
- pasteText
- tapOn:
id: login-submit
- extendedWaitUntil:
notVisible:
id: login-screen
timeout: 30000
- extendedWaitUntil:
visible:
id: tab-IndexTabScreen
timeout: 30000
- tapOn:
id: tab-IndexTabScreen
- extendedWaitUntil:
visible:
id: timeline-view-articles
timeout: 30000

View File

@ -0,0 +1,73 @@
appId: is.follow
---
- waitForAnimationToEnd:
timeout: 15000
- runFlow:
when:
visible:
id: onboarding-next
commands:
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- runFlow:
when:
notVisible:
text: Continue with Email
commands:
- runFlow:
when:
visible:
id: no-login-timeline
commands:
- tapOn:
id: no-login-timeline
- runFlow:
when:
notVisible:
id: no-login-timeline
commands:
- runFlow:
when:
visible:
id: home-avatar-trigger
commands:
- tapOn:
id: home-avatar-trigger
- runFlow:
when:
notVisible:
id: home-avatar-trigger
commands:
- runFlow:
when:
visible:
id: tab-SettingsTabScreen
commands:
- tapOn:
id: tab-SettingsTabScreen
- runFlow:
when:
notVisible:
id: tab-SettingsTabScreen
commands:
- tapOn:
text: Settings
- runFlow:
when:
visible:
id: settings-sign-in
commands:
- tapOn:
id: settings-sign-in
- extendedWaitUntil:
visible:
text: Continue with Email
timeout: 15000
- assertVisible:
text: Continue with Email

View File

@ -0,0 +1,41 @@
appId: is.follow
---
- runFlow: open-auth.yaml
- tapOn:
text: Continue with Email
- extendedWaitUntil:
visible:
id: register-email-input
timeout: 15000
- tapOn:
id: register-email-input
- inputText: ${E2E_EMAIL}
- tapOn:
id: register-password-input
- eraseText
- setClipboard: ${E2E_PASSWORD}
- pasteText
- tapOn:
id: register-confirm-password-input
- eraseText
- setClipboard: ${E2E_PASSWORD}
- pasteText
- tapOn:
id: register-submit
- runFlow:
when:
visible:
id: onboarding-next
commands:
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- tapOn:
id: onboarding-next
- extendedWaitUntil:
visible:
id: timeline-view-articles
timeout: 30000

View File

@ -0,0 +1,20 @@
appId: is.follow
---
- tapOn:
id: tab-SettingsTabScreen
- scrollUntilVisible:
element:
text: Sign Out
direction: DOWN
timeout: 10000
centerElement: false
- tapOn:
text: Sign Out
- tapOn:
text: Sign out
- extendedWaitUntil:
visible:
id: login-screen
timeout: 30000
- assertVisible:
id: login-screen

View File

@ -0,0 +1,48 @@
appId: is.follow
---
- tapOn:
id: tab-IndexTabScreen
- tapOn:
id: timeline-view-videos
- tapOn:
id: timeline-view-articles
- assertVisible:
id: timeline-entry-first
- tapOn:
id: timeline-entry-first
- extendedWaitUntil:
visible:
id: navigation-back
timeout: 20000
- tapOn:
id: navigation-back
- extendedWaitUntil:
visible:
id: timeline-entry-first
timeout: 20000
- longPressOn:
id: timeline-entry-first
- runFlow:
when:
visible: Mark as Read
commands:
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- runFlow:
when:
visible: Mark as Unread
commands:
- tapOn: Mark as Unread
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Read
- tapOn: Mark as Read
- longPressOn:
id: timeline-entry-first
- assertVisible: Mark as Unread

View File

@ -0,0 +1,24 @@
appId: is.follow
---
- tapOn:
id: tab-SubscriptionsTabScreen
- extendedWaitUntil:
visible:
id: subscription-feed-url-folo-onboarding
timeout: 20000
- longPressOn:
id: subscription-feed-url-folo-onboarding
- extendedWaitUntil:
visible: Unfollow
timeout: 10000
- tapOn: Unfollow
- tapOn:
text: Unfollow
- extendedWaitUntil:
notVisible:
id: subscription-feed-url-folo-onboarding
timeout: 20000
- assertNotVisible:
id: subscription-feed-url-folo-onboarding
- assertVisible:
id: tab-IndexTabScreen

144
apps/mobile/e2e/run-maestro.sh Executable file
View File

@ -0,0 +1,144 @@
#!/usr/bin/env sh
set -eu
platform="${1:?platform is required}"
debug_output="${MAESTRO_DEBUG_OUTPUT:-e2e/artifacts/${platform}}"
run_suffix="$(date +%s)-$$"
mkdir -p "${debug_output}"
: "${E2E_PASSWORD:=Password123!}"
: "${E2E_EMAIL:=folo-e2e-${platform}-${run_suffix}@example.com}"
export E2E_EMAIL
export E2E_PASSWORD
resolve_ios_device() {
if [ -n "${MAESTRO_IOS_DEVICE_ID:-}" ]; then
printf '%s' "${MAESTRO_IOS_DEVICE_ID}"
return
fi
xcrun simctl list devices booted | awk -F '[()]' '/Booted/ && $2 ~ /^[A-F0-9-]+$/ { print $2; exit }'
}
resolve_android_device() {
if [ -n "${MAESTRO_ANDROID_DEVICE_ID:-}" ]; then
printf '%s' "${MAESTRO_ANDROID_DEVICE_ID}"
return
fi
adb devices | awk '$2 == "device" && $1 != "List" { print $1; exit }'
}
extract_ios_app_from_tar() {
tar_path="$1"
dest_dir="${2:?destination is required}"
mkdir -p "${dest_dir}"
tar -xzf "${tar_path}" -C "${dest_dir}"
find "${dest_dir}" -maxdepth 3 -name 'Folo.app' | head -n1
}
resolve_ios_app_path() {
if [ -n "${MAESTRO_IOS_APP_PATH:-}" ]; then
if [ -d "${MAESTRO_IOS_APP_PATH}" ]; then
printf '%s' "${MAESTRO_IOS_APP_PATH}"
return
fi
if [ -f "${MAESTRO_IOS_APP_PATH}" ] && echo "${MAESTRO_IOS_APP_PATH}" | grep -q '\.tar\.gz$'; then
extract_dir="$(mktemp -d /tmp/folo-ios-app-XXXXXX)"
extract_ios_app_from_tar "${MAESTRO_IOS_APP_PATH}" "${extract_dir}"
return
fi
fi
latest_tar="$(find . -maxdepth 1 -name 'build-*.tar.gz' | sort | tail -n1)"
if [ -n "${latest_tar}" ]; then
extract_dir="$(mktemp -d /tmp/folo-ios-app-XXXXXX)"
extract_ios_app_from_tar "${latest_tar}" "${extract_dir}"
return
fi
find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1
}
prepare_ios_simulator() {
device_id="$1"
xcrun simctl shutdown "${device_id}" >/dev/null 2>&1 || true
xcrun simctl erase "${device_id}" >/dev/null 2>&1 || true
xcrun simctl boot "${device_id}" >/dev/null 2>&1 || true
xcrun simctl bootstatus "${device_id}" -b >/dev/null 2>&1 || true
xcrun simctl shutdown "${device_id}" >/dev/null 2>&1 || true
simulator_data="$HOME/Library/Developer/CoreSimulator/Devices/${device_id}/data"
for rel in \
Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles/UserSettings.plist \
Library/UserConfigurationProfiles/EffectiveUserSettings.plist \
Library/UserConfigurationProfiles/PublicInfo/PublicEffectiveUserSettings.plist
do
file="${simulator_data}/${rel}"
if [ -f "${file}" ]; then
/usr/libexec/PlistBuddy -c 'Add :restrictedBool dict' "${file}" 2>/dev/null || true
/usr/libexec/PlistBuddy -c 'Add :restrictedBool:allowPasswordAutoFill dict' "${file}" 2>/dev/null || true
/usr/libexec/PlistBuddy -c 'Set :restrictedBool:allowPasswordAutoFill:value false' "${file}" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c 'Add :restrictedBool:allowPasswordAutoFill:value bool false' "${file}" >/dev/null 2>&1 || true
fi
done
xcrun simctl boot "${device_id}" >/dev/null 2>&1 || true
xcrun simctl bootstatus "${device_id}" -b >/dev/null 2>&1 || true
}
case "${platform}" in
ios)
device_id="$(resolve_ios_device)"
if [ -z "${device_id}" ]; then
echo "No booted iOS simulator found. Set MAESTRO_IOS_DEVICE_ID or boot a simulator first." >&2
exit 1
fi
app_path="$(resolve_ios_app_path)"
if [ -z "${app_path}" ] || [ ! -d "${app_path}" ]; then
echo "Unable to resolve a built iOS .app bundle. Set MAESTRO_IOS_APP_PATH or place a build-*.tar.gz in apps/mobile." >&2
exit 1
fi
prepare_ios_simulator "${device_id}"
xcrun simctl install "${device_id}" "${app_path}" >/dev/null 2>&1 || true
xcrun simctl launch "${device_id}" is.follow >/dev/null 2>&1 || true
auth_ok=0
for attempt in 1 2 3; do
if maestro test --format junit --platform ios --device "${device_id}" --debug-output "${debug_output}/auth-${attempt}" \
-e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" e2e/flows/ios/auth.yaml; then
auth_ok=1
break
fi
done
if [ "${auth_ok}" -ne 1 ]; then
echo "iOS auth journey failed after retries." >&2
exit 1
fi
maestro test --format junit --platform ios --device "${device_id}" --debug-output "${debug_output}/content" \
-e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" e2e/flows/ios/content.yaml
;;
android)
flow_target="e2e/flows/${platform}/core.yaml"
device_id="$(resolve_android_device)"
if [ -z "${device_id}" ]; then
echo "No booted Android emulator found. Set MAESTRO_ANDROID_DEVICE_ID or boot an emulator first." >&2
exit 1
fi
adb -s "${device_id}" shell pm clear is.follow >/dev/null 2>&1 || true
adb -s "${device_id}" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
maestro test --format junit --platform android --device "${device_id}" --debug-output "${debug_output}" \
-e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" "${flow_target}"
;;
*)
echo "Unsupported platform: ${platform}" >&2
exit 1
;;
esac

View File

@ -28,6 +28,25 @@
"PROFILE": "preview"
}
},
"e2e-android": {
"extends": "preview",
"env": {
"PROFILE": "e2e-android",
"EXPO_PUBLIC_E2E_ENV_PROFILE": "prod",
"EXPO_PUBLIC_E2E_LANGUAGE": "en"
}
},
"e2e-ios-simulator": {
"extends": "preview",
"ios": {
"simulator": true
},
"env": {
"PROFILE": "e2e-ios-simulator",
"EXPO_PUBLIC_E2E_ENV_PROFILE": "prod",
"EXPO_PUBLIC_E2E_LANGUAGE": "en"
}
},
"production": {
"autoIncrement": true,
"channel": "production",

View File

@ -9,6 +9,6 @@
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>0.0.0-dev</string>
<string>0.3.0</string>
</dict>
</plist>

View File

@ -13,15 +13,38 @@ install! 'cocoapods',
prepare_react_native_project!
def generate_react_native_codegen!(ios_root)
app_root = File.expand_path('..', ios_root)
react_native_root = File.dirname(`node --print "require.resolve('react-native/package.json', { paths: ['#{ios_root}'] })"`.strip)
react_native_codegen_cli = File.join(react_native_root, 'scripts', 'generate-codegen-artifacts.js')
Pod::UI.puts 'Generating React Native codegen artifacts for iOS...'
codegen_output_root = File.join(ios_root, 'build', 'generated', 'ios')
system(
'node',
react_native_codegen_cli,
'--path',
app_root,
'--targetPlatform',
'ios',
'--outputPath',
codegen_output_root,
) || raise('React Native iOS codegen generation failed')
end
target 'Folo' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
expo_modules_autolinking_root = File.dirname(`node --print "require.resolve('expo-modules-autolinking/package.json', { paths: ['#{__dir__}'] })"`)
expo_modules_autolinking_cli = File.join(expo_modules_autolinking_root, 'bin/expo-modules-autolinking.js')
config_command = [
'npx',
'expo-modules-autolinking',
'node',
expo_modules_autolinking_cli,
'react-native-config',
'--json',
'--platform',
@ -29,6 +52,8 @@ target 'Folo' do
]
end
generate_react_native_codegen!(__dir__)
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']

View File

@ -7,6 +7,9 @@
"android": "expo run:android",
"bump": "vv",
"dev": "npm run start",
"e2e:android": "sh ./e2e/run-maestro.sh android",
"e2e:doctor": "sh -c 'for f in e2e/flows/shared/*.yaml e2e/flows/android/*.yaml e2e/flows/ios/*.yaml; do maestro check-syntax $f; done'",
"e2e:ios": "sh ./e2e/run-maestro.sh ios",
"eas-build-post-install": "rm -rf $TMPDIR/metro-cache",
"eas-build-pre-install": "command -v pod >/dev/null 2>&1 && pod repo update || echo 'CocoaPods not found, skipping pod repo update'",
"ios": "expo run:ios",
@ -151,6 +154,20 @@
"expo": {
"autolinking": {
"nativeModulesDir": "./native"
},
"doctor": {
"appConfigFieldsNotSyncedCheck": {
"enabled": false
},
"reactNativeDirectoryCheck": {
"enabled": true,
"exclude": [
"react-native-track-player",
"follow-native",
"react-native-color-matrix-image-filters"
],
"listUnknownPackages": false
}
}
}
}

View File

@ -23,8 +23,8 @@ const withFollowAssets = (config, props) => {
// Build the web renderer directly to avoid workspace filter resolution issues on EAS workers.
const webAppDir = path.resolve(__dirname, "..", "web-app")
const cmd = `pnpm --dir ${webAppDir} build --outDir ${path.resolve(props.assetsPath, "html-renderer")}`
console.info(`Assets source directory not found! Running \`${cmd}\` to generate assets.`)
execSync(cmd, { stdio: "inherit" })
console.error(`Assets source directory not found! Running \`${cmd}\` to generate assets.`)
execSync(cmd, { stdio: ["ignore", "ignore", "inherit"] })
}
if (!isAssetReady(props.assetsPath)) {
throw new Error(

View File

@ -9,6 +9,7 @@ import { Logo } from "../ui/logo"
export function NoLoginInfo({ target }: { target: "timeline" | "subscriptions" }) {
return (
<Pressable
testID={`no-login-${target}`}
className="flex-1 items-center justify-center gap-3"
onPress={() => destination.Login()}
>

View File

@ -15,15 +15,21 @@ export interface ModalHeaderSubmitButtonProps {
isValid: boolean
onPress: () => void
isLoading?: boolean
testID?: string
}
export const HeaderSubmitButton = ({
isValid,
onPress,
isLoading,
testID,
}: ModalHeaderSubmitButtonProps) => {
const label = useColor("label")
return (
<UINavigationHeaderActionButton onPress={onPress} disabled={!isValid || isLoading}>
<UINavigationHeaderActionButton
onPress={onPress}
disabled={!isValid || isLoading}
testID={testID}
>
{isLoading ? (
<PlatformActivityIndicator size="small" color={withOpacity(label, 0.5)} />
) : (
@ -37,13 +43,18 @@ export const HeaderSubmitTextButton = ({
onPress,
isLoading,
label,
testID,
}: ModalHeaderSubmitButtonProps & {
label?: string
}) => {
const { t } = useTranslation("common")
const labelColor = useColor("label")
return (
<UINavigationHeaderActionButton onPress={onPress} disabled={!isValid || isLoading}>
<UINavigationHeaderActionButton
onPress={onPress}
disabled={!isValid || isLoading}
testID={testID}
>
{isLoading && (
<View className="absolute inset-y-0 right-2 items-center justify-center">
<PlatformActivityIndicator size="small" color={withOpacity(labelColor, 0.5)} />
@ -69,6 +80,7 @@ export const HeaderCloseOnly = () => {
return (
<StackScreenHeaderPortal>
<UINavigationHeaderActionButton
testID="auth-back"
className="absolute"
style={{
top: insets.top,

View File

@ -399,6 +399,7 @@ export const DefaultHeaderBackButton = ({
if (!canGoBack && !canDismiss) return null
return (
<UINavigationHeaderActionButton
testID="navigation-back"
onPress={() => {
const leave = () => {
if (canGoBack) {
@ -441,16 +442,19 @@ export const UINavigationHeaderActionButton = ({
disabled,
className,
style,
testID,
}: {
children: ReactNode
onPress?: () => void
disabled?: boolean
className?: string
style?: StyleProp<ViewStyle>
testID?: string
}) => {
return (
<Pressable
hitSlop={5}
testID={testID}
className={cn("p-2", className)}
onPress={onPress}
disabled={disabled}

View File

@ -82,11 +82,12 @@ export const Tabbar: FC<{
<Grid columns={renderTabScreens.length} gap={10} className="mt-[7]">
{renderTabScreens.map((route, index) => {
const focused = index === selectedIndex
const label = route.title
const label = route.title ?? ""
return (
<MemoedTabItem
key={route.tabScreenIndex}
focused={focused}
identifier={route.identifier ?? String(route.tabScreenIndex)}
index={index}
label={label}
renderIcon={route.icon}
@ -100,55 +101,59 @@ export const Tabbar: FC<{
}
const MemoedTabItem: FC<{
focused: boolean
identifier: string
index: number
label: string
renderIcon?: (options: TabbarIconProps) => React.ReactNode
onPress: (index: number) => void
}> = memo(({ focused, index, label, renderIcon: renderIconFn, onPress: onPressProp }) => {
const inactiveTintColor = "#999"
const onPress = () => {
onPressProp?.(index)
}
const accessibilityLabel =
typeof label === "string" && Platform.OS === "ios" ? `${label}, tab` : undefined
const renderIcon = useCallback(
({ focused }: { focused: boolean }) => {
const iconSize = ICON_SIZE_ROUND
return (
<TabIcon
focused={focused}
iconSize={iconSize}
inactiveTintColor={inactiveTintColor}
renderIcon={renderIconFn || noop}
/>
)
},
[renderIconFn],
)
const renderLabel = useCallback(
({ focused }: { focused: boolean }) => {
return (
<TextLabel
focused={focused}
accessibilityLabel={accessibilityLabel}
label={label}
inactiveTintColor={inactiveTintColor}
style={styles.labelBeneath}
/>
)
},
[label, accessibilityLabel, inactiveTintColor],
)
return (
<TabItem
focused={focused}
onPress={onPress}
originalRenderIcon={renderIcon}
originalRenderLabel={renderLabel}
accessibilityLabel={accessibilityLabel}
/>
)
})
}> = memo(
({ focused, identifier, index, label, renderIcon: renderIconFn, onPress: onPressProp }) => {
const inactiveTintColor = "#999"
const onPress = () => {
onPressProp?.(index)
}
const accessibilityLabel =
typeof label === "string" && Platform.OS === "ios" ? `${label}, tab` : undefined
const renderIcon = useCallback(
({ focused }: { focused: boolean }) => {
const iconSize = ICON_SIZE_ROUND
return (
<TabIcon
focused={focused}
iconSize={iconSize}
inactiveTintColor={inactiveTintColor}
renderIcon={renderIconFn || noop}
/>
)
},
[renderIconFn],
)
const renderLabel = useCallback(
({ focused }: { focused: boolean }) => {
return (
<TextLabel
focused={focused}
accessibilityLabel={accessibilityLabel}
label={label}
inactiveTintColor={inactiveTintColor}
style={styles.labelBeneath}
/>
)
},
[label, accessibilityLabel, inactiveTintColor],
)
return (
<TabItem
focused={focused}
testID={`tab-${identifier}`}
onPress={onPress}
originalRenderIcon={renderIcon}
originalRenderLabel={renderLabel}
accessibilityLabel={accessibilityLabel}
/>
)
},
)
const TextLabel = (props: {
focused: boolean
accessibilityLabel: string | undefined
@ -286,12 +291,14 @@ const TabItem = memo(
originalRenderIcon,
originalRenderLabel,
accessibilityLabel,
testID,
}: {
focused: boolean
onPress: () => void
originalRenderIcon: (scene: { focused: boolean }) => React.ReactNode
originalRenderLabel: (scene: { focused: boolean }) => React.ReactNode
accessibilityLabel?: string
testID?: string
}) => {
const pressed = useSharedValue(0)
const animatedStyle = useAnimatedStyle(() => {
@ -308,6 +315,7 @@ const TabItem = memo(
}
return (
<Pressable
testID={testID}
onPress={() => {
onPress()
cancelAnimation(pressed)

View File

@ -132,6 +132,8 @@ export const PlainTextField = ({
<TextInput
{...props}
ref={textInputRef}
onChange={props.onChange}
onChangeText={props.onChangeText}
onFocus={composeEventHandlers(props.onFocus, () => setIsFocused(true))}
onBlur={composeEventHandlers(props.onBlur, () => setIsFocused(false))}
selectionColor={accentColor}

View File

@ -151,11 +151,22 @@ export const GroupedInsetListNavigationLink: FC<
onPress: () => void
disabled?: boolean
postfix?: React.ReactNode
testID?: string
} & BaseCellClassNames
> = ({ label, icon, onPress, disabled, className, leftClassName, rightClassName, postfix }) => {
> = ({
label,
icon,
onPress,
disabled,
className,
leftClassName,
rightClassName,
postfix,
testID,
}) => {
const rightIconColor = useColor("tertiaryLabel")
return (
<Pressable onPress={onPress} disabled={disabled} className={className}>
<Pressable testID={testID} onPress={onPress} disabled={disabled} className={className}>
{({ pressed }) => (
<GroupedInsetListBaseCell
className={cn(pressed ? "bg-system-fill" : undefined, disabled && "opacity-40")}

View File

@ -7,7 +7,7 @@ import { Platform } from "react-native"
import DeviceInfo from "react-native-device-info"
import { LoginScreen } from "../screens/(modal)/LoginScreen"
import { getCookie } from "./auth"
import { getAuthStateRevision, getCookie } from "./auth"
import { getClientId, getSessionId } from "./client-session"
import { getUserAgent } from "./native/user-agent"
import { Navigation } from "./navigation/Navigation"
@ -36,6 +36,9 @@ followClient.addRequestInterceptor(async (ctx) => {
})
followClient.addRequestInterceptor(async (ctx) => {
const { options } = ctx
;(options as typeof options & { __followAuthRevision?: number }).__followAuthRevision =
getAuthStateRevision()
const header = options.headers || {}
header["X-Client-Id"] = getClientId()
header["X-Session-Id"] = getSessionId()
@ -58,9 +61,43 @@ followClient.addRequestInterceptor(async (ctx) => {
return ctx
})
const getRequestCookie = (headers: HeadersInit | undefined) => {
if (!headers) {
return
}
if (headers instanceof Headers) {
return headers.get("cookie") ?? undefined
}
if (Array.isArray(headers)) {
return headers.find(([key]) => key.toLowerCase() === "cookie")?.[1]
}
return Object.entries(headers).find(([key]) => key.toLowerCase() === "cookie")?.[1]
}
const getRequestAuthRevision = (options: Record<string, unknown>) => {
const revision = options.__followAuthRevision
return typeof revision === "number" ? revision : undefined
}
followClient.addResponseInterceptor(async (ctx) => {
const { response } = ctx
const { options, response } = ctx
if (response.status === 401) {
const currentCookie = getCookie()
const requestCookie = getRequestCookie(options.headers)
const requestAuthRevision = getRequestAuthRevision(options as Record<string, unknown>)
const currentAuthRevision = getAuthStateRevision()
if (typeof requestAuthRevision === "number" && requestAuthRevision < currentAuthRevision) {
return ctx.response
}
if (currentCookie && requestCookie !== currentCookie) {
return ctx.response
}
userActions.removeCurrentUser()
Navigation.rootNavigation.presentControllerView(LoginScreen)
} else if (response.status >= 400) {

View File

@ -1,13 +1,13 @@
import { expoClient } from "@better-auth/expo/client"
import { expoClient, getSetCookie, hasBetterAuthCookies } from "@better-auth/expo/client"
import { baseAuthPlugins } from "@follow/shared/auth"
import { isNewUserQueryKey } from "@follow/store/user/constants"
import { whoamiQueryKey } from "@follow/store/user/hooks"
import { createMobileAPIHeaders } from "@follow/utils/headers"
import { useQuery } from "@tanstack/react-query"
import { createAuthClient } from "better-auth/react"
import { fetch as expoFetch } from "expo/fetch"
import { nativeApplicationVersion } from "expo-application"
import * as FileSystem from "expo-file-system/legacy"
import * as SecureStore from "expo-secure-store"
import Storage from "expo-sqlite/kv-store"
import { Platform } from "react-native"
import DeviceInfo from "react-native-device-info"
@ -18,11 +18,21 @@ import { getClientId, getSessionId } from "./client-session"
import { getUserAgent } from "./native/user-agent"
import { getEnvProfile, proxyEnv } from "./proxy-env"
import { queryClient } from "./query-client"
import { safeSecureStore } from "./secure-store"
const storagePrefix = "follow_auth"
export const cookieKey = `${storagePrefix}_cookie`
export const sessionTokenKey = "__Secure-better-auth.session_token"
let authStateRevision = 0
export const getAuthStateRevision = () => authStateRevision
const bumpAuthStateRevision = () => {
authStateRevision += 1
return authStateRevision
}
const plugins = [
...baseAuthPlugins,
expoClient({
@ -31,7 +41,7 @@ const plugins = [
storage: {
setItem(key, value) {
try {
SecureStore.setItem(key, value)
safeSecureStore.setItem(key, value)
} catch (e) {
console.warn("SecureStore.setItem failed:", e)
return
@ -41,18 +51,19 @@ const plugins = [
if (__DEV__) {
const env = getEnvProfile()
try {
SecureStore.setItem(`${cookieKey}_${env}`, value)
safeSecureStore.setItem(`${cookieKey}_${env}`, value)
} catch {
// Keychain may be unavailable in background
}
}
bumpAuthStateRevision()
queryClient.invalidateQueries({ queryKey: whoamiQueryKey })
queryClient.invalidateQueries({ queryKey: isNewUserQueryKey })
}
},
getItem(key) {
try {
return SecureStore.getItem(key)
return safeSecureStore.getItem(key)
} catch (e) {
console.warn("SecureStore.getItem failed:", e)
return null
@ -62,10 +73,48 @@ const plugins = [
}),
]
const updateCookieStorage = (serializedCookie: string) => {
try {
safeSecureStore.setItem(cookieKey, serializedCookie)
} catch (error) {
console.warn("SecureStore.setItem failed during auth cookie persistence:", error)
return false
}
const env = getEnvProfile()
try {
safeSecureStore.setItem(`${cookieKey}_${env}`, serializedCookie)
} catch {
// Keychain may be unavailable in background
}
bumpAuthStateRevision()
queryClient.invalidateQueries({ queryKey: whoamiQueryKey })
queryClient.invalidateQueries({ queryKey: isNewUserQueryKey })
return true
}
export const persistAuthCookieHeader = (setCookie: string | null | undefined) => {
if (!setCookie || !hasBetterAuthCookies(setCookie, "better-auth")) {
return false
}
let previousCookie: string | undefined
try {
previousCookie = safeSecureStore.getItem(cookieKey) ?? undefined
} catch (error) {
console.warn("SecureStore.getItem failed during auth cookie persistence:", error)
}
const serializedCookie = getSetCookie(setCookie, previousCookie)
return updateCookieStorage(serializedCookie)
}
export const authClient = createAuthClient({
baseURL: `${proxyEnv.API_URL}/better-auth`,
fetchOptions: {
cache: "no-store",
customFetchImpl: async (input, init) => expoFetch(input.toString(), init as any) as any,
// Learn more: https://better-fetch.vercel.app/docs/hooks
onRequest: async (ctx) => {
const headers = createMobileAPIHeaders({
@ -149,6 +198,7 @@ export function isAuthCodeValid(authCode: string) {
export const signOut = async () => {
await authClient.signOut()
bumpAuthStateRevision()
const dbPath = getDbPath()
await FileSystem.deleteAsync(dbPath)
await expo.reloadAppAsync("User sign out")

View File

@ -0,0 +1,16 @@
import Constants from "expo-constants"
interface AppExtra {
e2eEnvProfile?: string | null
e2eLanguage?: string | null
}
const getAppExtra = (): AppExtra => (Constants.expoConfig?.extra ?? {}) as AppExtra
export const getE2EEnvProfile = () =>
getAppExtra().e2eEnvProfile ?? process.env.EXPO_PUBLIC_E2E_ENV_PROFILE ?? null
export const getE2ELanguage = () =>
getAppExtra().e2eLanguage ?? process.env.EXPO_PUBLIC_E2E_LANGUAGE ?? null
export const isE2EEnabled = () => Boolean(getE2EEnvProfile() || getE2ELanguage())

View File

@ -12,9 +12,19 @@ import {
import { defaultResources } from "@/src/@types/default-resource"
import { getGeneralSettings } from "../atoms/settings/general"
import { getE2ELanguage } from "./e2e-config"
const fallbackLanguage = "en"
const getForcedLanguage = () => {
const language = getE2ELanguage()
if (!language) {
return null
}
return currentSupportedLanguages.includes(language) ? language : null
}
export const updateDayjsLocale = async (lang: string) => {
if (!(lang in dayjsLocaleImportMap)) return
const dayjsImport = dayjsLocaleImportMap[lang as keyof typeof dayjsLocaleImportMap]
@ -24,7 +34,8 @@ export const updateDayjsLocale = async (lang: string) => {
}
export async function initializeI18n() {
const { language } = getGeneralSettings()
const { language: storedLanguage } = getGeneralSettings()
const language = getForcedLanguage() ?? storedLanguage
return Promise.all([
updateDayjsLocale(language),

View File

@ -2,14 +2,25 @@ import type { env, envProfileMap } from "@follow/shared/env.rn"
import { getEnvProfiles__dangerously } from "@follow/shared/env.rn"
import { createAtomHooks } from "@follow/utils"
import { reloadAppAsync } from "expo"
import * as SecureStore from "expo-secure-store"
import { atomWithStorage } from "jotai/utils"
import type { SyncStorage } from "jotai/vanilla/utils/atomWithStorage"
import { cookieKey, sessionTokenKey } from "./auth"
import { cookieKey } from "./auth"
import { getE2EEnvProfile } from "./e2e-config"
import { JotaiPersistSyncStorage } from "./jotai"
import { safeSecureStore } from "./secure-store"
const [, , useEnvProfile, , getEnvProfile, _setEnvProfile] = createAtomHooks(
const getForcedEnvProfile = (): keyof typeof envProfileMap | null => {
const profile = getE2EEnvProfile()
if (!profile) {
return null
}
const envProfiles = getEnvProfiles__dangerously()
return profile in envProfiles ? (profile as keyof typeof envProfileMap) : null
}
const [, , useStoredEnvProfile, , getStoredEnvProfile, _setEnvProfile] = createAtomHooks(
atomWithStorage(
"##Follow-Current-Env-Profile",
__DEV__ ? "dev" : "prod",
@ -20,6 +31,14 @@ const [, , useEnvProfile, , getEnvProfile, _setEnvProfile] = createAtomHooks(
),
)
const getEnvProfile = () =>
getForcedEnvProfile() ?? (getStoredEnvProfile() as keyof typeof envProfileMap)
const useEnvProfile = () => {
const storedProfile = useStoredEnvProfile() as keyof typeof envProfileMap
return getForcedEnvProfile() ?? storedProfile
}
export const proxyEnv = new Proxy(
{},
{
@ -34,20 +53,15 @@ export const proxyEnv = new Proxy(
) as any as typeof env
export const setEnvProfile = (profile: keyof typeof envProfileMap) => {
if (getForcedEnvProfile()) return
const currentProfile = getEnvProfile()
if (currentProfile === profile) return
_setEnvProfile(profile)
try {
const input = SecureStore.getItem(`${cookieKey}_${profile}`)
const input = safeSecureStore.getItem(`${cookieKey}_${profile}`)
if (input) {
SecureStore.setItem(
cookieKey,
JSON.stringify({
[sessionTokenKey]: {
value: input,
},
}),
)
safeSecureStore.setItem(cookieKey, input)
}
} catch (e) {
console.warn("SecureStore access failed during env profile switch:", e)

View File

@ -0,0 +1,84 @@
import * as SecureStore from "expo-secure-store"
import Storage from "expo-sqlite/kv-store"
const fallbackPrefix = "follow_secure_store_fallback"
const warnedFallbackKeys = new Set<string>()
let forceFallback = false
const getFallbackKey = (key: string) => `${fallbackPrefix}:${key}`
const isSecureStoreUnavailable = (error: unknown) => {
if (!(error instanceof Error)) {
return false
}
return (
error.message.includes("KeyChainException") ||
error.message.includes("required entitlement") ||
error.message.includes("keychain")
)
}
const warnFallback = (action: "getItem" | "setItem", key: string, error: unknown) => {
const warnKey = `${action}:${key}`
if (warnedFallbackKeys.has(warnKey)) {
return
}
warnedFallbackKeys.add(warnKey)
if (!(error instanceof Error)) {
console.warn(`[auth-storage] SecureStore ${action} fallback enabled for ${key}`)
return
}
console.warn(`[auth-storage] SecureStore ${action} fallback enabled for ${key}: ${error.message}`)
}
const getFallbackValue = (key: string) => Storage.getItemSync(getFallbackKey(key))
export const safeSecureStore = {
getItem(key: string) {
if (forceFallback) {
return getFallbackValue(key)
}
try {
const value = SecureStore.getItem(key)
if (value != null) {
return value
}
} catch (error) {
if (isSecureStoreUnavailable(error)) {
forceFallback = true
warnFallback("getItem", key, error)
return getFallbackValue(key)
}
throw error
}
return getFallbackValue(key)
},
setItem(key: string, value: string) {
if (forceFallback) {
Storage.setItemSync(getFallbackKey(key), value)
return
}
try {
SecureStore.setItem(key, value)
Storage.removeItemSync(getFallbackKey(key))
return
} catch (error) {
if (isSecureStoreUnavailable(error)) {
forceFallback = true
warnFallback("setItem", key, error)
Storage.setItemSync(getFallbackKey(key), value)
return
}
throw error
}
},
}

View File

@ -57,7 +57,7 @@ export const EntryItemContextMenu = ({
if (!entry) return null
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Preview size="STRETCH" onPress={handlePressPreview}>

View File

@ -12,7 +12,7 @@ import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen"
import { selectFeed, selectTimeline } from "../screen/atoms"
type FeedSummaryFeed = {
id: string
id?: string | null
title?: Nullable<string>
url?: Nullable<string>
image?: Nullable<string>
@ -28,6 +28,7 @@ export const FeedSummary = ({
children,
preChildren,
className,
testID,
simple,
view,
preview,
@ -36,6 +37,7 @@ export const FeedSummary = ({
children?: React.ReactNode
preChildren?: React.ReactNode
className?: string
testID?: string
simple?: boolean
view?: number | null
preview?: boolean
@ -75,6 +77,7 @@ export const FeedSummary = ({
}
}}
className={className}
testID={testID}
>
{preChildren}
{/* Headline */}

View File

@ -9,8 +9,18 @@ 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 { 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()
@ -26,11 +36,21 @@ export const SearchFeed = () => {
const skeleton = useDataSkeleton(isLoading, data)
if (skeleton) return skeleton
if (data === undefined) return null
const resultCount = data.data?.length ?? 0
const discoveredItems = data.data ?? []
const items =
discoveredItems.length > 0
? discoveredItems
: searchValue && isDirectFeedInput(searchValue)
? [createDirectFeedItem(searchValue)]
: []
const resultCount = items.length
const resultLabel =
resultCount === 0
? t("discover.search.results_zero")
: t("discover.search.results_other", { count: resultCount })
return (
<View
style={{
@ -39,8 +59,8 @@ export const SearchFeed = () => {
>
<Text className="px-6 pt-4 text-text/60">{resultLabel}</Text>
<View>
{data.data?.map((item, index) => (
<View key={item.feed?.id ?? `feed-${index}`}>
{items.map((item, index) => (
<View key={item.feed?.id ?? item.feed?.url ?? `feed-${index}`}>
<SearchFeedCard item={item} />
<ItemSeparator />
</View>

View File

@ -1,25 +1,66 @@
import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
import { formatNumber } from "@follow/utils"
import type { DiscoveryItem, TrendingFeedItem } from "@follow-app/client-sdk"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { View } from "react-native"
import { Pressable, View } from "react-native"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { Text } from "@/src/components/ui/typography/Text"
import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re"
import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re"
import { User3CuteReIcon } from "@/src/icons/user_3_cute_re"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
import { useColor } from "@/src/theme/colors"
import { FeedSummary } from "../FeedSummary"
export const SearchFeedCard = ({ item }: { item: TrendingFeedItem | DiscoveryItem }) => {
export type SearchFeedCardItem = {
feed?: {
id?: string | null
title?: string | null
url?: string | null
image?: string | null
ownerUserId?: string | null
siteUrl?: string | null
description?: string | null
} | null
analytics?: {
subscriptionCount?: number | null
latestEntryPublishedAt?: string | null
updatesPerWeek?: number | null
} | null
}
export const SearchFeedCard = ({
item,
}: {
item: SearchFeedCardItem | TrendingFeedItem | DiscoveryItem
}) => {
const { t } = useTranslation("common")
const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "")
const iconColor = useColor("secondaryLabel")
const followerCount = item.analytics?.subscriptionCount || 0
const navigation = useNavigation()
const openFollow = useCallback(() => {
if (item.feed?.id) {
navigation.presentControllerView(FollowScreen, {
id: item.feed.id,
type: "feed",
})
return
}
if (item.feed?.url) {
navigation.presentControllerView(FollowScreen, {
url: item.feed.url,
type: "url",
})
}
}, [item.feed?.id, item.feed?.url, navigation])
return (
<FeedSummary feed={item.feed!} className="py-4 pl-4">
<FeedSummary feed={item.feed!} className="py-4 pl-4" testID="discover-feed-card">
<View className="mt-4 flex-row items-center gap-6">
<View className="flex-row items-center gap-1.5">
<User3CuteReIcon width={14} height={14} color={iconColor} />
@ -48,15 +89,19 @@ export const SearchFeedCard = ({ item }: { item: TrendingFeedItem | DiscoveryIte
</View>
<View className="ml-auto mr-4 mt-1">
{isSubscribed ? (
<View className="px-5 py-2">
<Text className="text-sm font-bold text-tertiary-label">
{t("feed.actions.followed")}
</Text>
</View>
<Pressable hitSlop={10} onPress={openFollow} testID="discover-feed-follow-action">
<View className="px-5 py-2">
<Text className="text-sm font-bold text-tertiary-label">
{t("feed.actions.followed")}
</Text>
</View>
</Pressable>
) : (
<View className="rounded-full bg-accent px-5 py-2">
<Text className="text-sm font-bold text-white">{t("feed.actions.follow")}</Text>
</View>
<Pressable hitSlop={10} onPress={openFollow} testID="discover-feed-follow-action">
<View className="rounded-full bg-accent px-5 py-2">
<Text className="text-sm font-bold text-white">{t("feed.actions.follow")}</Text>
</View>
</Pressable>
)}
</View>
</View>

View File

@ -182,6 +182,7 @@ const SearchInput = () => {
</Animated.View>
)}
<TextInput
testID="discover-search-input"
allowFontScaling={false}
textAlignVertical="center"
enterKeyHint="search"
@ -215,6 +216,7 @@ const SearchInput = () => {
</View>
<ReAnimatedPressable
testID="discover-search-cancel"
hitSlop={10}
onPress={() => {
setIsFocused(false)

View File

@ -48,6 +48,7 @@ export const EntryListContentArticle = ({
extraData={extraData as EntryExtraData}
view={view}
hasTopSeparator={index > 0}
testID={index === 0 ? "timeline-entry-first" : undefined}
/>
),
[view],

View File

@ -38,11 +38,13 @@ export const EntryNormalItem = memo(
extraData,
view,
hasTopSeparator = false,
testID,
}: {
entryId: string
extraData: EntryExtraData
view: FeedViewType
hasTopSeparator?: boolean
testID?: string
}) => {
const entry = useEntry(entryId, (state) => ({
id: state.id,
@ -93,6 +95,7 @@ export const EntryNormalItem = memo(
return (
<EntryItemContextMenu id={entryId} view={view}>
<ItemPressable
testID={testID ?? `entry-item-${entryId}`}
itemStyle={ItemPressableStyle.Plain}
className={cn(
view === FeedViewType.Notifications ? "p-2" : "p-4",

View File

@ -8,7 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useMemo, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { View } from "react-native"
import { Alert, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { z } from "zod"
@ -140,6 +140,40 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {
setIsLoading(false)
}
}
const handleUnfollow = () => {
if (!subscription?.feedId || isLoading) return
Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [
{
text: "Cancel",
style: "cancel",
},
{
text: t("operation.unfollow"),
style: "destructive",
onPress: async () => {
try {
setIsLoading(true)
await subscriptionSyncService.unsubscribe(subscription.feedId)
toast.success("Feed unfollowed")
if (canDismiss) {
navigate.dismiss()
} else {
navigate.back()
}
} catch (error) {
toast.error(
error instanceof Error ? getBizFetchErrorMessage(error) : "Failed to update feed",
)
} finally {
setIsLoading(false)
}
},
},
])
}
const insets = useSafeAreaInsets()
const { isValid, isDirty } = form.formState
const setScreenOptions = useSetModalScreenOptions()
@ -161,14 +195,15 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {
Header={
<NavigationBlurEffectHeaderView
title={`${isSubscribed ? tCommon("words.edit") : tCommon("words.follow")} - ${feed?.title}`}
headerRight={
headerRight={() => (
<HeaderSubmitTextButton
isValid={isValid}
onPress={form.handleSubmit(submit)}
isLoading={isLoading}
label={isSubscribed ? tCommon("words.save") : tCommon("words.follow")}
testID="follow-submit"
/>
}
)}
/>
}
>
@ -201,15 +236,29 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {
) : feed.latestEntryPublishedAt ? (
<View className="flex-row items-center gap-1">
<SafeAlertCuteReIcon color={textLabelColor} width={12} height={12} />
<Text className="text-sm text-text">
{tCommon("feed.updated_at")}
<RelativeDateTime date={feed.latestEntryPublishedAt} />
</Text>
<Text className="text-sm text-text">{tCommon("feed.updated_at")}</Text>
<RelativeDateTime
className="text-sm text-text"
date={feed.latestEntryPublishedAt}
/>
</View>
) : null}
</View>
</FeedSummary>
</GroupedInsetListCard>
{isSubscribed && (
<GroupedInsetListCard className="p-4">
<View className="items-start">
<Text
className="text-base font-medium text-red"
testID="follow-unfollow"
onPress={handleUnfollow}
>
{t("operation.unfollow")}
</Text>
</View>
</GroupedInsetListCard>
)}
{/* Group 2 */}
<GroupedInsetListCard className="gap-y-4 p-4">
<FormProvider form={form}>

View File

@ -1,8 +1,9 @@
import { userSyncService } from "@follow/store/user/store"
import { tracker } from "@follow/tracker"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import i18next from "i18next"
import { useCallback, useRef } from "react"
import { useCallback, useState } from "react"
import type { Control } from "react-hook-form"
import { useController, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
@ -14,7 +15,7 @@ import { z } from "zod"
import { SubmitButton } from "@/src/components/common/SubmitButton"
import { PlainTextField } from "@/src/components/ui/form/TextField"
import { Text } from "@/src/components/ui/typography/Text"
import { signIn, signUp } from "@/src/lib/auth"
import { authClient, persistAuthCookieHeader } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { Navigation } from "@/src/lib/navigation/Navigation"
import { toast } from "@/src/lib/toast"
@ -28,67 +29,197 @@ const formSchema = z.object({
password: z.string().min(8).max(128),
})
type FormValue = z.infer<typeof formSchema>
const getAuthErrorMessage = (value: unknown) => {
if (!value || typeof value !== "object" || !("error" in value)) {
return
}
const { error } = value
if (!error || typeof error !== "object" || !("message" in error)) {
return
}
return typeof error.message === "string" ? error.message : undefined
}
const getAuthData = (value: unknown) => {
if (!value || typeof value !== "object" || !("data" in value)) {
return
}
return value.data && typeof value.data === "object" ? value.data : undefined
}
const getResponseSetCookie = (response: Response) => {
const directValue =
response.headers.get("x-better-auth-set-cookie") ??
response.headers.get("set-cookie") ??
response.headers.get("Set-Cookie")
if (directValue) {
return directValue
}
const rawHeaders = (response as Response & { _rawHeaders?: unknown })._rawHeaders
if (!Array.isArray(rawHeaders)) {
return null
}
const values = rawHeaders
.filter(
(header): header is [string, string] =>
Array.isArray(header) &&
header.length >= 2 &&
typeof header[0] === "string" &&
typeof header[1] === "string",
)
.filter(([key]) => key.toLowerCase() === "set-cookie")
.map(([, value]) => value)
return values.length > 0 ? values.join(", ") : null
}
const hasTwoFactorRedirect = (value: unknown) => {
const data = getAuthData(value)
if (data && "twoFactorRedirect" in data) {
return Boolean(data.twoFactorRedirect)
}
if (!value || typeof value !== "object" || !("response" in value)) {
return false
}
const { response } = value
if (!response || typeof response !== "object" || !("twoFactorRedirect" in response)) {
return false
}
return Boolean(response.twoFactorRedirect)
}
const requestCredentialAuth = async ({
path,
body,
}: {
path: "/sign-in/email" | "/sign-up/email"
body: Record<string, string>
}) => {
let setCookie: string | null = null
const result = await authClient.$fetch(path, {
method: "POST",
body,
headers: await getTokenHeaders(),
throw: false,
onResponse(context) {
setCookie = getResponseSetCookie(context.response)
},
})
const persistedCookie = setCookie ? persistAuthCookieHeader(setCookie) : false
return {
result,
persistedCookie,
}
}
const establishCredentialSession = async ({
email,
password,
onTwoFactorRedirect,
}: {
email: string
password: string
onTwoFactorRedirect?: () => void
}) => {
const { result, persistedCookie } = await requestCredentialAuth({
path: "/sign-in/email",
body: {
email,
password,
},
})
const errorMessage = getAuthErrorMessage(result)
if (errorMessage) {
throw new Error(errorMessage)
}
if (hasTwoFactorRedirect(result)) {
onTwoFactorRedirect?.()
return null
}
const session = persistedCookie ? await userSyncService.whoami().catch(() => null) : null
if (!session?.user?.id) {
return null
}
return session
}
async function onSubmit(values: FormValue) {
const result = formSchema.safeParse(values)
if (!result.success) {
const issue = result.error.issues[0]
Alert.alert(i18next.t("login.invalid_email_or_password"), issue?.message)
return
return false
}
await signIn
.email(
{
email: result.data.email,
password: result.data.password,
},
{
headers: await getTokenHeaders(),
},
)
.then((res) => {
if (res.error) {
throw new Error(res.error.message)
}
// @ts-expect-error
if (res.data.twoFactorRedirect) {
let session = null
try {
session = await establishCredentialSession({
email: result.data.email,
password: result.data.password,
onTwoFactorRedirect: () => {
Navigation.rootNavigation.presentControllerView(TwoFactorAuthScreen)
}
})
.catch((error) => {
Alert.alert(error.message)
},
})
} catch (error) {
Alert.alert(error instanceof Error ? error.message : "Unable to sign in")
return false
}
if (!session?.user?.id) {
return false
}
tracker.userLogin({
type: "email",
})
return true
}
export function EmailLogin() {
const { t } = useTranslation()
const emailValueRef = useRef("")
const passwordValueRef = useRef("")
const [emailValue, setEmailValue] = useState("")
const [passwordValue, setPasswordValue] = useState("")
const submitMutation = useMutation({
mutationFn: onSubmit,
})
const onLogin = useCallback(() => {
submitMutation.mutate({
email: emailValueRef.current,
password: passwordValueRef.current,
email: emailValue,
password: passwordValue,
})
}, [submitMutation])
}, [emailValue, passwordValue, submitMutation])
const navigation = useNavigation()
return (
<View className="mx-auto flex w-full max-w-sm">
<View className="gap-4 rounded-2xl bg-secondary-system-background px-6 py-4">
<View className="flex-row">
<PlainTextField
onChangeText={(text) => {
emailValueRef.current = text
}}
testID="login-email-input"
value={emailValue}
onChangeText={setEmailValue}
selectionColor={accentColor}
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
importantForAutofill="auto"
placeholder={t("login.email")}
className="flex-1 text-text"
returnKeyType="next"
@ -100,14 +231,16 @@ export function EmailLogin() {
<View className="border-b-hairline border-b-opaque-separator" />
<View className="flex-row">
<PlainTextField
onChangeText={(text) => {
passwordValueRef.current = text
}}
testID="login-password-input"
value={passwordValue}
onChangeText={setPasswordValue}
selectionColor={accentColor}
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
textContentType="password"
importantForAutofill="auto"
placeholder={t("login.password")}
className="flex-1 text-text"
secureTextEntry
@ -125,6 +258,7 @@ export function EmailLogin() {
</Pressable>
<SubmitButton
isLoading={submitMutation.isPending}
testID="login-submit"
onPress={onLogin}
title={t("login.submit")}
/>
@ -170,6 +304,8 @@ export function EmailSignUp() {
const { t } = useTranslation()
const { control, handleSubmit, formState } = useForm<SignupFormValue>({
resolver: zodResolver(signupFormSchema),
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
email: "",
password: "",
@ -178,43 +314,63 @@ export function EmailSignUp() {
})
const submitMutation = useMutation({
mutationFn: async (values: SignupFormValue) => {
await signUp
.email(
{
try {
const { result, persistedCookie } = await requestCredentialAuth({
path: "/sign-up/email",
body: {
email: values.email,
password: values.password,
name: values.email.split("@")[0] ?? "",
},
{
headers: await getTokenHeaders(),
},
)
.then((res) => {
if (res.error?.message) {
toast.error(res.error.message)
} else {
toast.success(i18next.t("login.sign_up_successful"))
tracker.register({
type: "email",
})
Navigation.rootNavigation.back()
}
})
const errorMessage = getAuthErrorMessage(result)
if (errorMessage) {
toast.error(errorMessage)
return false
}
let session = persistedCookie ? await userSyncService.whoami().catch(() => null) : null
if (!session?.user?.id) {
session = await establishCredentialSession({
email: values.email,
password: values.password,
})
}
if (!session?.user?.id) {
toast.error("Unable to establish session after sign up")
return false
}
toast.success(i18next.t("login.sign_up_successful"))
tracker.register({
type: "email",
})
return true
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unable to sign up")
return false
}
},
})
const signup = handleSubmit((values) => {
submitMutation.mutate(values)
})
return (
<View className="mx-auto flex w-full max-w-sm">
<View className="gap-4 rounded-2xl bg-secondary-system-background px-6 py-4">
<View className="flex-row">
<SignupInput
testID="register-email-input"
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
importantForAutofill="auto"
control={control}
name="email"
placeholder={t("login.email")}
@ -228,10 +384,13 @@ export function EmailSignUp() {
<View className="border-b-hairline border-b-opaque-separator" />
<View className="flex-row">
<SignupInput
testID="register-password-input"
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password-new"
textContentType="newPassword"
importantForAutofill="auto"
control={control}
name="password"
placeholder={t("login.password")}
@ -243,10 +402,13 @@ export function EmailSignUp() {
<View className="border-b-hairline border-b-opaque-separator" />
<View className="flex-row">
<SignupInput
testID="register-confirm-password-input"
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password-new"
textContentType="newPassword"
importantForAutofill="auto"
control={control}
name="confirmPassword"
placeholder={t("login.confirm_password.label")}
@ -262,6 +424,7 @@ export function EmailSignUp() {
<SubmitButton
disabled={submitMutation.isPending || !formState.isValid}
isLoading={submitMutation.isPending}
testID="register-submit"
onPress={signup}
title={t("login.submit")}
className="mt-8"

View File

@ -24,6 +24,7 @@ export function Login() {
const [isEmail, setIsEmail] = useState(false)
return (
<View
testID="login-screen"
className="pb-safe-or-2 flex-1 justify-between"
style={{
paddingTop: insets.top + 56,
@ -73,12 +74,13 @@ export function Login() {
{isEmail ? (
<Text
className="pb-2 text-center text-lg font-medium text-label"
testID="auth-back"
onPress={() => setIsEmail(false)}
>
{t("login.back")}
</Text>
) : (
<Pressable onPress={() => setIsRegister(!isRegister)}>
<Pressable testID="auth-toggle-mode" onPress={() => setIsRegister(!isRegister)}>
<Text className="pb-2 text-center text-lg font-medium text-label">
<Trans
t={t}

View File

@ -27,6 +27,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai
return (
<Pressable
key={key}
testID={`login-provider-${provider.id}`}
hitSlop={20}
className="border-hairline flex w-full flex-row items-center justify-center gap-2 rounded-xl border-opaque-separator py-4 pl-5"
onPress={async () => {

View File

@ -67,6 +67,7 @@ function ItemWrapper({
onPress,
style,
className,
testID,
}: {
children: React.ReactNode
index: number
@ -75,6 +76,7 @@ function ItemWrapper({
onPress: () => void
className?: string
style?: Exclude<StyleProp<ViewStyle>, number>
testID?: string
}) {
const { width: windowWidth } = useWindowDimensions()
const activeViews = useViewWithSubscription()
@ -86,6 +88,7 @@ function ItemWrapper({
const bgColor = useColor("gray5")
return (
<ReAnimatedPressable
testID={testID}
className={cn(
"relative flex h-12 flex-row items-center justify-center gap-2 overflow-hidden rounded-[1.2rem] pl-2",
className,
@ -158,6 +161,7 @@ function ViewItem({
isActive={isActive}
index={index}
activeColor={view.activeColor}
testID={`timeline-view-${view.name.replace("feed_view_type.", "").replaceAll("_", "-")}`}
onPress={() =>
selectTimeline({
type: "view",

View File

@ -42,7 +42,7 @@ export function HomeLeftAction() {
return (
<ActionGroup className="ml-2">
<Pressable onPress={handlePress}>
<Pressable testID="home-avatar-trigger" onPress={handlePress}>
<UserAvatar
image={user?.image}
name={user?.name}

View File

@ -1,7 +1,6 @@
import { UserRole } from "@follow/constants"
import { useUserRole, useWhoami } from "@follow/store/user/hooks"
import type { StatusConfigs as ServerConfigs } from "@follow-app/client-sdk"
import type { ParseKeys } from "i18next"
import type { FC } from "react"
import { Fragment, useMemo } from "react"
import { useTranslation } from "react-i18next"
@ -43,28 +42,53 @@ import { NotificationsScreen } from "./routes/Notifications"
import { PlanScreen } from "./routes/Plan"
import { PrivacyScreen } from "./routes/Privacy"
interface GroupNavigationLink {
label: Extract<ParseKeys<"settings">, `titles.${string}`>
type SettingsNavigationTranslationKey =
| "titles.general"
| "titles.notifications"
| "titles.appearance"
| "titles.data_control"
| "titles.account"
| "titles.subscription.short"
| "titles.actions"
| "titles.feeds"
| "titles.lists"
| "titles.privacy"
| "titles.about"
| "titles.sign_out"
interface GroupNavigationLinkBase {
icon: React.ElementType
onPress: (data: { navigation: Navigation }) => void
iconBackgroundColor: string
trialNotAllowed?: boolean
anonymous?: boolean
todo?: boolean
hideIf?: (serverConfigs?: ServerConfigs | null) => boolean
testID?: string
}
type GroupNavigationLink =
| (GroupNavigationLinkBase & {
translationKey: SettingsNavigationTranslationKey
label?: never
})
| (GroupNavigationLinkBase & {
label: string
translationKey?: never
})
const SettingGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.general",
translationKey: "titles.general",
icon: Settings1CuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(GeneralScreen)
},
iconBackgroundColor: "#F43F5E",
testID: "settings-general-link",
},
{
label: "titles.notifications",
translationKey: "titles.notifications",
icon: NotificationCuteReIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(NotificationsScreen)
@ -74,7 +98,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
anonymous: false,
},
{
label: "titles.appearance",
translationKey: "titles.appearance",
icon: PaletteCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(AppearanceScreen)
@ -82,7 +106,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
iconBackgroundColor: "#8B5CF6",
},
{
label: "titles.data_control",
translationKey: "titles.data_control",
icon: DatabaseIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(DataScreen)
@ -91,19 +115,20 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [
anonymous: false,
},
{
label: "titles.account",
translationKey: "titles.account",
icon: UserSettingCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(AccountScreen)
},
iconBackgroundColor: "#F97316",
anonymous: false,
testID: "settings-account-link",
},
]
const SubscriptionGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.subscription.short",
translationKey: "titles.subscription.short",
icon: PowerOutlineIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(PlanScreen)
@ -116,7 +141,7 @@ const SubscriptionGroupNavigationLinks: GroupNavigationLink[] = [
const DataGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.actions",
translationKey: "titles.actions",
icon: Magic2CuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(ActionsScreen)
@ -127,7 +152,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
},
{
label: "titles.feeds",
translationKey: "titles.feeds",
icon: CertificateCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(FeedsScreen)
@ -136,9 +161,10 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
todo: true,
anonymous: false,
trialNotAllowed: true,
testID: "settings-feeds-link",
},
{
label: "titles.lists",
translationKey: "titles.lists",
icon: RadaCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(ListsScreen)
@ -151,7 +177,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.privacy",
translationKey: "titles.privacy",
icon: SafeLockFilledIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(PrivacyScreen)
@ -159,7 +185,7 @@ const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [
iconBackgroundColor: "#6366F1",
},
{
label: "titles.about",
translationKey: "titles.about",
icon: StarCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(AboutScreen)
@ -170,7 +196,7 @@ const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [
const ActionGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.sign_out",
translationKey: "titles.sign_out",
icon: ExitCuteFiIcon,
onPress: () => {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
@ -186,6 +212,7 @@ const ActionGroupNavigationLinks: GroupNavigationLink[] = [
},
iconBackgroundColor: "#DC2626",
anonymous: false,
testID: "settings-sign-out",
},
]
@ -201,15 +228,19 @@ const NavigationLinkGroup: FC<{
{links
.filter((link) => !link.todo)
.map((link) => {
const label = link.translationKey ? String(t(link.translationKey)) : link.label
const key = link.testID ?? link.translationKey ?? link.label
return (
<GroupedInsetListNavigationLink
key={link.label}
label={t(link.label)}
key={key}
label={label}
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor={link.iconBackgroundColor}>
<link.icon height={18} width={18} color="#fff" />
</GroupedInsetListNavigationLinkIcon>
}
testID={link.testID}
onPress={() => {
if (
link.trialNotAllowed &&
@ -246,11 +277,12 @@ export const SettingsList: FC = () => {
const filteredGroup = group
.filter((link) => link.anonymous !== !!whoami)
.filter((link) => !link.hideIf?.(serverConfigs))
if (filteredGroup.length === 0) return false
return filteredGroup
})
.filter((group): group is GroupNavigationLink[] => group !== false)
}, [whoami, serverConfigs])
}, [serverConfigs, whoami])
const pixelRatio = PixelRatio.get()
const groupGap = 100 / pixelRatio

View File

@ -284,6 +284,7 @@ export const UserHeaderBanner = ({
) : !user ? (
<Pressable
className="mx-auto"
testID="settings-sign-in"
onPress={() => navigation.presentControllerView(LoginScreen)}
>
<Text className="m-[6] text-sm text-accent">Sign in to your account</Text>

View File

@ -5,7 +5,6 @@ import { isEmptyObject, jotaiStore, sleep } from "@follow/utils"
import { EventBus } from "@follow/utils/event-bus"
import type { SettingsTab } from "@follow-app/client-sdk"
import { FollowAPIError } from "@follow-app/client-sdk"
import { omit } from "es-toolkit/compat"
import type { PrimitiveAtom } from "jotai"
import {
@ -22,16 +21,27 @@ type SettingMapping = {
general: GeneralSettings
}
const omitKeys: string[] = []
const pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {
const nextPayload = {} as Partial<T>
const record = payload as Record<string, unknown>
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
nextPayload[key as keyof T] = record[key as string] as T[keyof T]
}
}
return nextPayload
}
const localSettingGetterMap = {
appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys),
general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys),
appearance: () => getUISettings(),
general: () => getGeneralSettings(),
}
const createInternalSetter =
<T>(atom: PrimitiveAtom<T>) =>
(payload: T) => {
(payload: Partial<T>) => {
const current = jotaiStore.get(atom)
jotaiStore.set(atom, { ...current, ...payload })
}
@ -131,7 +141,7 @@ class SettingSyncQueue {
const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab
if (!tab) return
const nextPayload = omit(data.payload, omitKeys, settingWhiteListMap[tab])
const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab])
if (isEmptyObject(nextPayload)) return
this.enqueue(tab, nextPayload)
@ -216,7 +226,7 @@ class SettingSyncQueue {
private chain = Promise.resolve()
private threshold = 1000
private enqueueTime = Date.now()
private flushScheduled = false
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
@ -236,10 +246,20 @@ class SettingSyncQueue {
date: now,
})
if (now - this.enqueueTime > this.threshold) {
this.chain = this.chain.then(() => sleep(this.threshold)).finally(() => this.flush())
this.enqueueTime = Date.now()
if (this.flushScheduled) {
return
}
this.flushScheduled = true
this.chain = this.chain
.finally(() => sleep(this.threshold))
.finally(async () => {
try {
await this.flush()
} finally {
this.flushScheduled = false
}
})
}
private async flush() {
@ -273,9 +293,8 @@ class SettingSyncQueue {
const promises = [] as Promise<any>[]
for (const tab in groupedTab) {
const json = omit(
const json = pickSyncPayload(
groupedTab[tab as SettingSyncTab],
omitKeys,
settingWhiteListMap[tab as SettingSyncTab],
)
@ -324,7 +343,10 @@ class SettingSyncQueue {
if (!tab) {
const promises = [] as Promise<any>[]
for (const tab in localSettingGetterMap) {
const payload = localSettingGetterMap[tab as SettingSyncTab]()
const payload = pickSyncPayload(
localSettingGetterMap[tab as SettingSyncTab](),
settingWhiteListMap[tab as SettingSyncTab],
)
const promise = followClient.api.settings.update({
tab: tab as SettingsTab,
@ -336,7 +358,7 @@ class SettingSyncQueue {
this.chain = this.chain.finally(() => Promise.all(promises))
return this.chain
} else {
const payload = localSettingGetterMap[tab]()
const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])
this.chain = this.chain.finally(() =>
followClient.api.settings.update({
@ -393,6 +415,7 @@ class SettingSyncQueue {
if (isEmptyObject(remoteSettings.settings)) return
for (const tab in remoteSettings.settings) {
const settingTab = tab as SettingSyncTab
const remoteSettingPayload = remoteSettings.settings[tab as SettingsTab]
const updated = remoteSettings.updated[tab as SettingsTab]
@ -402,26 +425,26 @@ class SettingSyncQueue {
const remoteUpdatedDate = new Date(updated).getTime()
const localSettings = localSettingGetterMap[tab as SettingSyncTab]()
const localSettingsUpdated = (localSettings as { updated: number }).updated
const localSettings = localSettingGetterMap[settingTab]()
const localSettingsUpdated =
"updated" in localSettings && typeof localSettings.updated === "number"
? localSettings.updated
: undefined
if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) {
// Use remote and update local
const nextPayload: any = omit(
remoteSettingPayload,
omitKeys,
settingWhiteListMap[tab as SettingSyncTab],
)
const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[settingTab])
if (isEmptyObject(nextPayload)) {
continue
}
const setter = localSettingSetterMap[tab as SettingSyncTab]
const setter = localSettingSetterMap[settingTab]
nextPayload.updated = remoteUpdatedDate
setter(nextPayload)
setter({
...nextPayload,
updated: remoteUpdatedDate,
} as Partial<SettingMapping[typeof settingTab]>)
}
}
}

View File

@ -45,6 +45,7 @@ export const ListSubscriptionItem = memo(({ id, isFirst, isLast }: ListSubscript
<ItemPressable
itemStyle={ItemPressableStyle.Grouped}
className="h-12 flex-row items-center px-3"
testID={`subscription-list-${id}`}
onPress={() => {
selectFeed({
type: "list",

View File

@ -37,6 +37,12 @@ export const SubscriptionItem = memo(
enabled: !subscription && !feed,
})
const navigation = useNavigation()
const feedTestID = feed?.url
? `subscription-feed-url-${feed.url
.replaceAll(/[^a-z0-9]+/gi, "-")
.replaceAll(/^-+|-+$/g, "")
.toLowerCase()}`
: `subscription-feed-${id}`
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
@ -65,6 +71,7 @@ export const SubscriptionItem = memo(
inGrouped ? "pl-8 pr-4" : "px-4",
className,
)}
testID={feedTestID}
onPress={() => {
selectFeed({
type: "feed",

View File

@ -1,32 +1,48 @@
import { useWhoami } from "@follow/store/user/hooks"
import { Fragment, useEffect } from "react"
import { Fragment, useCallback, useEffect } from "react"
import { Pressable, ScrollView } from "react-native"
import { HeaderCloseOnly } from "@/src/components/layouts/header/HeaderElements"
import { Text } from "@/src/components/ui/typography/Text"
import { useSession } from "@/src/lib/auth"
import { useSwitchTab } from "@/src/lib/navigation/bottom-tab/hooks"
import { useCanDismiss } from "@/src/lib/navigation/hooks"
import { Navigation } from "@/src/lib/navigation/Navigation"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { useIsiPad } from "@/src/lib/platform"
import { Login } from "@/src/modules/login"
function exit() {
const router = Navigation.rootNavigation
if (router.canGoBack()) {
router.back()
} else {
router.popToRoot()
}
}
export const LoginScreen: NavigationControllerView = () => {
const whoami = useWhoami()
useEffect(() => {
if (whoami?.id && !__DEV__) {
exit()
const { data: session } = useSession()
const canDismiss = useCanDismiss()
const switchTab = useSwitchTab()
const exit = useCallback(() => {
if (canDismiss) {
Navigation.rootNavigation.dismiss()
return
}
}, [whoami])
Navigation.rootNavigation.popToRoot()
}, [canDismiss])
useEffect(() => {
if (!(session?.user?.id || whoami?.id) || __DEV__) {
return
}
switchTab(0)
const timer = setTimeout(() => {
exit()
}, 300)
return () => {
clearTimeout(timer)
}
}, [exit, session?.user?.id, switchTab, whoami?.id])
const isiPad = useIsiPad()
const Container = isiPad ? ScrollView : Fragment
// For development purposes, we don't want to redirect to the home page automatically
return (
<>

View File

@ -83,7 +83,11 @@ export const OnboardingScreen: NavigationControllerView = () => {
{/* Navigation buttons */}
<View className="mb-6 px-6">
<Pressable onPress={handleNext} className="w-full items-center rounded-xl bg-accent py-4">
<Pressable
testID="onboarding-next"
onPress={handleNext}
className="w-full items-center rounded-xl bg-accent py-4"
>
<Text className="text-lg font-bold text-white">
{currentStep < totalSteps - 1
? t("words.next")

View File

@ -22,6 +22,8 @@ export interface ResponsiveSelectProps {
disabled?: boolean
triggerClassName?: string
contentClassName?: string
triggerTestId?: string
nativeSelectTestId?: string
}
export const ResponsiveSelect = ({
defaultValue,
@ -35,6 +37,8 @@ export const ResponsiveSelect = ({
triggerClassName,
contentClassName,
placeholder,
triggerTestId,
nativeSelectTestId,
}: ResponsiveSelectProps) => {
const [valueInner] = useControlled(value, defaultValue ?? "", onValueChange)
@ -57,6 +61,7 @@ export const ResponsiveSelect = ({
return (
<button
type="button"
data-testid={triggerTestId}
onClick={() => realSelectRef?.click()}
className={cn(
"placeholder:text-text-secondary flex w-full items-center justify-between whitespace-nowrap rounded-md bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
@ -77,6 +82,7 @@ export const ResponsiveSelect = ({
<i className="i-mingcute-down-line ml-2 size-4 shrink-0 opacity-50" />
<select
ref={setRealSelectRef}
data-testid={nativeSelectTestId}
className="absolute inset-0 opacity-0"
value={valueInner}
onChange={(e) => onValueChange?.(e.target.value)}
@ -98,7 +104,7 @@ export const ResponsiveSelect = ({
value={valueInner}
onValueChange={onValueChange}
>
<SelectTrigger size={size} className={triggerClassName}>
<SelectTrigger size={size} className={triggerClassName} data-testid={triggerTestId}>
<SelectValue />
</SelectTrigger>
<SelectContent className={contentClassName} position="item-aligned">

View File

@ -189,7 +189,7 @@ importers:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
commander:
specifier: 14.0.1
version: 14.0.1
@ -266,6 +266,9 @@ importers:
'@pengx17/electron-forge-maker-appimage':
specifier: 1.2.1
version: 1.2.1(patch_hash=5b5ab1ba36e8c0d7ffee912ebf29c1a18bc101c9c661ceb1bb0bda3deaf4c667)(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
'@playwright/test':
specifier: 1.58.2
version: 1.58.2
'@types/html-minifier-terser':
specifier: 7.0.2
version: 7.0.2
@ -379,7 +382,7 @@ importers:
version: 4.3.0
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow-app/readability':
specifier: workspace:*
version: link:../../../../packages/readability
@ -491,7 +494,7 @@ importers:
version: 3.0.2(electron@38.3.0)
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/database':
specifier: workspace:*
version: link:../../../../packages/internal/database
@ -560,7 +563,7 @@ importers:
version: 3.22.0
'@splinetool/react-spline':
specifier: 4.1.0
version: 4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/query-sync-storage-persister':
specifier: 5.90.22
version: 5.90.22
@ -828,7 +831,7 @@ importers:
version: 20.6.1(bufferutil@4.1.0)
react-scan:
specifier: 0.4.3
version: 0.4.3(@types/react@19.1.17)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-router@7.13.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(rollup@4.57.1)
version: 0.4.3(@types/react@19.1.17)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-router@7.13.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(rollup@4.57.1)
typescript:
specifier: 'catalog:'
version: 5.9.3
@ -846,7 +849,7 @@ importers:
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@splinetool/react-spline':
specifier: 4.1.0
version: 4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/query-async-storage-persister':
specifier: 5.90.7
version: 5.90.7
@ -897,7 +900,7 @@ importers:
version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-intl:
specifier: 4.4.0
version: 4.4.0(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
version: 4.4.0(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -963,7 +966,7 @@ importers:
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
vinext:
specifier: 0.0.9
version: 0.0.9(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.2)
version: 0.0.9(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.2)
devDependencies:
'@cloudflare/vite-plugin':
specifier: 1.25.5
@ -1075,7 +1078,7 @@ importers:
version: 4.1.1(@types/react@19.1.17)(react@19.1.0)
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/components':
specifier: workspace:*
version: link:../../packages/internal/components
@ -1156,7 +1159,7 @@ importers:
version: 1.5.6
better-auth:
specifier: 1.3.28
version: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
camelcase-keys:
specifier: 10.0.2
version: 10.0.2
@ -1456,7 +1459,7 @@ importers:
version: 6.2.1
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/tracker':
specifier: workspace:*
version: link:../../packages/internal/tracker
@ -1963,7 +1966,7 @@ importers:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/configs':
specifier: workspace:*
version: link:../../configs
@ -1975,7 +1978,7 @@ importers:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/constants':
specifier: workspace:*
version: link:../constants
@ -2047,7 +2050,7 @@ importers:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/constants':
specifier: workspace:*
version: link:../constants
@ -2078,7 +2081,7 @@ importers:
version: 2.0.0(@types/node@25.2.3)
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@folo-services/drizzle':
specifier: 0.1.44
version: 0.1.44(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)
@ -2090,7 +2093,7 @@ importers:
version: 6.0.85(zod@3.25.76)
better-auth:
specifier: 1.3.28
version: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
drizzle-orm:
specifier: 0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(kysely@0.28.11)(pg@8.16.3)
@ -2108,7 +2111,7 @@ importers:
dependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/configs':
specifier: workspace:*
version: link:../../configs
@ -2157,7 +2160,7 @@ importers:
devDependencies:
'@follow-app/client-sdk':
specifier: 'catalog:'
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@follow/configs':
specifier: workspace:*
version: link:../../configs
@ -5891,6 +5894,11 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@pnpm/constants@7.1.1':
resolution: {integrity: sha512-31pZqMtjwV+Vaq7MaPrT1EoDFSYwye3dp6BiHIGRJmVThCQwySRKM7hCvqqI94epNkqFAAYoWrNynWoRYosGdw==}
engines: {node: '>=16.14'}
@ -11553,11 +11561,13 @@ packages:
git-raw-commits@5.0.0:
resolution: {integrity: sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==}
engines: {node: '>=18'}
deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.
hasBin: true
git-semver-tags@8.0.0:
resolution: {integrity: sha512-N7YRIklvPH3wYWAR2vysaqGLPRcpwQ0GKdlqTiVN5w1UmCdaeY3K8s6DMKRCh54DDdzyt/OAB6C8jgVtb7Y2Fg==}
engines: {node: '>=18'}
deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.
hasBin: true
github-from-package@0.0.0:
@ -18756,7 +18766,7 @@ snapshots:
'@better-auth/expo@1.3.28(better-auth@1.3.28(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)))(expo-crypto@15.0.8(expo@54.0.33))(expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(expo-secure-store@15.0.8(expo@54.0.33))(expo-web-browser@15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)))':
dependencies:
'@better-fetch/fetch': 1.1.18
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))
expo-crypto: 15.0.8(expo@54.0.33)
expo-linking: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0)
@ -18767,7 +18777,7 @@ snapshots:
'@better-auth/stripe@1.3.28(@better-auth/core@1.3.28(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(better-sqlite3@12.6.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(better-auth@1.3.28(better-sqlite3@12.6.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(stripe@20.3.1(@types/node@25.2.3))':
dependencies:
'@better-auth/core': 1.3.28(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(better-sqlite3@12.6.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
defu: 6.1.4
stripe: 20.3.1(@types/node@25.2.3)
zod: 4.3.6
@ -21328,13 +21338,13 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@follow-app/client-sdk@0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@follow-app/client-sdk@0.3.92(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(pg@8.16.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@folo-services/constants': 0.1.53
'@folo-services/drizzle': 0.1.44(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)
'@folo-services/exceptions': 0.1.28
'@folo-services/shared': 0.0.44(@opentelemetry/api@1.9.0)(better-sqlite3@12.6.2)(expo-sqlite@16.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0))(react@19.1.0))(hono@4.12.1)(kysely@0.28.11)(pg@8.16.3)
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
better-auth: 1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
zod: 4.1.12
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
@ -22788,6 +22798,10 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@pnpm/constants@7.1.1': {}
'@pnpm/error@5.0.3':
@ -24284,7 +24298,7 @@ snapshots:
'@speed-highlight/core@1.2.14': {}
'@splinetool/react-spline@4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@splinetool/react-spline@4.1.0(@splinetool/runtime@1.12.58)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@splinetool/runtime': 1.12.58
blurhash: 2.0.5
@ -24294,7 +24308,7 @@ snapshots:
react-merge-refs: 2.1.1
thumbhash: 0.1.1
optionalDependencies:
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@splinetool/runtime@1.12.58':
dependencies:
@ -25030,13 +25044,13 @@ snapshots:
dependencies:
unpic: 4.2.2
'@unpic/react@1.0.2(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@unpic/react@1.0.2(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@unpic/core': 1.0.3
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true
@ -26116,7 +26130,7 @@ snapshots:
before-after-hook@2.2.3: {}
better-auth@1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
better-auth@1.3.28(better-sqlite3@12.6.2)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@better-auth/core': 1.3.28(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(better-sqlite3@12.6.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.3.28(better-call@1.0.19)(better-sqlite3@12.6.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
@ -26133,7 +26147,7 @@ snapshots:
nanostores: 1.1.0
zod: 4.3.6
optionalDependencies:
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
@ -32150,11 +32164,11 @@ snapshots:
nested-error-stacks@2.0.1: {}
next-intl@4.4.0(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.3):
next-intl@4.4.0(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
use-intl: 4.8.3(react@19.1.0)
optionalDependencies:
@ -32165,7 +32179,7 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 16.0.11
'@swc/helpers': 0.5.15
@ -32184,6 +32198,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.0.11
'@next/swc-win32-x64-msvc': 16.0.11
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.58.2
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
@ -33738,7 +33753,7 @@ snapshots:
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react-scan@0.4.3(@types/react@19.1.17)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-router@7.13.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(rollup@4.57.1):
react-scan@0.4.3(@types/react@19.1.17)(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-router@7.13.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(rollup@4.57.1):
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
@ -33760,7 +33775,7 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
tsx: 4.21.0
optionalDependencies:
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-router: 7.13.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
unplugin: 2.1.0
transitivePeerDependencies:
@ -35810,9 +35825,9 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@0.0.9(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.2):
vinext@0.0.9(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.2):
dependencies:
'@unpic/react': 1.0.2(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@unpic/react': 1.0.2(next@16.0.11(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@vercel/og': 0.8.6
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.1.0(react@19.1.0))(react-server-dom-webpack@19.2.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.105.2))(react@19.1.0)(vite@7.3.1(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21