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:
parent
df748a5f6a
commit
93e4a3b48f
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export const LanguageSelector = ({
|
|||
<ResponsiveSelect
|
||||
size="sm"
|
||||
triggerClassName="w-48"
|
||||
triggerTestId="settings-language-select"
|
||||
contentClassName={contentClassName}
|
||||
defaultValue={finalRenderLanguage}
|
||||
value={finalRenderLanguage}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ app-example
|
|||
|
||||
ios/Pods
|
||||
android
|
||||
!e2e/flows/android/
|
||||
!e2e/flows/android/**
|
||||
Podfile.lock
|
||||
|
||||
buildServer.json
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
appId: is.follow
|
||||
---
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: Siri, Dictation & Privacy
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "8%,8%"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const EntryListContentArticle = ({
|
|||
extraData={extraData as EntryExtraData}
|
||||
view={view}
|
||||
hasTopSeparator={index > 0}
|
||||
testID={index === 0 ? "timeline-entry-first" : undefined}
|
||||
/>
|
||||
),
|
||||
[view],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue