Merge pull request #5042 from RSSNext/release/mobile/0.5.6

release(mobile): Release v0.5.6
This commit is contained in:
DIYgod 2026-07-13 11:46:49 +08:00 committed by GitHub
commit 8f7471951d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
299 changed files with 19595 additions and 13632 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
* text=auto eol=lf
*.splinecode filter=lfs diff=lfs merge=lfs -text
*.patch whitespace=-trailing-space

View File

@ -4,7 +4,7 @@ inputs:
xcode-version:
description: "Xcode version to use"
required: false
default: "26.0.1"
default: "26.4.1"
runs:
using: "composite"

View File

@ -5,10 +5,13 @@ on:
branches:
- "**"
paths:
- ".github/actions/setup-xcode/**"
- ".github/workflows/build-ios-development.yml"
- "apps/mobile/web-app/**"
- "apps/mobile/native/**"
- "apps/mobile/package.json"
- "apps/mobile/app.config.ts"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -29,7 +32,7 @@ jobs:
if [ -n "$available" ]; then
echo "runner-label=self-hosted" >> $GITHUB_OUTPUT
else
echo "runner-label=macos-latest" >> $GITHUB_OUTPUT
echo "runner-label=macos-26" >> $GITHUB_OUTPUT
fi
build-ipa-device-self-hosted:
@ -79,9 +82,9 @@ jobs:
build-ipa-device-github:
name: Build iOS IPA for device (GitHub-hosted)
if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'macos-latest'
if: github.secret_source != 'None' && needs.check-runner.outputs.runner-label == 'macos-26'
needs: check-runner
runs-on: macos-latest
runs-on: macos-26
steps:
- name: 📦 Checkout code
@ -132,7 +135,7 @@ jobs:
build-simulator:
name: Build iOS IPA for simulator
if: github.secret_source != 'None'
runs-on: macos-latest
runs-on: macos-26
steps:
- name: 📦 Checkout code

View File

@ -5,6 +5,8 @@ on:
branches:
- "**"
paths:
- ".github/actions/setup-xcode/**"
- ".github/workflows/build-ios.yml"
- "apps/mobile/**"
- "pnpm-lock.yaml"
workflow_dispatch:
@ -45,7 +47,7 @@ jobs:
if [ -n "$available" ]; then
echo "runner-label=self-hosted" >> $GITHUB_OUTPUT
else
echo "runner-label=macos-latest" >> $GITHUB_OUTPUT
echo "runner-label=macos-26" >> $GITHUB_OUTPUT
fi
build-ipa-self-hosted:
@ -100,9 +102,9 @@ jobs:
build-ipa-github:
name: Build iOS IPA (GitHub)
if: needs.check-runner.outputs.runner-label == 'macos-latest'
if: needs.check-runner.outputs.runner-label == 'macos-26'
needs: check-runner
runs-on: macos-latest
runs-on: macos-26
steps:
- name: 📦 Checkout code

View File

@ -21,7 +21,7 @@ jobs:
with:
lfs: true
- name: Cache turbo build setup
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -25,7 +25,7 @@ jobs:
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -29,7 +29,7 @@ jobs:
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -32,7 +32,7 @@ jobs:
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -26,7 +26,7 @@ jobs:
with:
lfs: true
- name: Cache turbo build setup
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
.vercel
.vinext
stats.html
electron.vite.config.*.mjs

2
.nvmrc
View File

@ -1 +1 @@
stable
22

View File

@ -3,6 +3,7 @@ pnpm-lock.yaml
CHANGELOG.md
.context
.waylog
.vinext
apps/external/postcss.config.cjs

View File

@ -12,7 +12,6 @@
<br />
<a href="https://github.com/RSSNext/Folo/stargazers"><img src="https://img.shields.io/github/stars/RSSNext/Follow?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
<a href="https://github.com/RSSNext/Folo/graphs/contributors"><img src="https://img.shields.io/github/contributors/RSSNext/Folo?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
<a href="https://status.follow.is/" target="_blank"><img src="https://status.follow.is/api/badge/18/uptime?color=%2344CC10&labelColor=black&style=flat-square"/></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/downloads/RSSNext/Folo/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
<a href="https://x.com/intent/follow?screen_name=folo_is"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<a href="https://discord.gg/AwWcAQ7euc" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2FAwWcAQ7euc%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>

View File

@ -36,14 +36,14 @@
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"commander": "14.0.1",
"commander": "15.0.0",
"pathe": "2.0.3"
},
"devDependencies": {
"@follow/configs": "workspace:*",
"@types/node": "25.2.3",
"tsup": "8.5.0",
"tsx": "4.21.0",
"@types/node": "26.1.1",
"tsup": "8.5.1",
"tsx": "4.23.0",
"typescript": "catalog:"
}
}

View File

@ -0,0 +1,16 @@
# What's new in v1.10.0
## No longer broken
- Fixed duplicated lines in code blocks from feeds that wrap each line in nested divs (e.g., Cloudflare's changelog)
- Fixed social timeline scroll reset on view changes
- Fixed timeline refreshes returning to the top before new content is rendered
- Fixed mark-read state changes while the timeline is being reset
- Fixed the recent reader spacer appearing when there are no recent entries
- Fixed reading mode content disappearing after translation updates
- Fixed streaming TTS scheduling stability
- Fixed the Spline AI indicator rendering while idle
## Thanks
Special thanks to volunteer contributor @TonyRL for the nested code block fix

View File

@ -66,6 +66,7 @@ export const viteRenderBaseConfig = {
codeInspectorPlugin({
bundler: "vite",
hotKeys: ["altKey"],
needEnvInspector: true,
}),
react({
// jsxImportSource: "@welldone-software/why-did-you-render", // <-----

View File

@ -10,6 +10,77 @@ const ONBOARDING_FEED_URL = "folo://onboarding"
const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false)
const visibleByTestId = (page: Page, testId: string) =>
page.locator(`[data-testid="${testId}"]:visible`).last()
const isElectronPage = (page: Page) => page.url().startsWith("app://")
const optionalRendererResponseTimeout = (page: Page) => (isElectronPage(page) ? 5_000 : 120_000)
const hasRenderedAppShell = async (page: Page) => {
const visibleTestIds = [
"login-button",
"login-modal",
"profile-menu-trigger",
"timeline-tab-articles",
"subscription-discover-trigger",
"discover-form-input",
"settings-language-select",
]
for (const testId of visibleTestIds) {
if (
await visibleByTestId(page, testId)
.isVisible()
.catch(() => false)
) {
return true
}
}
return false
}
const waitForRenderedAppShell = async (page: Page, timeout = 30_000) => {
return expect
.poll(hasRenderedAppShell.bind(null, page), { timeout })
.toBe(true)
.then(() => true)
.catch(() => false)
}
const isAuthenticatedUiReady = async (page: Page) => {
const profileVisible = await visibleByTestId(page, "profile-menu-trigger")
.isVisible()
.catch(() => false)
const loggedOutUiVisible = await isLoggedOutUiReady(page)
if (loggedOutUiVisible) {
return false
}
return profileVisible
}
const isLoggedOutUiReady = async (page: Page) => {
const loginButtonVisible = await page
.locator('[data-testid="login-button"]:visible')
.last()
.isVisible()
.catch(() => false)
const loginModalVisible = await page
.locator('[data-testid="login-modal"]:visible')
.last()
.isVisible()
.catch(() => false)
const loginInputVisible = await page
.locator('[data-testid="login-email-input"]:visible')
.last()
.isVisible()
.catch(() => false)
const registerInputVisible = await page
.locator('[data-testid="register-email-input"]:visible')
.last()
.isVisible()
.catch(() => false)
return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible
}
export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
await page.addInitScript(
@ -37,7 +108,12 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
headers.set("x-token", "r3:e2e-token")
}
return originalFetch(new Request(request, { headers }))
return originalFetch(
new Request(request, {
headers,
credentials: "include",
}),
)
}
if (!nextEnv) {
@ -99,86 +175,104 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
)
}
export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => {
export const openWebApp = async (
page: Page,
env: DesktopE2EEnv,
route = "/timeline/articles/all/pending",
) => {
await injectRecaptchaToken(page, env)
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
if (!(await waitForRenderedAppShell(page))) {
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
await waitForRenderedAppShell(page)
}
}
export const waitForAuthenticated = async (page: Page) => {
const isAuthenticatedUiReady = async () => {
const profileVisible = await page
.getByTestId("profile-menu-trigger")
.isVisible()
.catch(() => false)
const loginModalVisible = await page
.getByTestId("login-modal")
.isVisible()
.catch(() => false)
return profileVisible && !loginModalVisible
}
await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true)
await expect.poll(() => isAuthenticatedUiReady(page), { timeout: 90_000 }).toBe(true)
}
export const waitForLoggedOut = async (page: Page) => {
await expect.poll(() => isLoggedOutUiReady(page), { timeout: 30_000 }).toBe(true)
}
const waitForBetterAuthSessionCookie = async (page: Page, apiURL: string) => {
await expect
.poll(
async () => {
const loginButtonVisible = await page
.getByTestId("login-button")
.last()
.isVisible()
.catch(() => false)
const loginModalVisible = await page
.getByTestId("login-modal")
.last()
.isVisible()
.catch(() => false)
const loginInputVisible = await page
.getByTestId("login-email-input")
.last()
.isVisible()
.catch(() => false)
const registerInputVisible = await page
.getByTestId("register-email-input")
.last()
.isVisible()
.catch(() => false)
return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible
const cookies = await page.context().cookies(apiURL)
return cookies.some(
(cookie) =>
cookie.name === "better-auth.session_token" ||
cookie.name === "__Secure-better-auth.session_token",
)
},
{ timeout: 30_000 },
)
.toBe(true)
}
const waitForBetterAuthSessionCookieCleared = async (page: Page, apiURL: string) => {
await expect
.poll(
async () => {
const cookies = await page.context().cookies(apiURL)
return !cookies.some(
(cookie) =>
cookie.name === "better-auth.session_token" ||
cookie.name === "__Secure-better-auth.session_token",
)
},
{ timeout: 60_000 },
)
.toBe(true)
}
export const ensureLoginModal = async (page: Page) => {
await expect
.poll(
async () => {
const loginModalVisible = await page
.getByTestId("login-modal")
.locator('[data-testid="login-modal"]:visible')
.last()
.isVisible()
.catch(() => false)
const loginButtonVisible = await page
.getByTestId("login-button")
.locator('[data-testid="login-button"]:visible')
.last()
.isVisible()
.catch(() => false)
const loginInputVisible = await page
.getByTestId("login-email-input")
.locator('[data-testid="login-email-input"]:visible')
.last()
.isVisible()
.catch(() => false)
const registerInputVisible = await page
.getByTestId("register-email-input")
.locator('[data-testid="register-email-input"]:visible')
.last()
.isVisible()
.catch(() => false)
const credentialProviderVisible = await page
.locator('[data-testid="login-provider-credential"]:visible')
.last()
.isVisible()
.catch(() => false)
const loginDialogVisible = await page
.locator('[role="dialog"]:visible')
.filter({ hasText: /Sign in to Folo|Login/ })
.last()
.isVisible()
.catch(() => false)
return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible
return (
loginModalVisible ||
loginButtonVisible ||
loginInputVisible ||
registerInputVisible ||
credentialProviderVisible ||
loginDialogVisible
)
},
{ timeout: 30_000 },
)
@ -222,17 +316,41 @@ const ensureCredentialForm = async (page: Page, mode: "register" | "login") => {
if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) {
await expect(credentialProvider).toBeVisible({ timeout: 30_000 })
await credentialProvider.click({ timeout: 30_000, noWaitAfter: true })
await expect
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
timeout: 30_000,
})
.toBe(true)
for (let attempt = 0; attempt < 3; attempt++) {
await credentialProvider.click({ force: true, timeout: 30_000, noWaitAfter: true })
if (
await expect
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
timeout: 5_000,
})
.toBe(true)
.then(() => true)
.catch(() => false)
) {
break
}
}
}
await expect
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
timeout: 15_000,
})
.toBe(true)
if (await isVisible(oppositeForm)) {
await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 })
await oppositeFormSwitcher.click({ timeout: 30_000, noWaitAfter: true })
for (let attempt = 0; attempt < 3; attempt++) {
await oppositeFormSwitcher.click({ force: true, timeout: 10_000, noWaitAfter: true })
if (
await targetInput
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false)
) {
return
}
}
}
await expect(targetInput).toBeVisible({ timeout: 30_000 })
@ -247,7 +365,24 @@ export const registerWithCredential = async (page: Page, account: TestAccount) =
const submit = visibleByTestId(page, "register-submit")
await expect(submit).toBeEnabled({ timeout: 30_000 })
await submit.click()
const signUpResponse = page
.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().includes("/better-auth/sign-up/email"),
{ timeout: optionalRendererResponseTimeout(page) },
)
.catch(() => null)
const [response] = await Promise.all([
signUpResponse,
submit.click({ force: true, noWaitAfter: true }),
])
if (response) {
expect(response.ok()).toBe(true)
await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin)
}
await waitForAuthenticated(page)
}
@ -259,27 +394,69 @@ export const loginWithCredential = async (page: Page, account: TestAccount) => {
const submit = visibleByTestId(page, "login-submit")
await expect(submit).toBeEnabled({ timeout: 30_000 })
await submit.click()
const signInResponse = page
.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().includes("/better-auth/sign-in/email"),
{ timeout: optionalRendererResponseTimeout(page) },
)
.catch(() => null)
const [response] = await Promise.all([
signInResponse,
submit.click({ force: true, noWaitAfter: true }),
])
if (response) {
expect(response.ok()).toBe(true)
await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin)
}
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
await waitForRenderedAppShell(page, 120_000)
await waitForAuthenticated(page)
}
export const logoutFromProfileMenu = async (page: Page) => {
await page.keyboard.press("Escape").catch(() => {})
if (page.url().startsWith("app://")) {
await returnToMainShell(page)
}
await page.getByTestId("profile-menu-trigger").click()
await returnToMainShell(page)
const isElectron = isElectronPage(page)
const appHomeURL = new URL("/timeline/articles/all/pending", page.url()).toString()
await visibleByTestId(page, "profile-menu-trigger").click()
const signOutResponse = page
.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"),
{ timeout: 30_000 },
{ timeout: optionalRendererResponseTimeout(page) },
)
.catch(() => null)
await page.getByTestId("profile-menu-logout").click()
await signOutResponse
const [response] = await Promise.all([
signOutResponse,
page.getByTestId("profile-menu-logout").click({ force: true, noWaitAfter: true }),
])
if (response) {
expect(response.ok()).toBe(true)
await waitForBetterAuthSessionCookieCleared(page, new URL(response.url()).origin)
}
if (isElectron) {
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {})
} else {
await page.goto(appHomeURL, { waitUntil: "domcontentloaded" }).catch(() => {})
}
await waitForRenderedAppShell(page, 60_000)
if (!(await isLoggedOutUiReady(page))) {
if (!isElectron) {
await page.goto(appHomeURL, { waitUntil: "domcontentloaded" }).catch(() => {})
} else {
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
}
await waitForRenderedAppShell(page, 60_000)
}
await waitForLoggedOut(page)
}
@ -303,6 +480,35 @@ const waitForMainShell = async (page: Page) => {
.toBe(true)
}
const closeVisibleDialog = async (page: Page, timeout = 10_000) => {
const activeDialog = page.locator('[role="dialog"]:visible').last()
if (!(await activeDialog.isVisible().catch(() => false))) {
return
}
await page.keyboard.press("Escape").catch(() => {})
if (!(await activeDialog.isVisible().catch(() => false))) {
return
}
const modalClose = activeDialog.getByTestId("modal-close").first()
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click({ force: true, timeout: 3_000 }).catch(() => {})
} else {
await page.keyboard.press("Escape").catch(() => {})
}
if (await activeDialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
}
await expect
.poll(async () => activeDialog.isVisible().catch(() => false), { timeout })
.toBe(false)
}
const returnToMainShell = async (page: Page) => {
const discoverInput = page.getByTestId("discover-form-input")
if (await discoverInput.isVisible().catch(() => false)) {
@ -315,7 +521,15 @@ const returnToMainShell = async (page: Page) => {
}
if (await discoverInput.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
if (page.url().startsWith("app://")) {
await page.keyboard.press("Escape").catch(() => {})
} else {
await page
.goto(new URL("/timeline/articles/all/pending", page.url()).toString(), {
waitUntil: "domcontentloaded",
})
.catch(() => {})
}
}
await expect
@ -323,23 +537,18 @@ const returnToMainShell = async (page: Page) => {
.toBe(false)
}
if (!(await waitForRenderedAppShell(page, 15_000)) && !page.url().startsWith("app://")) {
await page
.goto(new URL("/timeline/articles/all/pending", page.url()).toString(), {
waitUntil: "domcontentloaded",
})
.catch(() => {})
await waitForRenderedAppShell(page)
}
await waitForMainShell(page)
const activeDialog = page.locator('[role="dialog"]:visible').last()
if (await activeDialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
if (await activeDialog.isVisible().catch(() => false)) {
const modalClose = activeDialog.getByTestId("modal-close").first()
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click().catch(() => {})
}
}
await expect
.poll(async () => activeDialog.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
await closeVisibleDialog(page)
}
const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => {
@ -360,13 +569,53 @@ export const openSettings = async (page: Page, tab: "general" | "feeds" = "gener
const settingsModal = page.locator("#setting-modal").first()
const openSettingsFromRouter = async () => {
await returnToMainShell(page)
const opened = await page
.evaluate((targetTab) => {
const router = (
window as Window & {
router?: {
showSettings?: (options?: string | { tab?: string }) => void
}
}
).router
if (typeof router?.showSettings !== "function") {
return false
}
router.showSettings({ tab: targetTab })
return true
}, tab)
.catch(() => false)
if (!opened) {
return false
}
await expect(settingsModal).toBeVisible({ timeout: 15_000 })
return true
}
const openSettingsFromMenu = async () => {
await returnToMainShell(page)
const profileTrigger = page.getByTestId("profile-menu-trigger")
const profileTrigger = visibleByTestId(page, "profile-menu-trigger")
await expect(profileTrigger).toBeVisible({ timeout: 15_000 })
await profileTrigger.click()
const preferencesItem = page.getByTestId("profile-menu-preferences")
for (let attempt = 0; attempt < 3; attempt++) {
await profileTrigger.click({ force: true })
if (
await preferencesItem
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false)
) {
break
}
await page.keyboard.press("Escape").catch(() => {})
}
await expect(preferencesItem).toBeVisible({ timeout: 15_000 })
await preferencesItem.click()
@ -374,10 +623,13 @@ export const openSettings = async (page: Page, tab: "general" | "feeds" = "gener
}
if (!(await settingsModal.isVisible().catch(() => false))) {
try {
await openSettingsFromMenu()
} catch {
await openSettingsFromMenu()
const openedFromRouter = await openSettingsFromRouter().catch(() => false)
if (!openedFromRouter) {
try {
await openSettingsFromMenu()
} catch {
await openSettingsFromMenu()
}
}
}
@ -425,31 +677,81 @@ export const closeSettings = async (page: Page) => {
}
export const setLanguage = async (page: Page, label: string) => {
const languageValue = label.includes("日本語") ? "ja" : label.includes("English") ? "en" : null
const syncResponse = languageValue
? page.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
response.url().includes("/settings/general") &&
response.status() < 400 &&
(response.request().postData() ?? "").includes(`"language":"${languageValue}"`),
{ timeout: 120_000 },
)
: null
await page.getByTestId("settings-language-select").click()
await page.getByRole("option", { name: label }).click()
await syncResponse
await expect(page.getByTestId("settings-language-select")).toContainText(label, {
timeout: 30_000,
})
}
export const getLanguageLabel = async (page: Page) => {
return page.getByTestId("settings-language-select").textContent()
return visibleByTestId(page, "settings-language-select").textContent()
}
export const openOnboardingFeedForm = async (
page: Page,
_env?: DesktopE2EEnv,
_options?: { electron?: boolean },
env?: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
const discoverInput = page.getByTestId("discover-form-input")
if (!(await discoverInput.isVisible().catch(() => false))) {
if (env && !options?.electron && !page.url().startsWith("app://")) {
await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" })
await waitForRenderedAppShell(page, 120_000)
} else if (!(await discoverInput.isVisible().catch(() => false))) {
await returnToMainShell(page)
const discoverTrigger = page.getByTestId("subscription-discover-trigger")
await expect(discoverTrigger).toBeVisible({ timeout: 15_000 })
await discoverTrigger.click()
if (
!(await discoverInput.isVisible().catch(() => false)) &&
env &&
!options?.electron &&
!page.url().startsWith("app://")
) {
await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" })
await waitForRenderedAppShell(page, 120_000)
}
}
await expect(discoverInput).toBeVisible({ timeout: 15_000 })
await expect(discoverInput).toBeVisible({ timeout: 120_000 })
await discoverInput.fill("")
await expect(discoverInput).toHaveValue("", { timeout: 15_000 })
const onboardingFeedResponse = page
.waitForResponse(
(response) => {
const url = new URL(response.url())
return (
response.request().method() === "GET" &&
url.pathname === "/feeds" &&
url.searchParams.get("url") === ONBOARDING_FEED_URL
)
},
{ timeout: 120_000 },
)
.catch(() => null)
await discoverInput.fill(ONBOARDING_FEED_URL)
await expect(discoverInput).toHaveValue(ONBOARDING_FEED_URL, { timeout: 15_000 })
await discoverInput.press("Enter")
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
await onboardingFeedResponse
await expect(visibleByTestId(page, "feed-form-submit")).toBeVisible({ timeout: 120_000 })
await expect(visibleByTestId(page, "feed-form-submit")).toBeEnabled({ timeout: 120_000 })
}
export const followOnboardingFeed = async (
@ -472,23 +774,38 @@ export const followOnboardingFeed = async (
export const dismissFeedForm = async (page: Page) => {
const cancelButton = visibleByTestId(page, "feed-form-cancel")
const dialog = page.locator('[role="dialog"]').last()
const dialog = page.locator('[role="dialog"]:visible').last()
if (!(await cancelButton.isVisible().catch(() => false))) {
if (await dialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
}
if (!(await dialog.isVisible().catch(() => false))) {
return
}
await cancelButton.click()
const modalClose = dialog.getByTestId("modal-close").first()
await expect
.poll(
async () =>
(await cancelButton.isVisible().catch(() => false)) ||
(await modalClose.isVisible().catch(() => false)),
{ timeout: 15_000 },
)
.toBe(true)
if (await cancelButton.isVisible().catch(() => false)) {
await cancelButton.click({ force: true, timeout: 3_000 }).catch(() => {})
} else {
await closeVisibleDialog(page)
}
if (
(await cancelButton.isVisible().catch(() => false)) ||
(await dialog.isVisible().catch(() => false))
) {
await page.keyboard.press("Escape").catch(() => {})
await closeVisibleDialog(page)
}
await expect
.poll(async () => dialog.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => {
@ -576,12 +893,14 @@ export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {
const videosTab = page.getByTestId("timeline-tab-videos")
await videosTab.click()
await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
const articlesTab = page.getByTestId("timeline-tab-articles")
await articlesTab.click()
await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
await closeVisibleDialog(page)
await expect
.poll(async () => page.locator("[data-entry-id]").count(), { timeout: 120_000 })
.toBeGreaterThan(0)
const unreadOnboardingEntry = page
.locator('[data-entry-id][data-read="false"]:visible')

View File

@ -20,7 +20,7 @@ test.describe("web core flows", () => {
page,
browser,
}) => {
test.setTimeout(180_000)
test.setTimeout(600_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-core")

View File

@ -24,9 +24,39 @@ const closeContextSafely = async (context: BrowserContext) => {
}
}
const reloadAndOpenSettingsWithRemoteLanguage = async (
page: Awaited<ReturnType<BrowserContext["newPage"]>>,
apiURL: string,
language: string,
) => {
await expect
.poll(
async () =>
page.evaluate(async (targetApiURL) => {
const response = await fetch(`${targetApiURL}/settings`, {
cache: "no-store",
credentials: "include",
})
if (!response.ok) {
return null
}
const payload = (await response.json().catch(() => null)) as {
settings?: { general?: { language?: string } }
} | null
return payload?.settings?.general?.language ?? null
}, apiURL),
{ timeout: 120_000 },
)
.toBe(language)
await page.reload({ waitUntil: "domcontentloaded" })
await openSettings(page)
}
test.describe("web multi-session sync", () => {
test("syncs settings between two browser sessions", async ({ browser }) => {
test.setTimeout(180_000)
test.setTimeout(600_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-sync")
@ -44,22 +74,15 @@ test.describe("web multi-session sync", () => {
await loginWithCredential(pageB, account)
await openSettings(pageA)
await openSettings(pageB)
await test.step("session A change syncs to session B", async () => {
await setLanguage(pageA, "日本語")
await expect
.poll(async () => getLanguageLabel(pageA), { timeout: 15_000 })
.toContain("日本語")
await reloadAndOpenSettingsWithRemoteLanguage(pageB, env.apiURL, "ja")
await expect
.poll(
async () => {
await pageB.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageB)
return getLanguageLabel(pageB)
},
{ timeout: 30_000 },
)
.poll(async () => getLanguageLabel(pageB), { timeout: 15_000 })
.toContain("日本語")
})
@ -68,15 +91,9 @@ test.describe("web multi-session sync", () => {
await expect
.poll(async () => getLanguageLabel(pageB), { timeout: 15_000 })
.toContain("English")
await reloadAndOpenSettingsWithRemoteLanguage(pageA, env.apiURL, "en")
await expect
.poll(
async () => {
await pageA.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageA)
return getLanguageLabel(pageA)
},
{ timeout: 60_000 },
)
.poll(async () => getLanguageLabel(pageA), { timeout: 15_000 })
.toContain("English")
})

View File

@ -11,6 +11,9 @@ export default defineConfig({
lib: {
entry: "./layer/main/src/index.ts",
},
rollupOptions: {
external: ["canvas", "jsdom"],
},
},
resolve: {
alias: {

View File

@ -24,38 +24,37 @@
"dependencies": {
"@electron-toolkit/preload": "3.0.2",
"@electron-toolkit/utils": "4.0.0",
"@eneris/push-receiver": "4.3.0",
"@eneris/push-receiver": "4.3.1",
"@follow-app/client-sdk": "catalog:",
"@follow-app/readability": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"builder-util-runtime": "9.5.1",
"electron-context-menu": "4.1.1",
"electron-ipc-decorator": "0.2.0",
"electron-log": "5.4.3",
"builder-util-runtime": "9.7.0",
"electron-context-menu": "4.1.2",
"electron-ipc-decorator": "1.0.1",
"electron-log": "5.4.4",
"electron-squirrel-startup": "1.0.1",
"electron-store": "11.0.2",
"electron-updater": "6.7.3",
"es-toolkit": "1.44.0",
"font-list": "2.0.2",
"i18next": "25.8.6",
"js-yaml": "4.1.1",
"ky": "1.14.3",
"linkedom": "0.18.12",
"electron-updater": "6.8.9",
"es-toolkit": "1.49.0",
"font-list": "2.1.0",
"i18next": "26.3.5",
"js-yaml": "5.2.1",
"ky": "2.0.2",
"linkedom": "0.18.13",
"lowdb": "7.0.1",
"node-machine-id": "1.1.12",
"ofetch": "1.5.1",
"pathe": "2.0.3",
"semver": "7.7.4",
"tar": "7.5.7",
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22"
"semver": "7.8.5",
"tar": "7.5.19",
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.23"
},
"devDependencies": {
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@types/js-yaml": "4.0.9",
"@types/node": "25.2.3",
"electron": "38.3.0",
"@types/node": "26.1.1",
"electron": "43.1.0",
"electron-devtools-installer": "4.0.0",
"typescript": "catalog:"
}

View File

@ -4,8 +4,7 @@ import { fileURLToPath } from "node:url"
import { callWindowExpose } from "@follow/shared/bridge"
import { DEV } from "@follow/shared/constants"
import { app, BrowserWindow, clipboard, dialog, shell } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator"
import path from "pathe"
import { START_IN_TRAY_ARGS } from "~/constants/app"
@ -56,7 +55,7 @@ export class AppService extends IpcService {
}
@IpcMethod()
switchAppLocale(context: IpcContext, input: string): void {
switchAppLocale(input: string): void {
i18n.changeLanguage(input)
AppManager.registerMenuAndContextMenu()
registerAppTray()
@ -90,16 +89,17 @@ export class AppService extends IpcService {
}
@IpcMethod()
async openExternal(_context: IpcContext, url: string): Promise<void> {
async openExternal(url: string): Promise<void> {
if (!url) return
await shell.openExternal(url)
}
@IpcMethod()
windowAction(context: IpcContext, input: WindowActionInput): void {
if (context.sender.getType() === "window") {
const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow()
windowAction(input: WindowActionInput): void {
const { sender } = getIpcContext()
if (sender.getType() === "window") {
const window: BrowserWindow | null = (sender as Sender).getOwnerBrowserWindow()
if (!window) return
switch (input.action) {
@ -124,18 +124,18 @@ export class AppService extends IpcService {
}
@IpcMethod()
quitAndInstall(_context: IpcContext): void {
quitAndInstall(): void {
quitAndInstall()
}
@IpcMethod()
readClipboard(_context: IpcContext): string {
readClipboard(): string {
return clipboard.readText()
}
@IpcMethod()
async search(context: IpcContext, input: SearchInput): Promise<Electron.Result | null> {
const { sender: webContents } = context
async search(input: SearchInput): Promise<Electron.Result | null> {
const { sender: webContents } = getIpcContext()
const { promise, resolve } = Promise.withResolvers<Electron.Result | null>()
@ -148,12 +148,13 @@ export class AppService extends IpcService {
}
@IpcMethod()
clearSearch(context: IpcContext): void {
context.sender.stopFindInPage("keepSelection")
clearSearch(): void {
getIpcContext().sender.stopFindInPage("keepSelection")
}
@IpcMethod()
async download(context: IpcContext, input: string): Promise<void> {
async download(input: string): Promise<void> {
const { sender } = getIpcContext()
const result = await dialog.showSaveDialog({
defaultPath: input.split("/").pop(),
})
@ -162,14 +163,14 @@ export class AppService extends IpcService {
try {
await downloadFile(input, result.filePath)
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
const senderWindow = (sender as Sender).getOwnerBrowserWindow()
if (senderWindow) {
callWindowExpose(senderWindow).toast.success("Download success!", {
duration: 1000,
})
}
} catch (err) {
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
const senderWindow = (sender as Sender).getOwnerBrowserWindow()
if (senderWindow) {
callWindowExpose(senderWindow).toast.error("Download failed!", {
duration: 1000,
@ -180,11 +181,9 @@ export class AppService extends IpcService {
}
@IpcMethod()
async exportCurrentPageAsPdf(
context: IpcContext,
input: ExportCurrentPageAsPdfInput = {},
): Promise<string | null> {
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
async exportCurrentPageAsPdf(input: ExportCurrentPageAsPdfInput = {}): Promise<string | null> {
const { sender } = getIpcContext()
const senderWindow = (sender as Sender).getOwnerBrowserWindow()
const dialogOptions: Electron.SaveDialogOptions = {
defaultPath: ensurePdfExtension(input.defaultPath || "Untitled.pdf"),
filters: [{ name: "PDF", extensions: ["pdf"] }],
@ -196,7 +195,7 @@ export class AppService extends IpcService {
if (result.canceled || !result.filePath) return null
const pdfData = await context.sender.printToPDF({
const pdfData = await sender.printToPDF({
printBackground: true,
preferCSSPageSize: true,
})
@ -208,12 +207,12 @@ export class AppService extends IpcService {
}
@IpcMethod()
getAppPath(_context: IpcContext): string {
getAppPath(): string {
return app.getAppPath()
}
@IpcMethod()
resolveAppAsarPath(_context: IpcContext, input: string): string {
resolveAppAsarPath(input: string): string {
const resolvedInput = input.startsWith("file://") ? fileURLToPath(input) : input
if (path.isAbsolute(resolvedInput)) {
@ -224,7 +223,7 @@ export class AppService extends IpcService {
}
@IpcMethod()
readyToShowMainWindow(_context: IpcContext) {
readyToShowMainWindow() {
const shouldShowWindow =
!app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS)
if (shouldShowWindow) {
@ -234,18 +233,18 @@ export class AppService extends IpcService {
}
@IpcMethod()
openCacheFolder(_context: IpcContext): void {
openCacheFolder(): void {
const dir = path.join(app.getPath("userData"), "cache")
shell.openPath(dir)
}
@IpcMethod()
getCacheLimit(_context: IpcContext): number {
getCacheLimit(): number {
return store.get(StoreKey.CacheSizeLimit) || 0
}
@IpcMethod()
async clearCache(_context: IpcContext): Promise<void> {
async clearCache(): Promise<void> {
const cachePath = path.join(app.getPath("userData"), "cache", "Cache_Data")
if (process.platform === "win32") {
// Request elevation on Windows
@ -268,7 +267,7 @@ export class AppService extends IpcService {
}
@IpcMethod()
limitCacheSize(_context: IpcContext, input: number): void {
limitCacheSize(input: number): void {
if (input === 0) {
store.delete(StoreKey.CacheSizeLimit)
} else {
@ -277,17 +276,17 @@ export class AppService extends IpcService {
}
@IpcMethod()
revealLogFile(_context: IpcContext) {
revealLogFile() {
return revealLogFile()
}
@IpcMethod()
getCacheSize(_context: IpcContext) {
getCacheSize() {
return getCacheSize()
}
@IpcMethod()
async selectDirectory(_context: IpcContext): Promise<string | null> {
async selectDirectory(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
})
@ -296,7 +295,7 @@ export class AppService extends IpcService {
}
@IpcMethod()
async checkPathExists(_context: IpcContext, input: string): Promise<boolean> {
async checkPathExists(input: string): Promise<boolean> {
try {
await fsp.access(input)
return true

View File

@ -5,7 +5,6 @@ import {
import { env } from "@follow/shared/env.desktop"
import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
@ -28,6 +27,37 @@ export class AuthService extends IpcService {
private pendingTwoFactorCookieHeader: string | null = null
private async getManagedCookieHeader(): Promise<string> {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) {
return ""
}
return buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
}),
)
}
private async persistAuthCookiesFromResponse(response: Response): Promise<void> {
const setCookieValues =
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []
const setCookie =
setCookieValues.length > 0
? setCookieValues.join(", ")
: response.headers.get("set-cookie") || ""
const mainWindow = WindowManager.getMainWindow()
if (response.ok && setCookie && mainWindow) {
await persistManagedAuthCookiesFromSetCookieHeader({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
setCookieHeader: setCookie,
})
}
}
private getAuthRequestHeaders(additionalHeaders?: Record<string, string>) {
return {
...createDesktopAPIHeaders({ version: PKG.version }),
@ -104,13 +134,7 @@ export class AuthService extends IpcService {
? setCookieValues.join(", ")
: response.headers.get("set-cookie") || ""
const mainWindow = WindowManager.getMainWindow()
if (response.ok && setCookie && mainWindow) {
await persistManagedAuthCookiesFromSetCookieHeader({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
setCookieHeader: setCookie,
})
}
await this.persistAuthCookiesFromResponse(response)
const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie)
this.pendingTwoFactorCookieHeader =
@ -142,7 +166,53 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async sessionChanged(_context: IpcContext, preferredToken?: string): Promise<void> {
async fetchWithAuth(payload: {
body?: string
headers?: Record<string, string>
method: string
url: string
}) {
const requestURL = new URL(payload.url)
const apiURL = new URL(env.VITE_API_URL)
if (requestURL.origin !== apiURL.origin) {
throw new Error("Refusing to proxy non-API request")
}
const headers = new Headers(payload.headers)
headers.delete("content-length")
headers.delete("host")
for (const [key, value] of Object.entries(this.getAuthRequestHeaders())) {
headers.set(key, value)
}
if (!headers.has("cookie")) {
const cookieHeader = await this.getManagedCookieHeader()
if (cookieHeader) {
headers.set("Cookie", cookieHeader)
}
}
const response = await fetch(payload.url, {
method: payload.method,
headers,
body:
payload.body !== undefined && payload.method !== "GET" && payload.method !== "HEAD"
? payload.body
: undefined,
})
await this.persistAuthCookiesFromResponse(response)
return {
body: await response.text(),
headers: Array.from(response.headers.entries()),
status: response.status,
statusText: response.statusText,
}
}
@IpcMethod()
async sessionChanged(preferredToken?: string): Promise<void> {
await updateNotificationsToken()
// Sync the current desktop session to the npm CLI login.
@ -155,7 +225,7 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async signOut(_context: IpcContext): Promise<void> {
async signOut(): Promise<void> {
await deleteNotificationsToken()
// Clear the synced CLI login on sign out.
@ -165,7 +235,7 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async signOutRemote(_context: IpcContext, token?: string): Promise<void> {
async signOutRemote(token?: string): Promise<void> {
await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, {
method: "POST",
headers: this.getAuthRequestHeaders(
@ -181,10 +251,11 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async verifyTotp(
_context: IpcContext,
payload: { code: string; trustDevice?: boolean; headers?: Record<string, string> },
) {
async verifyTotp(payload: {
code: string
trustDevice?: boolean
headers?: Record<string, string>
}) {
const mainWindow = WindowManager.getMainWindow()
const cookieHeader =
this.pendingTwoFactorCookieHeader ||
@ -253,10 +324,11 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async signInWithCredential(
_context: IpcContext,
payload: { email: string; password: string; headers?: Record<string, string> },
) {
async signInWithCredential(payload: {
email: string
password: string
headers?: Record<string, string>
}) {
return this.requestCredentialAuth(
"/sign-in/email",
{
@ -268,16 +340,13 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async signUpWithCredential(
_context: IpcContext,
payload: {
email: string
password: string
name: string
callbackURL: string
headers?: Record<string, string>
},
) {
async signUpWithCredential(payload: {
email: string
password: string
name: string
callbackURL: string
headers?: Record<string, string>
}) {
return this.requestCredentialAuth(
"/sign-up/email",
{
@ -291,7 +360,7 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async setSessionToken(_context: IpcContext, token: string): Promise<void> {
async setSessionToken(token: string): Promise<void> {
await this.applySessionToken(token)
}
}

View File

@ -1,4 +1,3 @@
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import {
@ -27,7 +26,7 @@ export class CliService extends IpcService {
static override readonly groupName = "cli"
@IpcMethod()
async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> {
async getInstallStatus(): Promise<CliInstallStatus> {
const [config, npxAvailable, desktopToken] = await Promise.all([
readCliConfig(),
isCliRunnerAvailable(),
@ -46,10 +45,7 @@ export class CliService extends IpcService {
}
@IpcMethod()
async installCli(
_context: IpcContext,
preferredToken?: string,
): Promise<{ success: boolean; error?: string }> {
async installCli(preferredToken?: string): Promise<{ success: boolean; error?: string }> {
try {
if (!(await isCliRunnerAvailable())) {
return { success: false, error: "npx is not available. Install Node.js and npm first." }
@ -73,7 +69,7 @@ export class CliService extends IpcService {
}
@IpcMethod()
async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
async uninstallCli(): Promise<{ success: boolean; error?: string }> {
try {
await syncSessionToCliConfig()
return { success: true }

View File

@ -1,5 +1,4 @@
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator"
interface InspectElementInput {
x: number
@ -10,7 +9,7 @@ export class DebugService extends IpcService {
static override readonly groupName = "debug"
@IpcMethod()
inspectElement(context: IpcContext, input: InspectElementInput): void {
context.sender.inspectElement(input.x, input.y)
inspectElement(input: InspectElementInput): void {
getIpcContext().sender.inspectElement(input.x, input.y)
}
}

View File

@ -1,4 +1,3 @@
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../../constants/app"
@ -82,7 +81,7 @@ export class DockService extends IpcService {
}
@IpcMethod()
setDockBadge(_context: IpcContext, count: number): void {
setDockBadge(count: number): void {
setDockCount(count)
}
}

View File

@ -1,7 +1,6 @@
import fsp from "node:fs/promises"
import os from "node:os"
import type { IpcContext } from "electron-ipc-decorator"
import path from "pathe"
import { afterEach, describe, expect, it, vi } from "vitest"
@ -51,10 +50,9 @@ describe("IntegrationService", () => {
it("saves Obsidian titles with path separators as one markdown file", async () => {
vaultPath = await fsp.mkdtemp(path.join(os.tmpdir(), "folo-obsidian-"))
const service = new IntegrationService()
const context = {} as IpcContext
await expect(
service.saveToObsidian(context, {
service.saveToObsidian({
url: "https://example.com",
title: "KAWA DESIGN 少女前线2追放 索米·雪兔献礼 1/6比例手办",
content: "content",

View File

@ -2,7 +2,6 @@ import { existsSync } from "node:fs"
import fsp from "node:fs/promises"
import { shell } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import path from "pathe"
@ -86,20 +85,17 @@ export class IntegrationService extends IpcService {
static override readonly groupName = "integration"
@IpcMethod()
async saveToObsidian(
context: IpcContext,
input: {
url: string
title: string
content: string
author: string
publishedAt: string
vaultPath: string
description?: string
feedTitle?: string
feedUrl?: string
},
) {
async saveToObsidian(input: {
url: string
title: string
content: string
author: string
publishedAt: string
vaultPath: string
description?: string
feedTitle?: string
feedUrl?: string
}) {
try {
const {
url,
@ -151,22 +147,22 @@ ${content}
}
@IpcMethod()
async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise<any> {
async saveToEagle(input: SaveToEagleInput): Promise<any> {
return saveMediaToEagle(input)
}
@IpcMethod()
setEagleContextMenuEnabled(context: IpcContext, input: SetEagleContextMenuEnabledInput): void {
setEagleContextMenuEnabled(input: SetEagleContextMenuEnabledInput): void {
store.set("eagleContextMenuEnabled", input.enabled)
}
@IpcMethod()
async loginToQBittorrent(context: IpcContext, input: LoginToQBittorrentInput) {
async loginToQBittorrent(input: LoginToQBittorrentInput) {
const { host, username, password } = input
const existingSID = store.get("qbittorrentSID")
if (existingSID) {
const errorMessage = await this.checkQBittorrentAuth(context, { host })
const errorMessage = await this.checkQBittorrentAuth({ host })
if (!errorMessage) {
return
}
@ -194,7 +190,7 @@ ${content}
return
}
async checkQBittorrentAuth(context: IpcContext, input: CheckQBittorrentAuthInput) {
async checkQBittorrentAuth(input: CheckQBittorrentAuthInput) {
const { host } = input
const sid = store.get("qbittorrentSID")
if (!sid) {
@ -214,7 +210,7 @@ ${content}
}
@IpcMethod()
async addMagnet(context: IpcContext, input: AddMagnetInput) {
async addMagnet(input: AddMagnetInput) {
const { host, urls } = input
const sid = store.get("qbittorrentSID")
if (!sid) {
@ -235,12 +231,11 @@ ${content}
return `Failed to add magnet links: ${text}`
}
// eslint-disable-next-line no-console
console.log(`Added magnet links to qBittorrent: ${urls.join(", ")}`)
}
@IpcMethod()
async customFetch(context: IpcContext, input: CustomFetchInput) {
async customFetch(input: CustomFetchInput) {
const requestId = Math.random().toString(36).slice(2, 8)
const { url, method, headers, body, timeout = 10_000 } = input
@ -383,7 +378,7 @@ ${content}
}
@IpcMethod()
async openURLScheme(context: IpcContext, scheme: string) {
async openURLScheme(scheme: string) {
const requestId = Math.random().toString(36).slice(2, 8)
try {

View File

@ -1,7 +1,6 @@
import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron"
import type { MenuItemConstructorOptions, MessageBoxOptions, WebContents } from "electron"
import { dialog, Menu, ShareMenu } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator"
type SerializableMenuItem = Omit<MenuItemConstructorOptions, "click" | "submenu"> & {
submenu?: SerializableMenuItem[]
@ -22,7 +21,7 @@ export class MenuService extends IpcService {
private normalizeMenuItems(
items: SerializableMenuItem[],
context: IpcContext,
sender: WebContents,
path: number[] = [],
): MenuItemConstructorOptions[] {
return items.map((item, index) => {
@ -30,20 +29,21 @@ export class MenuService extends IpcService {
return {
...item,
click() {
context.sender.send("menu-click", {
sender.send("menu-click", {
id: item.id,
path: curPath,
})
},
submenu: item.submenu ? this.normalizeMenuItems(item.submenu, context, curPath) : undefined,
submenu: item.submenu ? this.normalizeMenuItems(item.submenu, sender, curPath) : undefined,
}
})
}
@IpcMethod()
async showContextMenu(context: IpcContext, input: ShowContextMenuInput): Promise<void> {
async showContextMenu(input: ShowContextMenuInput): Promise<void> {
const { sender } = getIpcContext()
const defer = Promise.withResolvers<void>()
const normalizedMenuItems = this.normalizeMenuItems(input.items, context)
const normalizedMenuItems = this.normalizeMenuItems(input.items, sender)
const menu = Menu.buildFromTemplate(normalizedMenuItems)
menu.popup({
@ -53,7 +53,7 @@ export class MenuService extends IpcService {
}
@IpcMethod()
async showConfirmDialog(_context: IpcContext, input: ShowConfirmDialogInput): Promise<boolean> {
async showConfirmDialog(input: ShowConfirmDialogInput): Promise<boolean> {
const result = await dialog.showMessageBox({
message: input.title,
detail: input.message,
@ -64,14 +64,15 @@ export class MenuService extends IpcService {
}
@IpcMethod()
async showShareMenu(context: IpcContext, input: string): Promise<void> {
async showShareMenu(input: string): Promise<void> {
const { sender } = getIpcContext()
const menu = new ShareMenu({
urls: [input],
})
menu.popup({
callback: () => {
context.sender.send("menu-closed")
sender.send("menu-closed")
},
})
}

View File

@ -35,7 +35,7 @@ feedUrl: "https://www.techflowpost.com/feed"
expect(load(frontmatter.replace(/^---\n/, "").replace(/\n---$/, ""))).toEqual({
url: "https://www.techflowpost.com/zh-CN/article/31569",
author: "Deep Tide TechFlow",
publishedAt: new Date("2026-05-14T04:20:44.000Z"),
publishedAt: "2026-05-14T04:20:44",
description: [
"Author: David, Deep Tide TechFlow.",
"",

View File

@ -3,8 +3,7 @@ import fs from "node:fs"
import { callWindowExpose } from "@follow/shared/bridge"
import { readability } from "@follow-app/readability"
import { app, BrowserWindow } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { getIpcContext, IpcMethod, IpcService } from "electron-ipc-decorator"
import path from "pathe"
import type { ModelResult } from "vscode-languagedetection"
@ -74,7 +73,7 @@ export class ReaderService extends IpcService {
static override readonly groupName = "reader"
@IpcMethod()
async readability(_context: IpcContext, input: ReadabilityInput) {
async readability(input: ReadabilityInput) {
const { url } = input
if (!url) {
@ -86,7 +85,7 @@ export class ReaderService extends IpcService {
}
@IpcMethod()
async tts(context: IpcContext, input: TtsInput): Promise<string | null> {
async tts(input: TtsInput): Promise<string | null> {
const { id } = input
const text = input.text.trim()
const voice = input.voice?.trim()
@ -95,7 +94,7 @@ export class ReaderService extends IpcService {
return null
}
const window = BrowserWindow.fromWebContents(context.sender)
const window = BrowserWindow.fromWebContents(getIpcContext().sender)
if (!window) return null
const dirPath = path.join(app.getPath("userData"), "Cache", "tts", id)
@ -131,8 +130,8 @@ export class ReaderService extends IpcService {
}
@IpcMethod()
async getVoices(context: IpcContext) {
const window = BrowserWindow.fromWebContents(context.sender)
async getVoices() {
const window = BrowserWindow.fromWebContents(getIpcContext().sender)
try {
const response = await fetch(`${TTS_SERVICE_URL}/voices`)
if (!response.ok) {
@ -149,7 +148,6 @@ export class ReaderService extends IpcService {
@IpcMethod()
async detectCodeStringLanguage(
_context: IpcContext,
input: DetectCodeStringLanguageInput,
): Promise<ModelResult | undefined> {
const { codeString } = input

View File

@ -1,7 +1,6 @@
import { createRequire } from "node:module"
import { app, nativeTheme } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { WindowManager } from "~/manager/window"
@ -23,62 +22,62 @@ export class SettingService extends IpcService {
static override readonly groupName = "setting"
@IpcMethod()
getLoginItemSettings(_context: IpcContext): Electron.LoginItemSettings {
getLoginItemSettings(): Electron.LoginItemSettings {
return app.getLoginItemSettings()
}
@IpcMethod()
setLoginItemSettings(_context: IpcContext, input: SetLoginItemSettingsInput): void {
setLoginItemSettings(input: SetLoginItemSettingsInput): void {
app.setLoginItemSettings(input)
}
@IpcMethod()
openSettingWindow(_context: IpcContext): void {
openSettingWindow(): void {
WindowManager.showSetting()
}
@IpcMethod()
async getSystemFonts(_context: IpcContext): Promise<string[]> {
async getSystemFonts(): Promise<string[]> {
const fonts = await require("font-list").getFonts()
return fonts.map((font: string) => font.replaceAll('"', ""))
}
@IpcMethod()
getAppearance(_context: IpcContext): "light" | "dark" | "system" {
getAppearance(): "light" | "dark" | "system" {
return nativeTheme.themeSource
}
@IpcMethod()
setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void {
setAppearance(appearance: "light" | "dark" | "system"): void {
nativeTheme.themeSource = appearance
store.set("appearance", appearance)
}
@IpcMethod()
getMinimizeToTray(_context: IpcContext): boolean {
getMinimizeToTray(): boolean {
return getTrayConfig()
}
@IpcMethod()
setMinimizeToTray(_context: IpcContext, minimize: boolean): void {
setMinimizeToTray(minimize: boolean): void {
setTrayConfig(minimize)
}
@IpcMethod()
getProxyConfig(_context: IpcContext) {
getProxyConfig() {
const proxy = store.get("proxy")
return proxy ?? undefined
}
@IpcMethod()
setProxyConfig(_context: IpcContext, config: string) {
setProxyConfig(config: string) {
const result = setProxyConfig(config)
updateProxy()
return result
}
@IpcMethod()
getMessagingToken(_context: IpcContext): string | null {
getMessagingToken(): string | null {
return store.get("notifications-credentials") as string | null
}
}

View File

@ -74,12 +74,10 @@ export const clearAllData = async () => {
await ses.clearStorageData({
storages: [
"websql",
"filesystem",
"indexdb",
"localstorage",
"shadercache",
"websql",
"serviceworkers",
"cookies",
],

View File

@ -0,0 +1,109 @@
import type { Credentials } from "@eneris/push-receiver/dist/types"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { logger } from "~/logger"
import { apiClient } from "./api-client"
import { store } from "./store"
import { updateNotificationsToken } from "./user"
const { createTokenMock } = vi.hoisted(() => ({
createTokenMock: vi.fn(),
}))
vi.mock("./api-client", () => ({
apiClient: {
messaging: {
createToken: createTokenMock,
deleteToken: vi.fn(),
},
},
}))
vi.mock("./store", () => ({
store: {
get: vi.fn(),
set: vi.fn(),
},
}))
vi.mock("~/env", () => ({
isLinux: false,
isMacOS: true,
isWindows: false,
}))
vi.mock("~/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}))
const credentials = {
fcm: {
installation: {
createdAt: 0,
expiresIn: 0,
fid: "fid",
refreshToken: "refresh-token-secret",
token: "installation-token-secret",
},
token: "fcm-token-secret",
},
gcm: {
androidId: "android-id",
appId: "app-id",
securityToken: "security-token-secret",
token: "gcm-token-secret",
},
keys: {
authSecret: "auth-secret",
privateKey: "private-key-secret",
publicKey: "public-key",
},
config: {
bundleId: "is.follow",
projectId: "project-id",
vapidKey: "vapid-key",
},
} satisfies Credentials
describe("updateNotificationsToken", () => {
beforeEach(() => {
vi.clearAllMocks()
createTokenMock.mockResolvedValue(undefined)
})
it("registers the token without writing it to logs", async () => {
await updateNotificationsToken(credentials)
expect(store.set).toHaveBeenCalledWith("notifications-credentials", credentials)
expect(apiClient.messaging.createToken).toHaveBeenCalledWith({
channel: "macos",
token: credentials.fcm.token,
})
const serializedLogs = JSON.stringify([
...vi.mocked(logger.info).mock.calls,
...vi.mocked(logger.error).mock.calls,
])
expect(serializedLogs).not.toContain(credentials.fcm.token)
})
it("logs only a safe summary when registration fails", async () => {
createTokenMock.mockRejectedValueOnce({
message: "request failed",
request: {
body: {
token: credentials.fcm.token,
},
},
})
await updateNotificationsToken(credentials)
expect(logger.error).toHaveBeenCalledWith("Failed to update notification token")
expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(credentials.fcm.token)
})
})

View File

@ -25,9 +25,9 @@ export const updateNotificationsToken = async (newCredentials?: Credentials) =>
token: credentials.fcm.token,
channel: notificationChannel,
})
logger.info("updateNotificationsToken success: ", credentials.fcm.token)
} catch (error) {
logger.error("updateNotificationsToken error: ", error)
logger.info("Notification token updated successfully")
} catch {
logger.error("Failed to update notification token")
}
}
}

View File

@ -0,0 +1,213 @@
import type { Credentials } from "@eneris/push-receiver/dist/types"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { logger } from "~/logger"
import { updateNotificationsToken } from "../lib/user"
import { AppManager } from "./app"
const credentials = {
fcm: {
installation: {
createdAt: 0,
expiresIn: 0,
fid: "fid",
refreshToken: "refresh-token-secret",
token: "installation-token-secret",
},
token: "fcm-token-secret",
},
gcm: {
androidId: "android-id",
appId: "app-id",
securityToken: "security-token-secret",
token: "gcm-token-secret",
},
keys: {
authSecret: "auth-secret",
privateKey: "private-key-secret",
publicKey: "public-key",
},
config: {
bundleId: "is.follow",
projectId: "project-id",
vapidKey: "vapid-key",
},
} satisfies Credentials
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
firebaseConfig: {
apiKey: "firebase-api-key-secret",
appId: "app-id",
messagingSenderId: "sender-id",
projectId: "project-id",
},
onCredentialsChanged: undefined as ((event: { newCredentials: Credentials }) => void) | undefined,
onNotification: undefined as
((event: { message: { data?: Record<string, unknown> } }) => void) | undefined,
onReady: undefined as (() => void) | undefined,
receiverConfig: undefined as Record<string, unknown> | undefined,
storeGet: vi.fn(),
storeSet: vi.fn(),
updateNotificationsToken: vi.fn(),
}))
vi.mock("@eneris/push-receiver", () => ({
PushReceiver: class {
persistentIds = ["persistent-id"]
constructor(config: Record<string, unknown>) {
mocks.receiverConfig = config
}
connect = mocks.connect
onCredentialsChanged(callback: (event: { newCredentials: Credentials }) => void) {
mocks.onCredentialsChanged = callback
}
onNotification(callback: (event: { message: { data?: Record<string, unknown> } }) => void) {
mocks.onNotification = callback
}
onReady(callback: () => void) {
mocks.onReady = callback
}
},
}))
vi.mock("@follow/shared/bridge", () => ({
callWindowExpose: vi.fn(),
}))
vi.mock("@follow/shared/constants", () => ({
APP_PROTOCOL: "folo",
DEV: false,
LEGACY_APP_PROTOCOL: "follow",
}))
vi.mock("@follow/shared/env.desktop", () => ({
env: {
VITE_FIREBASE_CONFIG: JSON.stringify(mocks.firebaseConfig),
},
}))
vi.mock("electron", () => ({
app: {},
nativeTheme: {},
Notification: class {},
shell: {},
}))
vi.mock("electron-context-menu", () => ({
default: vi.fn(),
}))
vi.mock("~/manager/window", () => ({
WindowManager: {},
}))
vi.mock("../helper", () => ({ getIconPath: vi.fn() }))
vi.mock("../ipc", () => ({ initializeIpcServices: vi.fn() }))
vi.mock("../ipc/services/integration", () => ({ saveMediaToEagle: vi.fn() }))
vi.mock("../lib/cleaner", () => ({
checkAndCleanCodeCache: vi.fn(),
clearCacheCronJob: vi.fn(),
}))
vi.mock("../lib/cli-session-sync", () => ({
getSessionTokenFromCookies: vi.fn(),
syncSessionToCliConfig: vi.fn(),
}))
vi.mock("../lib/i18n", () => ({ t: vi.fn() }))
vi.mock("../lib/proxy", () => ({ updateProxy: vi.fn() }))
vi.mock("../lib/store", () => ({
store: {
get: mocks.storeGet,
set: mocks.storeSet,
},
}))
vi.mock("../lib/tray", () => ({ registerAppTray: vi.fn() }))
vi.mock("../lib/user", () => ({
updateNotificationsToken: mocks.updateNotificationsToken,
}))
vi.mock("../logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}))
vi.mock("../menu", () => ({ registerAppMenu: vi.fn() }))
vi.mock("../updater", () => ({ registerUpdater: vi.fn() }))
vi.mock("./lifecycle", () => ({
LifecycleManager: {
onReady: vi.fn(),
},
}))
describe("AppManager push notifications", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.receiverConfig = undefined
mocks.onCredentialsChanged = undefined
mocks.onNotification = undefined
mocks.onReady = undefined
mocks.connect.mockResolvedValue(undefined)
mocks.storeGet.mockImplementation((key: string) => {
if (key === "notifications-credentials") return credentials
if (key === "notifications-persistent-ids") return ["stored-persistent-id"]
return undefined
})
})
it("initializes and handles events without logging push secrets or payloads", async () => {
const registerPushNotifications = Reflect.get(
AppManager,
"registerPushNotifications",
) as () => Promise<void>
await registerPushNotifications.call(AppManager)
mocks.onCredentialsChanged?.({ newCredentials: credentials })
mocks.onNotification?.({
message: {
data: {
description: "private-notification-description",
type: "private-event",
},
},
})
expect(mocks.receiverConfig?.debug).toBe(false)
expect(updateNotificationsToken).toHaveBeenCalledWith(credentials)
const serializedLogs = JSON.stringify([
...vi.mocked(logger.info).mock.calls,
...vi.mocked(logger.error).mock.calls,
])
for (const secret of [
credentials.fcm.token,
credentials.fcm.installation.refreshToken,
credentials.keys.privateKey,
mocks.firebaseConfig.apiKey,
"private-notification-description",
]) {
expect(serializedLogs).not.toContain(secret)
}
})
it("does not log connection error details or report a failed connection as connected", async () => {
mocks.connect.mockRejectedValueOnce(
new Error(`connection failed with ${credentials.fcm.token}`),
)
const registerPushNotifications = Reflect.get(
AppManager,
"registerPushNotifications",
) as () => Promise<void>
await registerPushNotifications.call(AppManager)
expect(logger.error).toHaveBeenCalledWith("PushReceiver connection failed")
expect(logger.info).not.toHaveBeenCalledWith("PushReceiver connected")
expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(credentials.fcm.token)
})
})

View File

@ -108,32 +108,26 @@ class AppManagerStatic {
const persistentIds = store.get(persistentIdsKey)
const instance = new PushReceiver({
debug: true,
debug: false,
firebase: JSON.parse(env.VITE_FIREBASE_CONFIG),
persistentIds: persistentIds || [],
credentials: credentials || undefined,
bundleId: "is.follow",
chromeId: "is.follow",
})
logger.info(
`PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${
env.VITE_FIREBASE_CONFIG
}`,
)
logger.info("PushReceiver initialized")
instance.onReady(() => {
logger.info("PushReceiver ready")
})
instance.onCredentialsChanged(({ newCredentials }) => {
logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`)
logger.info("PushReceiver credentials changed")
updateNotificationsToken(newCredentials)
})
instance.onNotification((notification) => {
logger.info(
`PushReceiver received notification: ${JSON.stringify(notification.message.data)}`,
)
logger.info("PushReceiver received notification")
const { data } = notification.message
if (!data) {
return
@ -167,11 +161,10 @@ class AppManagerStatic {
try {
await instance.connect()
} catch (error) {
logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`)
logger.info("PushReceiver connected")
} catch {
logger.error("PushReceiver connection failed")
}
logger.info("PushReceiver connected")
}
private contextMenuDisposer?: () => void

View File

@ -11,7 +11,6 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"experimentalDecorators": true,
"baseUrl": ".",
"paths": {
"@pkg": ["../../package.json"],
"@locales/*": ["../../../../locales/*"],

View File

@ -1,13 +1,14 @@
import { fileURLToPath } from "node:url"
import tsconfigPath from "vite-tsconfig-paths"
import { defineProject } from "vitest/config"
import { configDefaults, defineProject } from "vitest/config"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
export default defineProject({
root: "./",
test: {
exclude: [...configDefaults.exclude, "dist/**", "out/**"],
globals: true,
environment: "node",
},

View File

@ -23,97 +23,97 @@
"@follow/store": "workspace:*",
"@follow/tracker": "workspace:*",
"@fontsource/sn-pro": "5.2.6",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@lexical/markdown": "0.40.0",
"@lexical/react": "0.40.0",
"@number-flow/react": "0.5.11",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@shikijs/transformers": "3.22.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@lexical/markdown": "0.46.0",
"@lexical/react": "0.46.0",
"@number-flow/react": "0.6.1",
"@radix-ui/react-avatar": "1.2.2",
"@radix-ui/react-context-menu": "2.3.3",
"@radix-ui/react-dialog": "1.1.19",
"@radix-ui/react-dropdown-menu": "2.1.20",
"@radix-ui/react-hover-card": "1.1.19",
"@radix-ui/react-label": "2.1.11",
"@radix-ui/react-popover": "1.1.19",
"@radix-ui/react-slider": "1.4.3",
"@radix-ui/react-slot": "1.3.0",
"@shikijs/transformers": "4.3.1",
"@splinetool/react-spline": "4.1.0",
"@tanstack/query-sync-storage-persister": "5.90.22",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-query-persist-client": "5.90.22",
"@tanstack/react-virtual": "3.13.18",
"@toon-format/toon": "2.1.0",
"@tanstack/query-sync-storage-persister": "5.101.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@tanstack/react-query-persist-client": "5.101.2",
"@tanstack/react-virtual": "3.14.5",
"@toon-format/toon": "2.3.0",
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@xyflow/react": "12.10.0",
"@xyflow/react": "12.11.2",
"@yornaath/batshit": "0.14.0",
"ai": "6.0.85",
"ai": "7.0.18",
"camelcase-keys": "10.0.2",
"chrono-node": "2.9.0",
"chrono-node": "2.9.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie-es": "2.0.0",
"dayjs": "1.11.19",
"cookie-es": "3.1.1",
"dayjs": "1.11.21",
"dnum": "2.17.0",
"electron-ipc-decorator": "0.2.0",
"electron-ipc-decorator": "1.0.1",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"es-toolkit": "1.44.0",
"firebase": "12.9.0",
"foxact": "0.2.52",
"es-toolkit": "1.49.0",
"firebase": "12.16.0",
"foxact": "0.3.8",
"franc-min": "6.2.0",
"fuse.js": "7.1.0",
"fuse.js": "7.4.2",
"hast-util-to-jsx-runtime": "2.3.6",
"hast-util-to-mdast": "10.1.2",
"i18next": "25.8.6",
"i18next": "26.3.5",
"i18next-browser-languagedetector": "8.2.1",
"idb-keyval": "6.2.2",
"immer": "11.1.4",
"jotai": "2.17.1",
"idb-keyval": "6.3.0",
"immer": "11.1.11",
"jotai": "2.20.1",
"lethargy": "1.0.9",
"lexical": "0.40.0",
"lexical": "0.46.0",
"masonic": "4.1.0",
"mdast-util-gfm-table": "2.0.0",
"mdast-util-to-markdown": "2.1.2",
"motion": "12.34.0",
"nanoid": "5.1.6",
"motion": "12.42.2",
"nanoid": "5.1.16",
"ofetch": "1.5.1",
"plain-shiki": "0.3.2",
"re-resizable": "6.11.2",
"react": "19.0.0",
"react": "19.2.7",
"react-blurhash": "0.3.0",
"react-dom": "19.0.0",
"react-error-boundary": "6.1.0",
"react-dom": "19.2.7",
"react-error-boundary": "6.1.2",
"react-fast-compare": "3.2.2",
"react-fast-marquee": "1.6.5",
"react-google-recaptcha-v3": "1.11.0",
"react-hook-form": "7.71.1",
"react-hotkeys-hook": "5.2.4",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-hook-form": "7.81.0",
"react-hotkeys-hook": "5.3.3",
"react-i18next": "17.0.8",
"react-intersection-observer": "10.0.3",
"react-ios-pwa-prompt": "2.0.6",
"react-markdown": "10.1.0",
"react-qr-code": "2.0.18",
"react-qr-code": "2.2.0",
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
"react-router": "7.13.0",
"react-router": "8.2.0",
"react-selecto": "1.26.3",
"react-shadow": "20.6.0",
"react-zoom-pan-pinch": "3.7.0",
"react-zoom-pan-pinch": "4.0.3",
"rehype-raw": "7.0.0",
"shiki": "3.22.0",
"shiki": "4.3.1",
"sonner": "2.0.7",
"tinykeys": "3.0.0",
"tinykeys": "4.0.0",
"title-case": "4.3.2",
"tldts": "7.0.23",
"ufo": "1.6.3",
"tldts": "7.4.7",
"ufo": "1.6.4",
"use-context-selector": "2.0.0",
"use-sync-external-store": "1.6.0",
"usehooks-ts": "3.1.1",
"zod": "3.25.76",
"zustand": "5.0.11"
"zod": "4.4.3",
"zustand": "5.0.14"
},
"devDependencies": {
"@follow/atoms": "workspace:*",
@ -125,11 +125,11 @@
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@folo-services/ai-tools": "catalog:",
"@types/node": "25.2.3",
"@types/node": "26.1.1",
"@vite-pwa/assets-generator": "1.0.2",
"fake-indexeddb": "6.2.5",
"happy-dom": "20.6.1",
"react-scan": "0.4.3",
"happy-dom": "20.10.6",
"react-scan": "0.5.7",
"typescript": "catalog:"
}
}

View File

@ -3,19 +3,88 @@ import "fake-indexeddb/auto"
import { enableMapSet } from "immer"
globalThis.window = {
location: new URL("https://example.com"),
__dbIsReady: true,
addEventListener: () => {},
get navigator() {
return globalThis.navigator
},
}
const createMemoryStorage = () => {
const data = new Map()
if (!globalThis.navigator) {
globalThis.navigator = {
onLine: true,
userAgent: "node",
return {
get length() {
return data.size
},
clear() {
data.clear()
},
getItem(key) {
return data.get(String(key)) ?? null
},
key(index) {
return Array.from(data.keys())[index] ?? null
},
removeItem(key) {
data.delete(String(key))
},
setItem(key, value) {
data.set(String(key), String(value))
},
}
}
const testNavigator = globalThis.navigator ?? {
onLine: true,
userAgent: "node",
}
const testWindow = globalThis.window ?? {}
globalThis.window = testWindow
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: testNavigator,
})
Object.defineProperties(testWindow, {
location: {
configurable: true,
value: testWindow.location ?? new URL("https://example.com"),
},
__dbIsReady: {
configurable: true,
value: true,
},
addEventListener: {
configurable: true,
writable: true,
value: testWindow.addEventListener?.bind(testWindow) ?? (() => {}),
},
navigator: {
configurable: true,
value: testNavigator,
},
})
const localStorageMock = testWindow.localStorage ?? createMemoryStorage()
const sessionStorageMock = testWindow.sessionStorage ?? createMemoryStorage()
Object.defineProperties(testWindow, {
localStorage: {
configurable: true,
value: localStorageMock,
},
sessionStorage: {
configurable: true,
value: sessionStorageMock,
},
})
Object.defineProperties(globalThis, {
localStorage: {
configurable: true,
value: localStorageMock,
},
sessionStorage: {
configurable: true,
value: sessionStorageMock,
},
})
enableMapSet()

View File

@ -26,11 +26,7 @@ type DistributionUpdaterStatus = BaseUpdaterStatus<"distribution"> & {
}
export type UpdaterStatusAtom =
| AppUpdaterStatus
| RendererUpdaterStatus
| PwaUpdaterStatus
| DistributionUpdaterStatus
| null
AppUpdaterStatus | RendererUpdaterStatus | PwaUpdaterStatus | DistributionUpdaterStatus | null
export const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks(
atom(null as UpdaterStatusAtom),
)

View File

@ -34,8 +34,7 @@ export const LCPEndDetector = () => {
scheduleRaf(() => {
// Prefer idle if available to avoid jank
const ric = (typeof window !== "undefined" && (window as any).requestIdleCallback) as
| ((cb: () => void, opts?: { timeout?: number }) => number)
| undefined
((cb: () => void, opts?: { timeout?: number }) => number) | undefined
if (ric) {
const id = ric(() => jotaiStore.set(LCPEndAtom, true), {
timeout: 200,
@ -101,8 +100,7 @@ export const LCPEndDetector = () => {
rafIds.forEach((id) => (window.cancelAnimationFrame as (h: number) => void)(id))
}
const cic = (typeof window !== "undefined" && (window as any).cancelIdleCallback) as
| ((id: number) => void)
| undefined
((id: number) => void) | undefined
if (cic) idleCallbackIds.forEach((id) => cic(id))
timeoutIds.forEach((id) => clearTimeout(id))
if (safetyTimer) clearTimeout(safetyTimer)
@ -116,5 +114,4 @@ export const LCPEndDetector = () => {
return null
}
// eslint-disable-next-line react-refresh/only-export-components
export const isLCPEnded = () => jotaiStore.get(LCPEndAtom)

View File

@ -4,7 +4,8 @@ import {
transformerNotationHighlight,
} from "@shikijs/transformers"
import type { ShikiTransformer } from "shiki"
import { createHighlighterCoreSync, createJavaScriptRegexEngine } from "shiki"
import { createHighlighterCoreSync } from "shiki/core"
import { createJavaScriptRegexEngine } from "shiki/engine/javascript"
export const shikiTransformers: ShikiTransformer[] = [
transformerMetaHighlight(),

View File

@ -1,3 +1,5 @@
@reference "../../../../styles/main.css";
.shiki-wrapper {
@apply overflow-hidden;

View File

@ -171,7 +171,7 @@ const DropdownMenuItem = ({
ref={ref}
className={cn(
"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1",
"focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background-accent-9-1",
"h-[28px]",
inset && "pl-8",
className,

View File

@ -598,16 +598,17 @@ const DOMImageViewer: FC<{
centerOnInit={true}
smooth={true}
centerZoomedOut
alignmentAnimation={{
autoAlignment={{
sizeX: 0,
sizeY: 0,
velocityAlignmentTime: 0.2,
}}
velocityAnimation={{
sensitivity: 1,
sensitivityMouse: 1,
sensitivityTouch: 1,
animationTime: 0.2,
}}
onTransformed={onTransformed}
onTransform={onTransformed}
>
<TransformComponent
wrapperProps={{

View File

@ -158,7 +158,7 @@ export const VideoPlayer = ({
</m.div>
</div>
{state.hasAudio && !state.muted && state.playing && <BizControlOutsideMedia />}
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<VideoPlayerContext.Provider
value={useMemo(
() => ({ state, controls, wrapperRef, src, variant }),
@ -189,7 +189,6 @@ const BizControlOutsideMedia = () => {
}
const FloatMutedButton = () => {
// eslint-disable-next-line @eslint-react/no-use-context
const ctx = useContext(VideoPlayerContext)
const isMuted = ctx.state.muted
return (
@ -372,7 +371,6 @@ const VolumeControl = () => {
}
const PlayProgressBar = () => {
// eslint-disable-next-line @eslint-react/no-use-context
const { state, controls } = useContext(VideoPlayerContext)
const [currentDragging, setCurrentDragging] = useState(false)
const [dragTime, setDragTime] = useState(0)

View File

@ -441,7 +441,7 @@ const ModalContext: FC<
return (
<CurrentModalContext value={modalContextProps}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<CurrentModalStateContext.Provider
value={useMemo(
() => ({

View File

@ -1,5 +1,10 @@
import { env } from "@follow/shared/env.desktop"
import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker"
import {
createPostHogBeforeSend,
setFirebaseTracker,
setPostHogTracker,
tracker,
} from "@follow/tracker"
import { captureAttributionFromURL, getAttributionForAnalytics } from "@follow/utils"
import type { AuthSessionResponse } from "@follow-app/client-sdk"
import posthog from "posthog-js"
@ -30,6 +35,7 @@ export const initAnalytics = async () => {
api_host: env.VITE_POSTHOG_HOST,
person_profiles: "identified_only",
defaults: "2025-05-24",
before_send: createPostHogBeforeSend(),
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,

View File

@ -1,6 +1,8 @@
import { callWindowExposeRenderer } from "@follow/shared/bridge"
import { detectIsEditableElement } from "@follow/utils"
import { showSettings } from "~/modules/settings/modal/use-setting-modal-hack"
interface ShortcutDefinition {
accelerator: string
action: () => void
@ -24,7 +26,7 @@ export const registerAppGlobalShortcuts = () => {
const shortcuts: ShortcutDefinition[] = [
{
accelerator: "CmdOrCtrl+,",
action: () => window.router.showSettings(),
action: () => showSettings(),
inputBypass: true,
},
{

View File

@ -1,6 +1,7 @@
import { renderToString } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { extractCodeFromHtml } from "../parse-html"
import { extractCodeFromHtml, parseHtml } from "../parse-html"
describe("extractCodeFromHtml", () => {
it("should extract code from div elements", () => {
@ -388,3 +389,35 @@ describe("extractCodeFromHtml", () => {
)
})
})
describe("parseHtml iframe", () => {
const renderIframe = (attrs: string) =>
renderToString(parseHtml(`<iframe ${attrs}></iframe>`).toContent()).toLowerCase()
const youtubeSrc = `src="https://www.youtube.com/embed/dQw4w9WgXcQ"`
it("should rewrite referrer-hiding policies on youtube iframes", () => {
for (const policy of ["no-referrer", "same-origin"]) {
expect(renderIframe(`${youtubeSrc} referrerpolicy="${policy}"`)).toContain(
`referrerpolicy="strict-origin-when-cross-origin"`,
)
}
})
it("should keep other declared referrerpolicy values on youtube iframes", () => {
expect(renderIframe(`${youtubeSrc} referrerpolicy="origin"`)).toContain(
`referrerpolicy="origin"`,
)
})
it("should set referrerpolicy when youtube iframes declare none", () => {
// an absent attribute would inherit no-referrer from the document meta tag
expect(renderIframe(youtubeSrc)).toContain(`referrerpolicy="strict-origin-when-cross-origin"`)
})
it("should drop referrerpolicy on non-youtube iframes", () => {
expect(
renderIframe(`src="https://example.com/embed" referrerpolicy="no-referrer"`),
).not.toContain("referrerpolicy")
})
})

View File

@ -9,17 +9,63 @@ import PKG from "@pkg"
import { setLoginModalShow } from "~/atoms/user"
import { ipcServices } from "./client"
import { getAuthSessionToken, getClientId, getSessionId } from "./client-session"
const isElectronRuntime = () => {
return IN_ELECTRON || (typeof window !== "undefined" && !!window.electron)
}
const fetchWithElectronAuth = async (request: Request) => {
const requestURL = new URL(request.url)
const apiURL = new URL(env.VITE_API_URL)
const authService = ipcServices?.auth as
| (NonNullable<typeof ipcServices>["auth"] & {
fetchWithAuth?: (payload: {
body?: string
headers?: Record<string, string>
method: string
url: string
}) => Promise<{
body: string
headers: [string, string][]
status: number
statusText: string
}>
})
| undefined
if (!isElectronRuntime() || requestURL.origin !== apiURL.origin || !authService?.fetchWithAuth) {
return fetch(request)
}
const body =
request.method !== "GET" && request.method !== "HEAD" ? await request.clone().text() : undefined
const response = await authService.fetchWithAuth({
body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
url: request.url,
})
return new Response(response.body, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
})
}
export const followClient = new FollowClient({
credentials: "include",
timeout: 60_000,
baseURL: env.VITE_API_URL,
fetch: async (input, options = {}) =>
fetch(input.toString(), {
fetch: async (input, options = {}) => {
const request = new Request(input.toString(), {
...options,
cache: "no-store",
}),
})
return fetchWithElectronAuth(request)
},
})
export const followApi = followClient.api
@ -29,7 +75,7 @@ followClient.addRequestInterceptor(async (ctx) => {
headers.set("X-Client-Id", getClientId())
headers.set("X-Session-Id", getSessionId())
const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null
const authSessionToken = isElectronRuntime() ? getAuthSessionToken() : null
if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) {
headers.set(
"Cookie",
@ -48,7 +94,7 @@ followClient.addRequestInterceptor(async (ctx) => {
followClient.addResponseInterceptor(async ({ response }) => {
if (response.status === 401) {
const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null
const authSessionToken = isElectronRuntime() ? getAuthSessionToken() : null
const shouldPromptForLogin =
response.url.includes("/better-auth/get-session") || (!whoami() && !authSessionToken)

View File

@ -16,6 +16,8 @@ import { createHeadingRenderer } from "~/components/ui/markdown/renderers/Headin
import { MarkdownInlineImage } from "~/components/ui/markdown/renderers/InlineImage"
import { Media } from "~/components/ui/media/Media"
const youtubeEmbedRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtube-nocookie\.com)\/embed\//
function markInlineImage(node?: Element) {
for (const item of node?.children ?? []) {
if (item.type === "element" && item.tagName === "img") {
@ -142,7 +144,7 @@ export const parseHtml = (
return createElement("input", props)
},
iframe: ({ node, ...props }) => {
const { width, height, src, ...rest } = props
const { width, height, src, referrerPolicy, ...rest } = props
// Apply security sandbox attributes and responsive styling
return createElement("iframe", {
@ -154,6 +156,16 @@ export const parseHtml = (
sandbox: "allow-scripts allow-same-origin allow-popups allow-forms",
allowFullScreen: true,
loading: "lazy",
// Avoid YouTube Error 153 https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity
...(typeof src === "string" &&
youtubeEmbedRegex.test(src) && {
referrerPolicy:
!referrerPolicy ||
referrerPolicy === "no-referrer" ||
referrerPolicy === "same-origin"
? "strict-origin-when-cross-origin"
: referrerPolicy,
}),
style: {
aspectRatio: width && height ? `${width} / ${height}` : "16 / 9",
...rest.style,

View File

@ -1,5 +1,4 @@
import "./wdyr"
import "@follow/components/tailwind"
import "./styles/main.css"
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
@ -31,7 +30,6 @@ initializeApp().finally(() => {
}
})
// eslint-disable-next-line @eslint-react/dom/no-flush-sync
flushSync(() => setAppIsReady(true))
})

View File

@ -8,7 +8,7 @@ import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot } from "lexical"
import type { Ref } from "react"
import { memo, use, useCallback, useImperativeHandle, useRef, useState } from "react"
import { matchKeyBindingPress, parseKeybinding } from "tinykeys"
import { matchKeybindingPress, parseKeybinding } from "tinykeys"
import { AIChatContextBar } from "~/modules/ai-chat/components/layouts/AIChatContextBar"
import { COMMAND_ID } from "~/modules/command/commands/id"
@ -24,7 +24,7 @@ import { AIModelIndicator } from "./AIModelIndicator"
const chatInputVariants = cva(
[
"bg-mix-background/transparent-8/2 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80",
"bg-mix-background-transparent-8-2 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80",
"relative overflow-hidden rounded-2xl border backdrop-blur-background duration-200 focus-within:ring-2",
"z-[1]",
],
@ -129,7 +129,7 @@ export const ChatInput = memo(
const presses = parseKeybinding(shortcut)
// For single key shortcuts (not sequences), check if the first press matches
return presses.length === 1 && presses[0] && matchKeyBindingPress(event, presses[0])
return presses.length === 1 && presses[0] && matchKeybindingPress(event, presses[0])
})
if (matchesToggleShortcut) {

View File

@ -11,7 +11,7 @@ export const ScrollToBottomButton = ({ onClick }: ScrollToBottomButtonProps) =>
type="button"
onClick={onClick}
className={cn(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}

View File

@ -14,7 +14,7 @@ export const LoadingState = ({ description = "Fetching data..." }: LoadingStateP
export const ErrorState = ({ error = "An error occurred. Please try again." }: ErrorStateProps) => {
return (
<div className="flex h-32 items-center justify-center rounded-lg text-sm text-text-tertiary bg-mix-red/background-1/4">
<div className="flex h-32 items-center justify-center rounded-lg text-sm text-text-tertiary bg-mix-red-background-1-4">
{error}
</div>
)

View File

@ -43,11 +43,7 @@ export interface ViewMentionData extends MentionBaseData {
}
export type MentionData =
| EntryMentionData
| FeedMentionData
| DateMentionData
| CategoryMentionData
| ViewMentionData
EntryMentionData | FeedMentionData | DateMentionData | CategoryMentionData | ViewMentionData
export type MentionType = MentionData["type"]

View File

@ -37,8 +37,7 @@ export type AIChatContextBlock = ValueContextBlock | FileAttachmentContextBlock
// Helper type for creating new blocks without id
export type AIChatContextBlockInput =
| Omit<ValueContextBlock, "id">
| Omit<FileAttachmentContextBlock, "id">
Omit<ValueContextBlock, "id"> | Omit<FileAttachmentContextBlock, "id">
export type AIChatContextBlockType = AIChatContextBlock["type"]

View File

@ -526,7 +526,7 @@ function AIChatInterface({ inputRef }: AIChatInterfaceProps) {
type="button"
onClick={() => resetScrollState()}
className={cn(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}

View File

@ -349,7 +349,7 @@ export const ScheduleConfig = memo<ScheduleConfigProps>(
}
value={value.dayOfWeek.toString()}
>
<SelectTrigger className="h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4">
<SelectTrigger className="h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent-background-1-4">
<SelectValue placeholder={t("schedule.day_placeholder")} />
</SelectTrigger>
<SelectContent>
@ -406,7 +406,7 @@ export const ScheduleConfig = memo<ScheduleConfigProps>(
}
value={value.dayOfMonth.toString()}
>
<SelectTrigger className="h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent/background-1/4">
<SelectTrigger className="h-6 justify-between rounded-[4px] border-0 bg-material-opaque px-1.5 py-0 text-xs hover:bg-mix-accent-background-1-4">
<SelectValue placeholder={t("schedule.day_placeholder")} />
</SelectTrigger>
<SelectContent>

View File

@ -138,7 +138,6 @@ const FeedResponsiveResizerContainer = ({
}, [feedColumnShow])
const when = useGlobalFocusableScopeSelector(
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback
React.useCallback((activeScope) => !activeScope.or(...FloatingLayerScope), []),
)

View File

@ -1,4 +1,3 @@
/* eslint-disable @eslint-react/dom/no-missing-iframe-sandbox */
import { Button } from "@follow/components/ui/button/index.js"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import {

View File

@ -12,6 +12,8 @@ import { Input } from "@follow/components/ui/input/Input.js"
import type { LoginRuntime } from "@follow/shared/auth"
import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { userActions } from "@follow/store/user/store"
import type { AuthUser } from "@follow-app/client-sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
@ -83,6 +85,47 @@ const getAuthTokenFromResult = (result: unknown) => {
return null
}
const getAuthUserFromResult = (result: unknown): AuthUser | null => {
if (!result || typeof result !== "object") {
return null
}
const data =
"data" in result && result.data && typeof result.data === "object" ? result.data : result
if (!("user" in data) || !data.user || typeof data.user !== "object") {
return null
}
const user = data.user as { id?: unknown }
return typeof user.id === "string" ? (data.user as AuthUser) : null
}
const persistElectronAuthUser = async (result: unknown) => {
if (!IN_ELECTRON) {
return
}
const authUser = getAuthUserFromResult(result)
if (!authUser) {
return
}
await userActions.upsertMany([
{
id: authUser.id,
email: authUser.email ?? null,
handle: authUser.handle ?? null,
name: authUser.name ?? null,
image: authUser.image ?? null,
isMe: true,
emailVerified: authUser.emailVerified ?? false,
bio: authUser.bio ?? null,
website: authUser.website ?? null,
socialLinks: authUser.socialLinks ?? null,
},
])
}
type ElectronAuthResult = {
data?: Record<string, unknown>
error?: {
@ -220,6 +263,7 @@ export function LoginWithPassword({
if (token) {
setAuthSessionToken(token)
}
await persistElectronAuthUser(result)
}
}}
onSuccess={() => {
@ -235,6 +279,7 @@ export function LoginWithPassword({
if (token) {
setAuthSessionToken(token)
}
await persistElectronAuthUser(res)
}
handleSessionChanges()
}
@ -429,6 +474,7 @@ export function RegisterForm({
if (token) {
setAuthSessionToken(token)
}
await persistElectronAuthUser(result)
}
handleSessionChanges()

View File

@ -88,7 +88,4 @@ type EntryPreviousEntryCommand = Command<{
}>
export type EntryRenderCommand =
| EntryScrollDownCommand
| EntryScrollUpCommand
| EntryNextEntryCommand
| EntryPreviousEntryCommand
EntryScrollDownCommand | EntryScrollUpCommand | EntryNextEntryCommand | EntryPreviousEntryCommand

View File

@ -99,10 +99,11 @@ export const FeedForm: Component<{
const feedQuery = useFeedQuery(queryParams)
const id = feedQuery.data?.feed.id || _id
const feed = useFeedByIdOrUrl({
const feedFromStore = useFeedByIdOrUrl({
id,
url,
}) as FeedModel
}) as FeedModel | undefined
const feed = feedFromStore || (feedQuery.data?.feed as FeedModel | undefined)
const { t } = useTranslation()

View File

@ -30,7 +30,6 @@ export const EntryItemSkeleton: FC<{
return (
<div className={cn(getView(view)?.gridMode ? girdClassNames : "flex flex-col")}>
{Array.from({ length: count }).map((_, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique
<div key={index}>{SkeletonItem}</div>
))}
</div>

View File

@ -30,7 +30,7 @@ export function PictureItemStateLess({ entry }: EntryItemStatelessProps) {
return (
<div className="relative w-full select-none text-text" ref={ref}>
<MasonryItemWidthContext value={currentItemWidth}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>
<MediaContainerWidthProvider width={currentItemWidth}>

View File

@ -277,7 +277,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
<div ref={containerRef} className="mx-4 pt-4">
{isInitDim && deferIsInitLayout && (
<MasonryItemWidthContext value={currentItemWidth}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>
<MasonryIntersectionContext value={intersectionObserver}>

View File

@ -39,7 +39,6 @@ export const MarkAllReadButton = ({
// const activeScope = useGlobalFocusableScope()
const when = useGlobalFocusableScopeSelector(
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback
useCallback(
(activeScope) => activeScope.or(HotkeyScope.Timeline, HotkeyScope.SubscriptionList),
[],

View File

@ -54,7 +54,7 @@ export const ImageGallery = ({ images }: { images: MediaModel[] }) => {
return (
<div ref={containerRef}>
<MasonryItemWidthContext value={currentItemWidth}>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext value={setMasonryItemsRadio}>
<MediaContainerWidthProvider width={currentItemWidth}>

View File

@ -509,7 +509,7 @@ function AIChatInterface({ inputRef }: AIChatInterfaceProps) {
type="button"
onClick={() => resetScrollState()}
className={cn(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background-transparent-8-2",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}

View File

@ -1,3 +1,5 @@
@reference "../../styles/main.css";
.status-bar {
@apply relative z-10 h-px w-full shrink-0 scale-y-75 transform;

View File

@ -19,8 +19,7 @@ type TtsAudioHandle = {
}
type AudioContextConstructor =
| (new (contextOptions?: AudioContextOptions | undefined) => AudioContext)
| null
(new (contextOptions?: AudioContextOptions | undefined) => AudioContext) | null
const getAudioContextConstructor = (): AudioContextConstructor => {
if (typeof window === "undefined") {

View File

@ -60,8 +60,7 @@ export const UserRenderer = ({
iconClassName,
}: {
user?: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number][
| "fromUser"
| "toUser"]
"fromUser" | "toUser"]
hideName?: boolean
iconClassName?: string
}) => {

View File

@ -18,7 +18,6 @@ const defaultContextValue: EntryContentContext = {
export const EntryContentContext = createContext<EntryContentContext>(defaultContextValue)
export const EntryContentProvider: Component<EntryContentContext> = ({ children, ...value }) => (
// eslint-disable-next-line @eslint-react/no-context-provider
<EntryContentContext.Provider value={value}>{children}</EntryContentContext.Provider>
)

View File

@ -68,7 +68,6 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements
}
}, [entry, feedId, onImageContextMenu, view])
return (
// eslint-disable-next-line @eslint-react/no-context-provider
<MarkdownImageRecordContext.Provider value={images}>
<MarkdownRenderActionContext value={actions}>
<EntryInfoContext value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}>

View File

@ -70,7 +70,6 @@ export function EntryContentMarkdownRenderer({
}, [entry, feedId, onImageContextMenu, view])
return (
// eslint-disable-next-line @eslint-react/no-context-provider
<MarkdownImageRecordContext.Provider value={images}>
<MarkdownRenderActionContext value={actions}>
<EntryInfoContext value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}>

View File

@ -11,6 +11,7 @@ import { useAuthQuery } from "~/hooks/common"
import { settings } from "~/queries/settings"
import { useSettingContextSelector } from "../modal/hooks"
import type { RemoteSettingsResponse } from "./sync-queue"
import { settingSyncQueue } from "./sync-queue"
export const SettingSyncIndicator = () => {
@ -19,16 +20,23 @@ export const SettingSyncIndicator = () => {
const canSync = useSettingContextSelector((s) => s.canSync)
const isOnline = useIsOnline()
const onceRef = useRef(false)
const seedEmptyRemoteOnceRef = useRef(false)
useEffect(() => {
if (!isLoading && remoteSettings && !onceRef.current) {
const hasSetting = JSON.stringify(remoteSettings.settings) !== "{}"
onceRef.current = true
if (hasSetting) {
return
}
// Replace local to remote
settingSyncQueue.replaceRemote()
if (isLoading || !remoteSettings) {
return
}
const hasSetting = JSON.stringify(remoteSettings.settings) !== "{}"
if (hasSetting) {
settingSyncQueue.applyRemoteSettings(remoteSettings as RemoteSettingsResponse)
return
}
if (!seedEmptyRemoteOnceRef.current) {
seedEmptyRemoteOnceRef.current = true
// Seed remote settings once for new accounts, but re-check before writing because another
// session may have created remote settings while this modal was loading.
settingSyncQueue.replaceRemoteIfEmpty()
}
}, [remoteSettings, isLoading])

View File

@ -1,4 +1,3 @@
/* eslint-disable @eslint-react/no-array-index-key */
import { UserRole } from "@follow/constants"
import { SettingPaidLevels } from "@follow/shared/settings/constants"
import { useUserRole } from "@follow/store/user/hooks"
@ -51,11 +50,7 @@ export const createSettingBuilder =
<T extends object>(useSetting: () => T) =>
<K extends keyof T>(props: {
settings: (
| SettingItem<T, K>
| SectionSettingItem
| CustomSettingItem
| ActionSettingItem
| boolean
SettingItem<T, K> | SectionSettingItem | CustomSettingItem | ActionSettingItem | boolean
)[]
}) => {
const { settings } = props

View File

@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import {
getGeneralSettings,
initializeDefaultGeneralSettings,
setGeneralSetting,
} from "~/atoms/settings/general"
import {
getSpotlightSettings,
initializeDefaultSpotlightSettings,
@ -9,11 +14,14 @@ import { initializeDefaultUISettings } from "~/atoms/settings/ui"
import { settingSyncQueue } from "./sync-queue"
const { settingsPrefetchMock, settingsUpdateMock, whoamiMock } = vi.hoisted(() => ({
settingsPrefetchMock: vi.fn(),
settingsUpdateMock: vi.fn(),
whoamiMock: vi.fn(),
}))
const { settingsGetMock, settingsPrefetchMock, settingsUpdateMock, whoamiMock } = vi.hoisted(
() => ({
settingsGetMock: vi.fn(),
settingsPrefetchMock: vi.fn(),
settingsUpdateMock: vi.fn(),
whoamiMock: vi.fn(),
}),
)
vi.mock("@follow/store/user/getters", () => ({
whoami: whoamiMock,
@ -31,6 +39,7 @@ vi.mock("~/lib/api-client", () => ({
followClient: {
api: {
settings: {
get: settingsGetMock,
update: settingsUpdateMock,
},
},
@ -40,7 +49,8 @@ vi.mock("~/lib/api-client", () => ({
vi.mock("~/queries/settings", () => ({
settings: {
get: () => ({
prefetch: settingsPrefetchMock,
key: ["settings"],
fn: settingsPrefetchMock,
}),
},
}))
@ -79,8 +89,14 @@ describe("desktop spotlight setting sync", () => {
settings: {},
updated: {},
})
settingsGetMock.mockResolvedValue({
code: 0,
settings: {},
updated: {},
})
initializeDefaultUISettings()
initializeDefaultGeneralSettings()
initializeDefaultSpotlightSettings()
localStorage.clear()
})
@ -92,9 +108,32 @@ describe("desktop spotlight setting sync", () => {
settingSyncQueue.queue = []
localStorage.clear()
initializeDefaultUISettings()
initializeDefaultGeneralSettings()
initializeDefaultSpotlightSettings()
})
test("applyRemoteSettings hydrates general settings from provided payload", () => {
setGeneralSetting("language", "ja")
settingSyncQueue.applyRemoteSettings({
code: 0,
settings: {
general: {
language: "en",
},
},
updated: {
general: "2030-04-14T12:00:00.000Z",
},
})
expect(getGeneralSettings()).toMatchObject({
language: "en",
updated: Date.parse("2030-04-14T12:00:00.000Z"),
})
expect(settingsPrefetchMock).not.toHaveBeenCalled()
})
test("syncLocal hydrates spotlight rules from remote appearance settings", async () => {
const rule = createRule()
settingsPrefetchMock.mockResolvedValue({
@ -149,4 +188,22 @@ describe("desktop spotlight setting sync", () => {
}),
)
})
test("replaceRemoteIfEmpty does not overwrite existing remote settings", async () => {
settingsGetMock.mockResolvedValue({
code: 0,
settings: {
general: {
language: "ja",
},
},
updated: {
general: "2026-04-14T12:00:00.000Z",
},
})
await settingSyncQueue.replaceRemoteIfEmpty()
expect(settingsUpdateMock).not.toHaveBeenCalled()
})
})

View File

@ -28,6 +28,7 @@ import {
import { __uiSettingAtom, getUISettings, uiServerSyncWhiteListKeys } from "~/atoms/settings/ui"
import { followClient } from "~/lib/api-client"
import { jotaiStore } from "~/lib/jotai"
import { queryClient } from "~/lib/query-client"
import { settings } from "~/queries/settings"
type SettingMapping = {
@ -38,6 +39,11 @@ type SettingMapping = {
}
type SettingDomain = keyof SettingMapping
type RemoteSettingsTab = "appearance" | "general" | "ai"
export interface RemoteSettingsResponse {
code: 0
settings: Record<string, any>
updated: Record<string, string>
}
const pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {
const nextPayload = {} as Partial<T>
@ -337,22 +343,19 @@ class SettingSyncQueue {
private threshold = 1000
private flushScheduled = false
private pendingPromise: Promise<{
code: 0
settings: Record<string, any>
updated: Record<string, string>
}> | null = null
private pendingPromise: Promise<RemoteSettingsResponse> | null = null
private fetchSettingRemote() {
if (this.pendingPromise) {
return this.pendingPromise
}
const promise = settings.get().prefetch() as Promise<{
code: 0
settings: Record<string, any>
updated: Record<string, string>
}>
const settingQuery = settings.get()
const promise = queryClient.fetchQuery({
queryKey: settingQuery.key,
queryFn: settingQuery.fn,
staleTime: 0,
}) as Promise<RemoteSettingsResponse>
this.pendingPromise = promise.finally(() => {
this.pendingPromise = null
@ -370,6 +373,10 @@ class SettingSyncQueue {
: {}
}
private async fetchFreshRemoteSettings() {
return followClient.api.settings.get() as Promise<RemoteSettingsResponse>
}
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
@ -500,6 +507,23 @@ class SettingSyncQueue {
}
}
private replaceAllRemote() {
return Promise.all([
followClient.api.settings.update({
tab: "appearance",
...buildFullLocalAppearancePayload(),
}),
followClient.api.settings.update({
tab: "general",
...getLocalPayloadForRemoteTab("general"),
}),
followClient.api.settings.update({
tab: "ai",
...getLocalPayloadForRemoteTab("ai"),
}),
])
}
replaceRemote(tab?: SettingSyncTab) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
@ -509,22 +533,7 @@ class SettingSyncQueue {
this.bindQueueOwner(currentUserId)
if (!tab) {
const promises = [
followClient.api.settings.update({
tab: "appearance",
...buildFullLocalAppearancePayload(),
}),
followClient.api.settings.update({
tab: "general",
...getLocalPayloadForRemoteTab("general"),
}),
followClient.api.settings.update({
tab: "ai",
...getLocalPayloadForRemoteTab("ai"),
}),
]
this.chain = this.chain.finally(() => Promise.all(promises))
this.chain = this.chain.finally(() => this.replaceAllRemote())
return this.chain
} else {
this.chain = this.chain.finally(async () => {
@ -547,25 +556,37 @@ class SettingSyncQueue {
}
}
async syncLocal() {
replaceRemoteIfEmpty() {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
if (!currentUserId) {
return this.chain
}
this.bindQueueOwner(currentUserId)
const remoteSettings = await this.fetchSettingRemote().catch((error) => {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return null
}
this.chain = this.chain.finally(async () => {
try {
const remoteSettings = await this.fetchFreshRemoteSettings()
if (!isEmptyObject(remoteSettings.settings)) {
return
}
this.reportSyncError("syncLocal", error)
return null
await this.replaceAllRemote()
} catch (error) {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return
}
this.reportSyncError("flush", error)
}
})
if (!remoteSettings) return
return this.chain
}
applyRemoteSettings(remoteSettings: RemoteSettingsResponse) {
if (isEmptyObject(remoteSettings.settings)) return
for (const tab in remoteSettings.settings) {
@ -597,6 +618,28 @@ class SettingSyncQueue {
}
}
}
async syncLocal() {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const remoteSettings = await this.fetchSettingRemote().catch((error) => {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return null
}
this.reportSyncError("syncLocal", error)
return null
})
if (!remoteSettings) return
this.applyRemoteSettings(remoteSettings)
}
}
export const settingSyncQueue = new SettingSyncQueue()

View File

@ -98,7 +98,7 @@ export function SettingModalLayout(props: PropsWithChildren) {
cursor: "grabbing",
}}
>
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
{}
<SettingContext.Provider value={defaultCtx}>
<Resizable
onResizeStart={handleResizeStart}

View File

@ -1,6 +1,36 @@
// HACK: Use expose the navigate function in the window object, avoid to import `router` circular issue.
import type { SettingModalOptions } from "./useSettingModal"
const showSettings = (args?: SettingModalOptions) => window.router.showSettings.call(null, args)
const getShowSettings = () => {
const showSettings = window.router?.showSettings
return typeof showSettings === "function" ? showSettings : null
}
export const showSettings = (args?: SettingModalOptions) => {
const currentShowSettings = getShowSettings()
if (currentShowSettings) {
currentShowSettings(args)
return
}
let retryCount = 0
const retry = () => {
const nextShowSettings = getShowSettings()
if (nextShowSettings) {
nextShowSettings(args)
return
}
retryCount += 1
if (retryCount >= 20) {
console.error("[Settings] window.router.showSettings is not ready")
return
}
window.setTimeout(retry, 50)
}
window.setTimeout(retry)
}
export const useSettingModal = () => showSettings

View File

@ -1,6 +1,3 @@
/* eslint-disable @eslint-react/no-children-to-array */
/* eslint-disable @eslint-react/no-children-map */
import { cn } from "@follow/utils/utils"
import type { FC, PropsWithChildren, ReactNode } from "react"
import { cloneElement, createContext, use, useEffect, useRef } from "react"

View File

@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest"
import * as generalRouteModule from "~/pages/settings/(settings)/general"
import { getSettingPages } from "./settings-glob"
import type { SettingPageConfig } from "./utils"
vi.mock("@follow/shared/env.desktop", () => ({
env: {
VITE_API_URL: "https://api.example.com",
VITE_WEB_URL: "https://app.example.com",
},
}))
type SettingRouteModule = {
handle?: SettingPageConfig
loader?: unknown
}
describe("settings route metadata", () => {
it("uses route handles while preserving the modal metadata mapping", () => {
const pages = getSettingPages() as Record<string, { loader: SettingPageConfig }>
const routeModule = generalRouteModule as SettingRouteModule
expect(Object.keys(pages)).toHaveLength(14)
expect(routeModule.loader).toBeUndefined()
expect(routeModule.handle).toBeDefined()
expect(pages.general?.loader).toBe(routeModule.handle)
})
})

View File

@ -30,14 +30,14 @@ function getSettings() {
const Module = map[path] as {
Component: () => JSX.Element
loader: SettingPageConfig
handle: SettingPageConfig
}
if (!Module.loader) continue
if (!Module.handle) continue
settings.push({
...Module.loader,
...Module.handle,
Component: Module.Component,
loader: Module.loader,
loader: Module.handle,
path: p,
})
}

View File

@ -33,7 +33,7 @@ import { URLSchemePreview } from "~/modules/integration/URLSchemePreview"
const httpTemplateSchema = z.object({
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
url: z.string().url("URL is required"),
headers: z.record(z.string()),
headers: z.record(z.string(), z.string()),
body: z.string().optional(),
})
@ -60,7 +60,7 @@ const createFormSchema = () =>
}
} catch (error) {
if (error instanceof z.ZodError) {
error.errors.forEach((zodError) => {
error.issues.forEach((zodError) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: zodError.message,

View File

@ -220,13 +220,7 @@ interface PlanCardProps {
const PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardProps) => {
const { t } = useTranslation("settings")
const getPlanActionType = ():
| "current"
| "upgrade"
| "coming-soon"
| "in-trial"
| "switch"
| "new"
| null => {
"current" | "upgrade" | "coming-soon" | "in-trial" | "switch" | "new" | null => {
if (plan.isComingSoon) return "coming-soon"
switch (true) {
case isCurrentPlan: {

View File

@ -148,9 +148,7 @@ const TimelineTabsSettings = () => {
// Determine hovered list
const overContainer = (over.data?.current as any)?.container as
| "visible"
| "hidden"
| undefined
"visible" | "hidden" | undefined
const overKey: "visible" | "hidden" =
overContainer || (visible.includes(overId) ? "visible" : "hidden")

View File

@ -204,7 +204,7 @@ const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo
if (prevOrderIndex < orderIndex) setDirection("right")
else setDirection("left")
}
// eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout
setTimeout(() => {
setCurrentAnimatedActive(viewIndex)
}, 0)
@ -317,7 +317,6 @@ const CommandsHandler = ({
timelineList: string[]
}) => {
const when = useGlobalFocusableScopeSelector(
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback
useCallback(
(activeScope) =>
activeScope.or(HotkeyScope.SubscriptionList, HotkeyScope.Timeline) ||

View File

@ -3,7 +3,7 @@ import { RSSHubLogo } from "@follow/components/ui/platform-icon/icons.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
import { useMeasure } from "@follow/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import { useUserRole, useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import type { FC } from "react"
import { memo, useCallback, useLayoutEffect, useState } from "react"
@ -38,7 +38,8 @@ export type ProfileButtonProps = LoginProps & {
export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
const serverConfig = useServerConfigs()
const { status, session } = useSession()
const { user } = session || {}
const whoami = useWhoami()
const user = session?.user ?? whoami
const settingModalPresent = useSettingModal()
const presentUserProfile = usePresentUserProfileModal("dialog")
const { t } = useTranslation()
@ -53,7 +54,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
const role = useUserRole()
const isInMASReview = useIsInMASReview()
if (status !== "authenticated") {
if (status !== "authenticated" && !user) {
return <LoginButton {...props} />
}
@ -65,9 +66,13 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
data-testid="profile-menu-trigger"
>
{props.animatedAvatar ? (
<TransitionAvatar stage={dropdown ? "zoom-in" : ""} />
<TransitionAvatar data-testid="profile-menu-trigger" stage={dropdown ? "zoom-in" : ""} />
) : (
<UserAvatar hideName className="size-6 p-0 [&_*]:border-0" />
<UserAvatar
data-testid="profile-menu-trigger"
hideName
className="size-6 p-0 [&_*]:border-0"
/>
)}
</DropdownMenuTrigger>

View File

@ -3,7 +3,7 @@ import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const priority = Number.MAX_SAFE_INTEGER
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: "i-mgc-information-cute-re",
name: "titles.about",
priority,

View File

@ -6,8 +6,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-ai-cute-re"
const priority = (1000 << 1) + 15
// eslint-disable-next-line react-refresh/only-export-components
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: iconName,
name: "titles.ai",
priority,

View File

@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-palette-cute-re"
const priority = (1000 << 1) + 10
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: iconName,
name: "titles.appearance",
priority,

View File

@ -7,7 +7,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const priority = (1000 << 1) + 25
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: <Android2CuteReIcon />,
headerIcon: <Android2CuteReIcon className="size-5 text-accent" />,
name: "titles.cli",

View File

@ -6,7 +6,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const priority = (1000 << 1) + 30
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: <MaterialSymbolsDatabaseOutline />,
name: "titles.data_control",
priority,

View File

@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-certificate-cute-re"
const priority = (1000 << 2) + 20
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: iconName,
name: "titles.feeds",
priority,

View File

@ -5,7 +5,7 @@ import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-settings-7-cute-re"
const priority = 1000 << 1
export const loader = defineSettingPageData({
export const handle = defineSettingPageData({
icon: iconName,
name: "titles.general",
priority,

Some files were not shown because too many files have changed in this diff Show More