Merge pull request #5042 from RSSNext/release/mobile/0.5.6
release(mobile): Release v0.5.6
This commit is contained in:
commit
8f7471951d
|
|
@ -1,2 +1,3 @@
|
|||
* text=auto eol=lf
|
||||
*.splinecode filter=lfs diff=lfs merge=lfs -text
|
||||
*.patch whitespace=-trailing-space
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ next-env.d.ts
|
|||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
.vercel
|
||||
.vinext
|
||||
stats.html
|
||||
|
||||
electron.vite.config.*.mjs
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pnpm-lock.yaml
|
|||
CHANGELOG.md
|
||||
.context
|
||||
.waylog
|
||||
.vinext
|
||||
|
||||
apps/external/postcss.config.cjs
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -66,6 +66,7 @@ export const viteRenderBaseConfig = {
|
|||
codeInspectorPlugin({
|
||||
bundler: "vite",
|
||||
hotKeys: ["altKey"],
|
||||
needEnvInspector: true,
|
||||
}),
|
||||
react({
|
||||
// jsxImportSource: "@welldone-software/why-did-you-render", // <-----
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export default defineConfig({
|
|||
lib: {
|
||||
entry: "./layer/main/src/index.ts",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["canvas", "jsdom"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,12 +74,10 @@ export const clearAllData = async () => {
|
|||
|
||||
await ses.clearStorageData({
|
||||
storages: [
|
||||
"websql",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
"cookies",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@pkg": ["../../package.json"],
|
||||
"@locales/*": ["../../../../locales/*"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@reference "../../../../styles/main.css";
|
||||
|
||||
.shiki-wrapper {
|
||||
@apply overflow-hidden;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ const ModalContext: FC<
|
|||
|
||||
return (
|
||||
<CurrentModalContext value={modalContextProps}>
|
||||
{/* eslint-disable-next-line @eslint-react/no-context-provider */}
|
||||
{}
|
||||
<CurrentModalStateContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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), []),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -88,7 +88,4 @@ type EntryPreviousEntryCommand = Command<{
|
|||
}>
|
||||
|
||||
export type EntryRenderCommand =
|
||||
| EntryScrollDownCommand
|
||||
| EntryScrollUpCommand
|
||||
| EntryNextEntryCommand
|
||||
| EntryPreviousEntryCommand
|
||||
EntryScrollDownCommand | EntryScrollUpCommand | EntryNextEntryCommand | EntryPreviousEntryCommand
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@reference "../../styles/main.css";
|
||||
|
||||
.status-bar {
|
||||
@apply relative z-10 h-px w-full shrink-0 scale-y-75 transform;
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -60,8 +60,7 @@ export const UserRenderer = ({
|
|||
iconClassName,
|
||||
}: {
|
||||
user?: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number][
|
||||
| "fromUser"
|
||||
| "toUser"]
|
||||
"fromUser" | "toUser"]
|
||||
hideName?: boolean
|
||||
iconClassName?: string
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])}>
|
||||
|
|
|
|||
|
|
@ -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])}>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue