Merge pull request #4953 from RSSNext/release/desktop/1.5.0

release(desktop): Release v1.5.0
This commit is contained in:
DIYgod 2026-04-03 13:17:09 +08:00 committed by GitHub
commit ab925fedb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
294 changed files with 18643 additions and 3561 deletions

View File

@ -18,6 +18,7 @@ This skill extends `../mobile-e2e/SKILL.md`. Read that skill first for the basel
- iOS register flow: `apps/mobile/e2e/flows/ios/register.yaml`
- Android register flow: `apps/mobile/e2e/flows/android/register.yaml`
- Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml`
- iOS interaction skill: `/Users/diygod/.agents/skills/axe/SKILL.md`
- Expo config: `apps/mobile/app.config.ts`
- Build profiles: `apps/mobile/eas.json`
- Mobile artifacts: `apps/mobile/e2e/artifacts/`
@ -185,6 +186,10 @@ xcrun simctl install "$IOS_UDID" <PATH_TO_Folo.app>
xcrun simctl launch "$IOS_UDID" is.follow
```
### iOS interaction after launch
After the app is running on the dedicated iOS simulator, use the `$axe` skill for simulator interaction during visual validation. Use `$axe` as the default interaction layer for iOS screenshot-driven checks.
## Android workflow
Reuse the Java and Android SDK setup from `../mobile-e2e/SKILL.md`.
@ -364,7 +369,7 @@ For auth-related work:
## Screenshot-driven visual testing
Once the app is in the right state, drive the rest of the validation with the visual toolchain available in the current environment. Screenshots are the source of truth for acceptance.
Once the app is in the right state, drive the rest of the validation visually. On iOS, use the `$axe` skill as the default interaction tool for this phase. On Android, use the interaction tooling available in the current environment. Screenshots are the source of truth for acceptance.
Create a timestamped artifact folder first:
@ -430,4 +435,5 @@ If the client supports local image rendering, attach the key screenshots as imag
- If doctor, typecheck, build, install, or server startup fails, stop and report the exact failing command.
- If `local` mode cannot reach the local server, do not silently fall back to `prod`.
- If the visual flow cannot be completed because the environment lacks the required interaction tooling, report that limitation clearly and still return the screenshots you captured.
- If the iOS visual flow cannot be completed because `$axe` is unavailable or unusable, report that limitation clearly and still return the screenshots you captured.
- If the Android visual flow cannot be completed because the environment lacks suitable interaction tooling, report that limitation clearly and still return the screenshots you captured.

View File

@ -29,8 +29,6 @@ env:
VITE_WEB_URL: ${{ vars.VITE_WEB_URL }}
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_OPENPANEL_CLIENT_ID: ${{ vars.VITE_OPENPANEL_CLIENT_ID }}
VITE_OPENPANEL_API_URL: ${{ vars.VITE_OPENPANEL_API_URL }}
VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
@ -153,7 +151,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db
OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY }}
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
max_attempts: 3
timeout_minutes: 10

View File

@ -0,0 +1,69 @@
name: "\u2601\ufe0f Deploy Desktop to Cloudflare"
on:
push:
branches: [main, dev]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy Desktop Web
runs-on: ubuntu-latest
env:
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
lfs: true
- name: Checkout LFS objects
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- name: Use Node.js LTS
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build desktop web (SPA)
working-directory: apps/desktop
env:
VITE_WEB_URL: ${{ github.ref == 'refs/heads/dev' && 'https://dev.folo.is' || 'https://app.folo.is' }}
VITE_API_URL: ${{ github.ref == 'refs/heads/dev' && 'https://api.dev.folo.is' || 'https://api.folo.is' }}
run: pnpm run build:web
- name: Deploy desktop web to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/desktop
command: deploy --env dev
- name: Deploy desktop web to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/desktop
command: 'deploy --env=""'

View File

@ -1,18 +1,24 @@
name: "\u2601\ufe0f Deploy Landing to Cloudflare"
on:
push:
branches: [main, dev]
workflow_dispatch:
inputs:
confirm_production_deploy:
description: Deploy the selected branch to production (folo.is)
required: true
default: false
type: boolean
name: ☁️ Deploy to Cloudflare Workers
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy
name: Build & Deploy Landing Worker
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- name: Checkout code
uses: actions/checkout@v6
@ -32,39 +38,40 @@ jobs:
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js LTS
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build desktop web (SPA)
run: pnpm exec turbo run Folo#build:web
- name: Resolve deployment target
id: target
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then
echo "Manual production deploy was not confirmed." >&2
exit 1
fi
- name: Build SSR Worker
working-directory: apps/ssr
run: pnpm run build:worker
echo "environment=prod" >> "$GITHUB_OUTPUT"
exit 0
fi
- name: Copy WASM file
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "environment=dev" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "environment=prod" >> "$GITHUB_OUTPUT"
- name: Build Landing Worker
run: pnpm exec turbo run @follow/landing#cf:build
- name: Deploy to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy --env dev
- name: Deploy Landing to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
if: steps.target.outputs.environment == 'dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@ -72,17 +79,8 @@ jobs:
workingDirectory: apps/landing
command: deploy --env dev --name landing-next-dev --routes landing.dev.folo.is/*
- name: Deploy to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy
- name: Deploy Landing to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
if: steps.target.outputs.environment == 'prod'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@ -0,0 +1,109 @@
name: "\u2601\ufe0f Deploy SSR to Cloudflare"
on:
push:
branches: [main, dev]
workflow_dispatch:
inputs:
confirm_production_deploy:
description: Deploy the selected branch to production (app.folo.is)
required: true
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy SSR Worker
runs-on: ubuntu-latest
env:
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
lfs: true
- name: Checkout LFS objects
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- name: Use Node.js LTS
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Resolve deployment target
id: target
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then
echo "Manual production deploy was not confirmed." >&2
exit 1
fi
echo "environment=prod" >> "$GITHUB_OUTPUT"
echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT"
echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "environment=dev" >> "$GITHUB_OUTPUT"
echo "vite_web_url=https://dev.folo.is" >> "$GITHUB_OUTPUT"
echo "vite_api_url=https://api.dev.folo.is" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "environment=prod" >> "$GITHUB_OUTPUT"
echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT"
echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT"
- name: Build desktop web (SSR assets)
working-directory: apps/desktop
env:
VITE_WEB_URL: ${{ steps.target.outputs.vite_web_url }}
VITE_API_URL: ${{ steps.target.outputs.vite_api_url }}
run: pnpm run build:web
- name: Build SSR Worker
working-directory: apps/ssr
run: pnpm run build:worker
- name: Copy WASM file
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
- name: Deploy SSR Worker to Cloudflare (dev)
if: steps.target.outputs.environment == 'dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy --env dev
- name: Deploy SSR Worker to Cloudflare (prod)
if: steps.target.outputs.environment == 'prod'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: 'deploy --env=""'

View File

@ -1,3 +1,4 @@
/** @type {import("prettier").Config & import("prettier-plugin-tailwindcss").PluginOptions} */
export default {
semi: false,
singleQuote: false,
@ -6,4 +7,25 @@ export default {
trailingComma: "all",
objectWrap: "preserve",
plugins: ["prettier-plugin-tailwindcss"],
tailwindConfig: "./apps/desktop/tailwind.config.ts",
overrides: [
{
files: "apps/mobile/**/*.{css,js,jsx,ts,tsx}",
options: {
tailwindConfig: "./apps/mobile/tailwind.config.ts",
},
},
{
files: "apps/mobile/web-app/html-renderer/**/*.{css,js,jsx,ts,tsx}",
options: {
tailwindConfig: "./apps/mobile/web-app/html-renderer/tailwind.config.ts",
},
},
{
files: "apps/ssr/**/*.{css,html,js,jsx,ts,tsx}",
options: {
tailwindConfig: "./apps/ssr/tailwind.config.ts",
},
},
],
}

View File

@ -1,9 +1,22 @@
{
"name": "@follow/cli",
"name": "folocli",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "Folo CLI for AI agents and power users",
"version": "0.0.4",
"description": "Folo CLI for terminal workflows and automation",
"author": "Folo Team",
"license": "AGPL-3.0-only",
"homepage": "https://github.com/RSSNext/Folo",
"repository": {
"type": "git",
"url": "git+https://github.com/RSSNext/Folo.git"
},
"keywords": [
"folo",
"rss",
"reader",
"cli",
"automation"
],
"bin": {
"folo": "./dist/index.js"
},
@ -11,6 +24,9 @@
"dist",
"skill.md"
],
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsup --config tsup.config.ts && chmod +x dist/index.js",
"dev": "tsx src/index.ts",

View File

@ -14,12 +14,19 @@ Use this skill when a user asks to:
## Preconditions
1. Folo CLI is installed and executable as `folo`.
1. Node.js and npm are installed so the CLI can be executed with `npx`.
2. Authentication is configured:
- `folo auth login` (recommended, opens browser and auto-logins)
- or `folo auth login --token <session-token>`
- `npx --yes folocli@latest login` (recommended, opens browser and auto-logins)
- or `npx --yes folocli@latest login --token <session-token>`
- or set `FOLO_TOKEN=<token>`
## Execution Policy
- Prefer `npx --yes folocli@latest ...` for all agent runs.
- Do not require `npm install -g folocli`.
- No separate update preflight is needed. Using `folocli@latest` is the update strategy.
- If a user already has a working global `folo` binary, it is acceptable, but `npx --yes folocli@latest` remains the recommended default in docs and automation.
## Output Contract
Default output is JSON with a stable envelope:
@ -56,50 +63,50 @@ You can switch output mode:
### 1. Timeline Reading
1. Fetch timeline:
- `folo timeline --limit 10`
- `npx --yes folocli@latest timeline --limit 10`
2. Get entry detail:
- `folo entry get <entryId>`
- `npx --yes folocli@latest entry get <entryId>`
3. Get readability content:
- `folo entry read <entryId>`
- `npx --yes folocli@latest entry read <entryId>`
### 2. Subscription Management
1. Discover:
- `folo search discover <keyword>`
- `npx --yes folocli@latest search discover <keyword>`
2. Add subscription:
- `folo subscription add --feed <url>`
- or `folo subscription add --list <listId>`
- `npx --yes folocli@latest subscription add --feed <url>`
- or `npx --yes folocli@latest subscription add --list <listId>`
3. List subscriptions:
- `folo subscription list`
- `npx --yes folocli@latest subscription list`
### 3. Unread Processing
1. Check unread total:
- `folo unread count`
- `npx --yes folocli@latest unread count`
2. List unread subscriptions:
- `folo unread list`
- `npx --yes folocli@latest unread list`
3. Read unread entries:
- `folo timeline --unread-only --limit 20`
- `npx --yes folocli@latest timeline --unread-only --limit 20`
4. Mark read:
- `folo entry mark-read <entryId>`
- or batch: `folo entry mark-all-read --view articles`
- `npx --yes folocli@latest entry mark-read <entryId>`
- or batch: `npx --yes folocli@latest entry mark-all-read --view articles`
### 4. Collection Operations
- Add: `folo collection add <entryId>`
- Remove: `folo collection remove <entryId>`
- List: `folo collection list --limit 20`
- Add: `npx --yes folocli@latest collection add <entryId>`
- Remove: `npx --yes folocli@latest collection remove <entryId>`
- List: `npx --yes folocli@latest collection list --limit 20`
### 5. OPML Import / Export
- Export:
- `folo opml export --output backup.opml`
- `npx --yes folocli@latest opml export --output backup.opml`
- Import:
- `folo opml import feeds.opml`
- `npx --yes folocli@latest opml import feeds.opml`
## Pagination Pattern
`folo timeline` returns:
`npx --yes folocli@latest timeline` returns:
- `entries`
- `nextCursor`
@ -107,68 +114,71 @@ You can switch output mode:
Loop until `hasNext` is `false`:
1. `folo timeline --limit 20`
1. `npx --yes folocli@latest timeline --limit 20`
2. Read `nextCursor`
3. `folo timeline --limit 20 --cursor <nextCursor>`
3. `npx --yes folocli@latest timeline --limit 20 --cursor <nextCursor>`
4. Repeat
## Command Reference
- `folo auth login [--timeout <seconds>] [--token <token>]`
- `folo auth logout`
- `folo auth whoami`
- `npx --yes folocli@latest login [--timeout <seconds>] [--token <token>]`
- `npx --yes folocli@latest logout`
- `npx --yes folocli@latest whoami`
- `npx --yes folocli@latest auth login [--timeout <seconds>] [--token <token>]`
- `npx --yes folocli@latest auth logout`
- `npx --yes folocli@latest auth whoami`
- `folo timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]`
- `folo timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]`
- `folo timeline --list <listId> [--limit <n>] [--cursor <datetime>]`
- `folo timeline --category <name> [--view <type>] [--limit <n>]`
- `npx --yes folocli@latest timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --list <listId> [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest timeline --category <name> [--view <type>] [--limit <n>]`
- `folo subscription list [--view <type>] [--category <name>]`
- `folo subscription add --feed <url> [--category <name>] [--view <type>] [--private]`
- `folo subscription add --list <listId> [--category <name>] [--view <type>]`
- `folo subscription remove <id> [--target feed|list|url]`
- `folo subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]`
- `npx --yes folocli@latest subscription list [--view <type>] [--category <name>]`
- `npx --yes folocli@latest subscription add --feed <url> [--category <name>] [--view <type>] [--private]`
- `npx --yes folocli@latest subscription add --list <listId> [--category <name>] [--view <type>]`
- `npx --yes folocli@latest subscription remove <id> [--target feed|list|url]`
- `npx --yes folocli@latest subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]`
- `folo entry get <entryId>`
- `folo entry read <entryId>`
- `folo entry mark-read <entryId>`
- `folo entry mark-unread <entryId>`
- `folo entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]`
- `npx --yes folocli@latest entry get <entryId>`
- `npx --yes folocli@latest entry read <entryId>`
- `npx --yes folocli@latest entry mark-read <entryId>`
- `npx --yes folocli@latest entry mark-unread <entryId>`
- `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]`
- `folo feed get <feedId|feedUrl>`
- `folo feed refresh <feedId>`
- `folo feed analytics <feedId>`
- `npx --yes folocli@latest feed get <feedId|feedUrl>`
- `npx --yes folocli@latest feed refresh <feedId>`
- `npx --yes folocli@latest feed analytics <feedId>`
- `folo list ls`
- `folo list get <listId>`
- `folo list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]`
- `folo list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]`
- `folo list delete <listId>`
- `folo list add-feed <listId> --feed <feedId>`
- `folo list remove-feed <listId> --feed <feedId>`
- `npx --yes folocli@latest list ls`
- `npx --yes folocli@latest list get <listId>`
- `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]`
- `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]`
- `npx --yes folocli@latest list delete <listId>`
- `npx --yes folocli@latest list add-feed <listId> --feed <feedId>`
- `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>`
- `folo search discover <keyword> [--type feeds|lists]`
- `folo search rsshub <keyword> [--lang <lang>]`
- `folo search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]`
- `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]`
- `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]`
- `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]`
- `folo collection list [--limit <n>] [--cursor <datetime>]`
- `folo collection add <entryId> [--view <type>]`
- `folo collection remove <entryId>`
- `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]`
- `npx --yes folocli@latest collection add <entryId> [--view <type>]`
- `npx --yes folocli@latest collection remove <entryId>`
- `folo opml export [--output <file>]`
- `folo opml import <file> [--items <url1,url2,...>]`
- `npx --yes folocli@latest opml export [--output <file>]`
- `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]`
- `folo unread count`
- `folo unread list [--view <type>]`
- `npx --yes folocli@latest unread count`
- `npx --yes folocli@latest unread list [--view <type>]`
## Error Recovery
- `UNAUTHORIZED`
- Re-login: `folo auth login`
- or `folo auth login --token <token>`
- Re-login: `npx --yes folocli@latest login`
- or `npx --yes folocli@latest login --token <token>`
- Or set `FOLO_TOKEN`
- `HTTP_4xx` / `HTTP_5xx`
- Retry with `--verbose` for request details
- Verify `--api-url` if using non-default endpoint
- `INVALID_ARGUMENT`
- Run `folo <command> --help` to inspect accepted options
- Run `npx --yes folocli@latest <command> --help` to inspect accepted options

View File

@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("./browser-login", () => ({
loginWithBrowser: vi.fn(),
resolveBrowserLoginToken: vi.fn(),
}))
const { loginWithBrowser, resolveBrowserLoginToken } = await import("./browser-login")
const { resolveLoginToken } = await import("./commands/auth")
describe("resolveLoginToken", () => {
it("exchanges a provided one-time token into a session token", async () => {
vi.mocked(resolveBrowserLoginToken).mockResolvedValueOnce("session-token")
await expect(
resolveLoginToken({
inputToken: "one-time-token",
apiUrl: "https://api.folo.is",
timeoutMs: 180_000,
onStatus: vi.fn(),
}),
).resolves.toBe("session-token")
expect(resolveBrowserLoginToken).toHaveBeenCalledWith("https://api.folo.is", "one-time-token")
})
it("falls back to browser login when no token is provided", async () => {
vi.mocked(loginWithBrowser).mockResolvedValueOnce({
token: "browser-session-token",
callbackUrl: "http://127.0.0.1/callback",
loginUrl: "https://app.folo.is/login",
})
await expect(
resolveLoginToken({
apiUrl: "https://api.folo.is",
timeoutMs: 180_000,
onStatus: vi.fn(),
}),
).resolves.toBe("browser-session-token")
})
})

View File

@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
import { resolveCLILoginUrl } from "./browser-login"
import { resolveBrowserLoginToken, resolveCLILoginUrl } from "./browser-login"
import { CLIError } from "./output"
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe("browser login helpers", () => {
it("maps production API URL using env.common", () => {
@ -45,4 +51,149 @@ describe("browser login helpers", () => {
/Invalid API URL/,
)
})
it("exchanges one-time token for a session token", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
user: { id: "user-1" },
}),
{
status: 200,
headers: {
"content-type": "application/json",
"set-cookie":
"__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None",
},
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
})
it("falls back to verify when apply endpoint is unavailable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 404,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { token: "session-token" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://api.folo.is/better-auth/one-time-token/apply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.folo.is/better-auth/one-time-token/verify",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ token: "one-time-token" }),
}),
)
})
it("falls back when the callback already contains a session token", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Invalid token" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session: { id: "session-1" },
user: { id: "user-1" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
vi.stubGlobal("fetch", fetchMock)
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "session-token")
expect(token).toBe("session-token")
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.folo.is/better-auth/get-session",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: "Bearer session-token",
Cookie:
"__Secure-better-auth.session_token=session-token; better-auth.session_token=session-token",
}),
}),
)
})
it("surfaces verification failures when neither token path works", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Token expired" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
}),
)
vi.stubGlobal("fetch", fetchMock)
await expect(
resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "expired-token"),
).rejects.toEqual(
new CLIError("UNAUTHORIZED", "Browser login token verification failed: Token expired"),
)
})
it("throws invalid argument for malformed api url", async () => {
await expect(resolveBrowserLoginToken("not-a-url", "token")).rejects.toEqual(
new CLIError("INVALID_ARGUMENT", "Invalid API URL: not-a-url"),
)
})
})

View File

@ -8,6 +8,9 @@ import { CLIError } from "./output"
const LOCAL_CALLBACK_HOST = "127.0.0.1"
const LOCAL_CALLBACK_PATH = "/callback"
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
const ONE_TIME_TOKEN_APPLY_PATH = "/better-auth/one-time-token/apply"
const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify"
const SESSION_CHECK_PATH = "/better-auth/get-session"
const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [
{
@ -97,6 +100,177 @@ export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string
return webUrl.toString()
}
const resolveAuthEndpointUrl = (apiUrl: string, path: string): string => {
let api: URL
try {
api = new URL(apiUrl)
} catch {
throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`)
}
return new URL(path, api.origin).toString()
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
const readSetCookieValues = (response: Response): string[] => {
if (typeof response.headers.getSetCookie === "function") {
return response.headers.getSetCookie()
}
const setCookie = response.headers.get("set-cookie")
return setCookie ? [setCookie] : []
}
const extractSessionTokenFromSetCookie = (setCookieValues: string[]): string | undefined => {
for (const setCookie of setCookieValues) {
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
if (match?.[1]) {
return match[1]
}
}
return undefined
}
const extractSessionTokenFromBody = (data: unknown): string | undefined => {
if (!isRecord(data)) {
return undefined
}
if (isRecord(data.session) && typeof data.session.token === "string") {
return data.session.token
}
return undefined
}
const extractErrorMessage = async (response: Response): Promise<string | undefined> => {
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = (await response.json().catch(() => null)) as unknown
if (isRecord(data) && typeof data.message === "string" && data.message.length > 0) {
return data.message
}
return undefined
}
const text = await response.text().catch(() => "")
return text || undefined
}
const hasValidSessionToken = async (apiUrl: string, token: string): Promise<boolean> => {
const response = await fetch(resolveAuthEndpointUrl(apiUrl, SESSION_CHECK_PATH), {
headers: {
Authorization: `Bearer ${token}`,
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
},
method: "GET",
})
if (!response.ok) {
return false
}
const data = (await response.json().catch(() => null)) as unknown
return isRecord(data) && Boolean(data.user) && Boolean(data.session)
}
export const resolveBrowserLoginToken = async (apiUrl: string, token: string): Promise<string> => {
const applyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_APPLY_PATH)
const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH)
const requestBody = JSON.stringify({ token })
let response: Response | undefined
let errorMessage: string | undefined
try {
response = await fetch(applyUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: requestBody,
})
} catch (error) {
throw new CLIError(
"NETWORK_ERROR",
`Failed to apply browser login token: ${(error as Error).message}`,
)
}
if (response.ok) {
const data = (await response.json().catch(() => null)) as unknown
const sessionToken =
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
extractSessionTokenFromBody(data)
if (!sessionToken) {
throw new CLIError(
"UNAUTHORIZED",
"Browser login token apply succeeded without returning a session token.",
)
}
return sessionToken
}
errorMessage = await extractErrorMessage(response)
if (response.status === 404) {
try {
response = await fetch(verifyUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: requestBody,
})
} catch (error) {
throw new CLIError(
"NETWORK_ERROR",
`Failed to verify browser login token: ${(error as Error).message}`,
)
}
if (response.ok) {
const data = (await response.json().catch(() => null)) as unknown
const sessionToken =
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
extractSessionTokenFromBody(data)
if (!sessionToken) {
throw new CLIError(
"UNAUTHORIZED",
"Browser login verification succeeded without returning a session token.",
)
}
return sessionToken
}
errorMessage = await extractErrorMessage(response)
}
try {
if (await hasValidSessionToken(apiUrl, token)) {
return token
}
} catch {
// Ignore fallback probe failures and surface the original verification error below.
}
throw new CLIError(
"UNAUTHORIZED",
errorMessage
? `Browser login token verification failed: ${errorMessage}`
: "Browser login token verification failed.",
)
}
export interface BrowserLoginOptions {
apiUrl: string
timeoutMs?: number
@ -166,13 +340,22 @@ export const loginWithBrowser = async (
: ""
const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)
settle(() => {
resolve({
token,
callbackUrl,
loginUrl,
})
})
void (async () => {
try {
const sessionToken = await resolveBrowserLoginToken(options.apiUrl, token)
settle(() => {
resolve({
token: sessionToken,
callbackUrl,
loginUrl,
})
})
} catch (error) {
settle(() => {
reject(error)
})
}
})()
})
server.once("error", (error) => {
@ -210,7 +393,7 @@ export const loginWithBrowser = async (
reject(
new CLIError(
"TIMEOUT",
"Timed out waiting for browser login. Please run `folo auth login` again.",
"Timed out waiting for browser login. Please run `folo login` again.",
),
)
})

View File

@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"
const execFileAsync = promisify(execFile)
const cliPath = resolve(process.cwd(), "dist/index.js")
const testToken = process.env.FOLO_TEST_TOKEN
const isolatedHome = mkdtempSync(resolve(tmpdir(), "folo-cli-test-"))
const isolatedHome = mkdtempSync(resolve(tmpdir(), "folocli-test-"))
type CLIExecution = {
code: number
@ -64,7 +64,7 @@ describe("cli e2e", () => {
})
it.runIf(Boolean(testToken))("can fetch session with test token", async () => {
const result = await runCLI(["--token", testToken!, "auth", "whoami"])
const result = await runCLI(["--token", testToken!, "whoami"])
expect(result.code).toBe(0)
const payload = JSON.parse(result.stdout) as {

View File

@ -12,7 +12,7 @@ const readString = (value: unknown): string | undefined => {
return typeof value === "string" && value.length > 0 ? value : undefined
}
const normalizeToken = (token: string | undefined) => {
export const normalizeToken = (token: string | undefined) => {
if (!token || !token.includes("%")) {
return token
}
@ -42,6 +42,61 @@ export interface CommandContext {
token?: string
}
export interface CLIAuthSession {
user?: Record<string, unknown>
session?: Record<string, unknown>
role?: unknown
roleEndAt?: unknown
feedSubscriptionLimit?: unknown
rsshubSubscriptionLimit?: unknown
}
const readSessionErrorMessage = async (response: Response): Promise<string | undefined> => {
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null
return typeof data?.message === "string" ? data.message : undefined
}
const text = await response.text().catch(() => "")
return text || undefined
}
export const fetchAuthSession = async ({
apiUrl,
token,
verbose = false,
}: {
apiUrl: string
token: string
verbose?: boolean
}): Promise<CLIAuthSession> => {
const requestUrl = `${apiUrl}/better-auth/get-session`
if (verbose) {
console.error(`[request] GET ${requestUrl}`)
}
const response = await fetch(requestUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
},
})
if (verbose) {
console.error(`[response] GET ${requestUrl} -> ${response.status}`)
}
if (!response.ok) {
const message = await readSessionErrorMessage(response)
throw new CLIError("UNAUTHORIZED", message || "Token is invalid or expired.")
}
return (await response.json()) as CLIAuthSession
}
export const getGlobalOptions = (command: Command): GlobalOptions => {
const options = command.optsWithGlobals() as Record<string, unknown>
@ -82,7 +137,7 @@ export const createCommandContext = async (
if (requireAuth && !token) {
throw new CLIError(
"UNAUTHORIZED",
"Missing token. Run `folo auth login` (browser sign-in) or set FOLO_TOKEN.",
"Missing token. Run `folo login` (browser sign-in) or set FOLO_TOKEN.",
)
}

View File

@ -1,103 +1,154 @@
import type { Command } from "commander"
import { parsePositiveInt } from "../args"
import { loginWithBrowser } from "../browser-login"
import { getGlobalOptions } from "../client"
import { loginWithBrowser, resolveBrowserLoginToken } from "../browser-login"
import { fetchAuthSession, getGlobalOptions, normalizeToken } from "../client"
import { runCommand } from "../command"
import { clearToken, getConfigPath, updateConfig } from "../config"
import { CLIError } from "../output"
export const resolveLoginToken = async ({
inputToken,
apiUrl,
timeoutMs,
onStatus,
}: {
inputToken?: string
apiUrl: string
timeoutMs: number
onStatus: (message: string) => void
}) => {
if (inputToken) {
return await resolveBrowserLoginToken(apiUrl, inputToken)
}
const browserLogin = await loginWithBrowser({
apiUrl,
timeoutMs,
onStatus,
})
return browserLogin.token
}
interface AuthLoginOptions {
token?: string
timeout?: number
}
export const registerAuthCommand = (program: Command) => {
const authCommand = program.command("auth").description("Authentication commands")
const runLoginAction = async function (this: Command, options: AuthLoginOptions) {
await runCommand(
this,
async ({ client, options: globalOptions }) => {
const resolvedToken = await resolveLoginToken({
inputToken: options.token ?? getGlobalOptions(this).token,
apiUrl: globalOptions.apiUrl,
timeoutMs: (options.timeout ?? 180) * 1000,
onStatus: (message) => {
console.error(`[auth] ${message}`)
},
})
authCommand
.command("login")
.description("Sign in via browser (or save a provided token) and verify authentication")
.option("--token <token>", "Session token from Folo")
const token = normalizeToken(resolvedToken) ?? resolvedToken
client.setAuthToken(token)
const session = await fetchAuthSession({
apiUrl: globalOptions.apiUrl,
token,
verbose: globalOptions.verbose,
})
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
await updateConfig({
token,
apiUrl: globalOptions.apiUrl,
})
return {
message: "Login successful.",
configPath: getConfigPath(),
user: session.user,
}
},
{ requireAuth: false },
)
}
const runLogoutAction = async function (this: Command) {
await runCommand(
this,
async () => {
await clearToken()
return {
message: "Logged out.",
configPath: getConfigPath(),
}
},
{ requireAuth: false },
)
}
const runWhoamiAction = async function (this: Command) {
await runCommand(this, async ({ token, options: globalOptions }) => {
if (!token) {
throw new CLIError("UNAUTHORIZED", "Missing token.")
}
const session = await fetchAuthSession({
apiUrl: globalOptions.apiUrl,
token,
verbose: globalOptions.verbose,
})
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
return {
user: session.user,
session: session.session,
role: session.role,
roleEndAt: session.roleEndAt ?? null,
feedSubscriptionLimit: session.feedSubscriptionLimit,
rsshubSubscriptionLimit: session.rsshubSubscriptionLimit,
}
})
}
const registerLoginCommand = (program: Command, name: string, description: string) => {
program
.command(name)
.description(description)
.option("--token <token>", "Session or one-time token from Folo")
.option(
"--timeout <seconds>",
"Browser login timeout in seconds (default: 180)",
parsePositiveInt,
)
.action(async function (this: Command, options: AuthLoginOptions) {
await runCommand(
this,
async ({ client, options: globalOptions }) => {
let token = options.token ?? getGlobalOptions(this).token
if (!token) {
const timeoutMs = (options.timeout ?? 180) * 1000
const browserLogin = await loginWithBrowser({
apiUrl: globalOptions.apiUrl,
timeoutMs,
onStatus: (message) => {
console.error(`[auth] ${message}`)
},
})
token = browserLogin.token
}
client.setAuthToken(token)
const session = await client.api.auth.getSession()
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
await updateConfig({
token,
apiUrl: globalOptions.apiUrl,
})
return {
message: "Login successful.",
configPath: getConfigPath(),
user: session.user,
}
},
{ requireAuth: false },
)
})
authCommand
.command("logout")
.description("Clear stored token")
.action(async function (this: Command) {
await runCommand(
this,
async () => {
await clearToken()
return {
message: "Logged out.",
configPath: getConfigPath(),
}
},
{ requireAuth: false },
)
})
authCommand
.command("whoami")
.description("Show current session user")
.action(async function (this: Command) {
await runCommand(this, async ({ client }) => {
const session = await client.api.auth.getSession()
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
return {
user: session.user,
session: session.session,
role: session.role,
roleEndAt: session.roleEndAt ?? null,
feedSubscriptionLimit: session.feedSubscriptionLimit,
rsshubSubscriptionLimit: session.rsshubSubscriptionLimit,
}
})
})
.action(runLoginAction)
}
const registerLogoutCommand = (program: Command, name: string, description: string) => {
program.command(name).description(description).action(runLogoutAction)
}
const registerWhoamiCommand = (program: Command, name: string, description: string) => {
program.command(name).description(description).action(runWhoamiAction)
}
export const registerAuthCommand = (program: Command) => {
const authCommand = program.command("auth").description("Authentication commands")
registerLoginCommand(
authCommand,
"login",
"Sign in via browser (or save a provided token) and verify authentication",
)
registerLogoutCommand(authCommand, "logout", "Clear stored token")
registerWhoamiCommand(authCommand, "whoami", "Show current session user")
registerLoginCommand(program, "login", "Sign in and store a CLI session")
registerLogoutCommand(program, "logout", "Clear the stored CLI session")
registerWhoamiCommand(program, "whoami", "Show the current CLI session user")
}

View File

@ -1,5 +1,6 @@
import { Command } from "commander"
import packageJSON from "../package.json"
import { parseFormat } from "./args"
import { defaultApiURL } from "./client"
import { registerAuthCommand } from "./commands/auth"
@ -20,7 +21,7 @@ const program = new Command()
program
.name("folo")
.description("Folo CLI client for structured automation")
.version("0.1.0")
.version(packageJSON.version)
.option("-f, --format <format>", "Output format: json | table | plain", parseFormat, "json")
.option("--api-url <url>", `API base URL (default: ${defaultApiURL})`)
.option("--token <token>", "Override stored token")

View File

@ -4,8 +4,6 @@ VITE_IMGPROXY_URL=http://localhost:2873
VITE_SENTRY_DSN=
VITE_BUILD_TYPE=production
VITE_INBOXES_EMAIL=@follow.re
VITE_OPENPANEL_CLIENT_ID=
VITE_OPENPANEL_API_URL=
VITE_EDITOR=cursor

View File

@ -173,7 +173,7 @@ For hover states on buttons or interactive areas within glass containers:
}}
>
{/* Subtle shine effect */}
<div className="via-gray/5 absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5" />
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray/5 to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5" />
</button>
```

View File

@ -0,0 +1,22 @@
# What's new in v1.5.0
## Shiny new things
## Improvements
- Polished authentication, wallet, notifications, and discover surfaces
- Kept the AI chat input within the viewport during longer conversations
## No longer broken
- Fixed Electron sign-in for accounts using two-factor authentication
- Fixed PDF export through Electron IPC
- Fixed auth origin headers and restored renderer API requests
- Fixed theme preference persistence
- Fixed hovered unread entries disappearing in poor network conditions
- Fixed Obsidian vault selection and export reliability on macOS with native folder picking
- Fixed Android video playback changing the original audio pitch
## Thanks
Special thanks to volunteer contributor @Eumenides-K for their valuable contributions

View File

@ -0,0 +1,178 @@
import { mkdir } from "node:fs/promises"
import { chromium } from "@playwright/test"
import { join } from "pathe"
import { createTestAccount, tryDeleteCurrentUser } from "../support/account"
import {
closeSettings,
dismissFeedForm,
followOnboardingFeed,
openSettings,
openWebApp,
} from "../support/app"
import { bootstrapAuthenticatedWebSession } from "../support/auth-bootstrap"
import { buildWebAppURL, resolveDesktopE2EEnv } from "../support/env"
const SETTING_TABS = [
"general",
"appearance",
"notifications",
"shortcuts",
"ai",
"integration",
"feeds",
"list",
"profile",
"data-control",
"cli",
"plan",
"about",
] as const
const SUBVIEW_ROUTES = ["discover", "power", "action", "rsshub", "ai"] as const
const waitForUiSettled = async (page: import("@playwright/test").Page, delay = 1200) => {
await page.waitForLoadState("domcontentloaded")
await page.waitForTimeout(delay)
}
const waitForRouteReady = async (
page: import("@playwright/test").Page,
route: (typeof SUBVIEW_ROUTES)[number],
) => {
await waitForUiSettled(page, route === "power" ? 3500 : 1200)
if (route === "power") {
await page
.waitForFunction(
() =>
document.body.textContent?.includes("Your Balance") ||
document.body.textContent?.includes("Transactions") ||
document.body.textContent?.includes("Create Wallet"),
undefined,
{ timeout: 15_000 },
)
.catch(() => {})
}
}
async function main() {
const env = resolveDesktopE2EEnv()
const outputDir = join(
env.desktopAppDir,
"e2e",
"artifacts",
"ui-audit",
`run-${new Date().toISOString().replaceAll(":", "-")}`,
)
await mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({
channel: "chromium",
headless: true,
args: ["--disable-web-security"],
})
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: {
width: 1440,
height: 980,
},
colorScheme: "light",
})
let page = await context.newPage()
const account = createTestAccount("ui-audit")
let screenshotIndex = 1
const capture = async (name: string) => {
const path = join(outputDir, `${String(screenshotIndex).padStart(2, "0")}-${name}.png`)
screenshotIndex += 1
await page.screenshot({ path, fullPage: false })
console.info(path)
}
const bootstrapAccount = async () => {
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await bootstrapAuthenticatedWebSession(page, env, account)
return
} catch (error) {
await capture(`auth-bootstrap-attempt-${attempt}-failed`)
if (attempt === 3) {
throw error
}
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForUiSettled(page)
}
}
}
try {
await openWebApp(page, env)
await waitForUiSettled(page)
await capture("00-login-modal")
await page.close()
page = await context.newPage()
await bootstrapAccount()
await waitForUiSettled(page)
await capture("01-home-articles")
await followOnboardingFeed(page, env)
await waitForUiSettled(page)
await capture("02-discover-follow")
await dismissFeedForm(page)
const timelineTabs = await page.locator('[data-testid^="timeline-tab-"]').all()
for (const tab of timelineTabs) {
const testId = await tab.getAttribute("data-testid")
if (!testId) continue
await tab.click()
await waitForUiSettled(page)
await capture(`timeline-${testId.replace("timeline-tab-", "")}`)
}
for (const route of SUBVIEW_ROUTES) {
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
await waitForRouteReady(page, route)
await capture(`subview-${route}`)
}
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForUiSettled(page)
await openSettings(page)
await waitForUiSettled(page)
for (const tab of SETTING_TABS) {
if (tab === "general") {
await capture("settings-general")
continue
}
const tabTrigger = page.getByTestId(`settings-tab-${tab}`)
if (!(await tabTrigger.isVisible().catch(() => false))) {
continue
}
await tabTrigger.click()
await waitForUiSettled(page)
await capture(`settings-${tab}`)
}
await closeSettings(page)
await waitForUiSettled(page)
await capture("home-after-settings")
} finally {
await tryDeleteCurrentUser(page, env).catch(() => null)
await context.close().catch(() => {})
await browser.close().catch(() => {})
}
}
void main()

View File

@ -16,6 +16,30 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
(nextEnv) => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
const originalFetch = globalThis.fetch.bind(globalThis)
const authEndpoints = [
"/better-auth/sign-in/email",
"/better-auth/sign-up/email",
"/better-auth/forget-password",
]
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const requestURL = new URL(request.url, globalThis.location.origin)
const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path))
if (!shouldInjectToken) {
return originalFetch(input, init)
}
const headers = new Headers(request.headers)
if (!headers.has("x-token")) {
headers.set("x-token", "r3:e2e-token")
}
return originalFetch(new Request(request, { headers }))
}
if (!nextEnv) {
return
}
@ -549,10 +573,14 @@ export const expectOnboardingFeedUnsubscribed = async (
export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {
await returnToMainShell(page)
await page.getByTestId("timeline-tab-videos").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0)
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)
await page.getByTestId("timeline-tab-articles").click()
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)
const unreadOnboardingEntry = page

View File

@ -0,0 +1,224 @@
import type { BrowserContext, Page } from "@playwright/test"
import { nanoid } from "nanoid"
import type { TestAccount } from "./account"
import { injectRecaptchaToken, waitForAuthenticated } from "./app"
import type { DesktopE2EEnv } from "./env"
import { buildWebAppURL } from "./env"
type AuthBootstrapResponse = {
token?: string | null
error?: {
message?: string
} | null
}
type ParsedCookie = {
expires?: number
httpOnly: boolean
name: string
path: string
sameSite: "Lax" | "None" | "Strict"
secure: boolean
value: string
}
const splitSetCookieHeader = (header: string) => {
const parts: string[] = []
let buffer = ""
for (const char of header) {
if (char === ",") {
const recent = buffer.toLowerCase()
const hasExpires = recent.includes("expires=")
const hasGmt = /gmt/i.test(recent)
if (hasExpires && !hasGmt) {
buffer += char
continue
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
buffer = ""
continue
}
buffer += char
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
return parts
}
const parseSetCookieHeader = (header: string): ParsedCookie[] => {
return splitSetCookieHeader(header)
.map((cookie) => {
const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim())
const [name, ...valueParts] = nameValue?.split("=") ?? []
if (!name) {
return null
}
const parsedCookie: ParsedCookie = {
name,
value: valueParts.join("="),
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
}
for (const attribute of attributes) {
const [rawKey, ...rawValueParts] = attribute.split("=")
const key = rawKey?.toLowerCase()
const value = rawValueParts.join("=")
switch (key) {
case "expires": {
const expires = new Date(value)
if (!Number.isNaN(expires.getTime())) {
parsedCookie.expires = expires.getTime() / 1000
}
break
}
case "httponly": {
parsedCookie.httpOnly = true
break
}
case "path": {
parsedCookie.path = value || "/"
break
}
case "samesite": {
if (value === "None" || value === "Strict" || value === "Lax") {
parsedCookie.sameSite = value
}
break
}
case "secure": {
parsedCookie.secure = true
break
}
}
}
return parsedCookie
})
.filter(Boolean)
}
const requestAuth = async ({
apiURL,
path,
body,
}: {
apiURL: string
body: Record<string, unknown>
path: string
}) => {
const response = await fetch(new URL(path, apiURL), {
method: "POST",
headers: {
"Cache-Control": "no-store",
"content-type": "application/json",
"x-app-name": "Folo Web",
"x-app-platform": "desktop/web",
"x-app-version": "1.4.0",
"x-client-id": nanoid(),
"x-session-id": nanoid(),
"x-token": "ac:fallback",
},
body: JSON.stringify(body),
})
return {
response,
body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null,
setCookie: response.headers.get("set-cookie"),
}
}
const signIn = (env: DesktopE2EEnv, account: TestAccount) =>
requestAuth({
apiURL: env.apiURL,
path: "/better-auth/sign-in/email",
body: {
email: account.email,
password: account.password,
rememberMe: true,
},
})
const signUp = (env: DesktopE2EEnv, account: TestAccount) =>
requestAuth({
apiURL: env.apiURL,
path: "/better-auth/sign-up/email",
body: {
email: account.email,
password: account.password,
name: account.email.split("@")[0] ?? account.email,
callbackURL: `${env.webURL}/login`,
},
})
const applyCookiesToContext = async (
context: BrowserContext,
env: DesktopE2EEnv,
setCookieHeader: string,
) => {
const cookies = parseSetCookieHeader(setCookieHeader)
await context.addCookies(
cookies.map((cookie) => ({
url: env.apiURL,
name: cookie.name,
value: cookie.value,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
sameSite: cookie.sameSite,
expires: cookie.expires,
})),
)
}
export const bootstrapAuthenticatedWebSession = async (
page: Page,
env: DesktopE2EEnv,
account: TestAccount,
) => {
let signInResult = await signIn(env, account)
if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {
const signUpResult = await signUp(env, account)
const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? ""
const isExistingAccount =
signUpError.includes("exist") ||
signUpError.includes("already") ||
signUpError.includes("taken")
if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) {
throw new Error(
signUpResult.body?.error?.message ||
signInResult.body?.error?.message ||
`auth bootstrap failed with ${signUpResult.response.status}`,
)
}
signInResult = await signIn(env, account)
}
if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {
throw new Error(
signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`,
)
}
await applyCookiesToContext(page.context(), env, signInResult.setCookie)
await injectRecaptchaToken(page, env)
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForAuthenticated(page)
}

View File

@ -51,6 +51,30 @@ export const launchElectronApp = async (env: DesktopE2EEnv) => {
await page.waitForLoadState("domcontentloaded")
await page.evaluate(() => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
const originalFetch = globalThis.fetch.bind(globalThis)
const authEndpoints = [
"/better-auth/sign-in/email",
"/better-auth/sign-up/email",
"/better-auth/forget-password",
]
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const requestURL = new URL(request.url, globalThis.location.origin)
const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path))
if (!shouldInjectToken) {
return originalFetch(input, init)
}
const headers = new Headers(request.headers)
if (!headers.has("x-token")) {
headers.set("x-token", "r3:e2e-token")
}
return originalFetch(new Request(request, { headers }))
}
})
return {

View File

@ -102,10 +102,7 @@ const config: ForgeConfig = {
buildVersion: process.env.BUILD_VERSION || undefined,
appBundleId: "is.follow",
icon: isStaging ? "resources/icon-staging" : "resources/icon",
extraResource: [
"./resources/app-update.yml",
...(fs.existsSync("./resources/cli") ? ["./resources/cli"] : []),
],
extraResource: ["./resources/app-update.yml"],
protocols: [
{
name: "Folo",

View File

@ -29,7 +29,6 @@
"@follow-app/readability": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@openpanel/web": "1.0.7",
"builder-util-runtime": "9.5.1",
"electron-context-menu": "4.1.1",
"electron-ipc-decorator": "0.2.0",

View File

@ -30,10 +30,18 @@ interface SearchInput {
options: Electron.FindInPageOptions
}
interface ExportCurrentPageAsPdfInput {
defaultPath?: string
}
interface Sender extends Electron.WebContents {
getOwnerBrowserWindow: () => Electron.BrowserWindow | null
}
const ensurePdfExtension = (filePath: string) => {
return path.extname(filePath).toLowerCase() === ".pdf" ? filePath : `${filePath}.pdf`
}
export class AppService extends IpcService {
static override readonly groupName = "app"
@ -171,22 +179,48 @@ export class AppService extends IpcService {
}
}
@IpcMethod()
async exportCurrentPageAsPdf(
context: IpcContext,
input: ExportCurrentPageAsPdfInput = {},
): Promise<string | null> {
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
const dialogOptions: Electron.SaveDialogOptions = {
defaultPath: ensurePdfExtension(input.defaultPath || "Untitled.pdf"),
filters: [{ name: "PDF", extensions: ["pdf"] }],
properties: ["createDirectory", "showOverwriteConfirmation"],
}
const result = senderWindow
? await dialog.showSaveDialog(senderWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
if (result.canceled || !result.filePath) return null
const pdfData = await context.sender.printToPDF({
printBackground: true,
preferCSSPageSize: true,
})
const filePath = ensurePdfExtension(result.filePath)
await fsp.writeFile(filePath, pdfData)
return filePath
}
@IpcMethod()
getAppPath(_context: IpcContext): string {
return app.getAppPath()
}
@IpcMethod()
resolveAppAsarPath(context: IpcContext, input: string): string {
if (input.startsWith("file://")) {
input = fileURLToPath(input)
resolveAppAsarPath(_context: IpcContext, input: string): string {
const resolvedInput = input.startsWith("file://") ? fileURLToPath(input) : input
if (path.isAbsolute(resolvedInput)) {
return resolvedInput
}
if (path.isAbsolute(input)) {
return input
}
return path.join(app.getAppPath(), input)
return path.join(app.getAppPath(), resolvedInput)
}
@IpcMethod()
@ -251,4 +285,23 @@ export class AppService extends IpcService {
getCacheSize(_context: IpcContext) {
return getCacheSize()
}
@IpcMethod()
async selectDirectory(_context: IpcContext): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]!
}
@IpcMethod()
async checkPathExists(_context: IpcContext, input: string): Promise<boolean> {
try {
await fsp.access(input)
return true
} catch {
return false
}
}
}

View File

@ -1,5 +1,5 @@
import { env } from "@follow/shared/env.desktop"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
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"
@ -7,13 +7,29 @@ import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync"
import {
buildManagedAuthCookieHeader,
buildManagedAuthCookieHeaderFromSetCookieHeader,
getManagedAuthCookies,
persistManagedAuthCookiesFromSetCookieHeader,
} from "../../lib/auth-cookies"
import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync"
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
import { logger } from "../../logger"
export class AuthService extends IpcService {
static override readonly groupName = "auth"
private pendingTwoFactorCookieHeader: string | null = null
private getAuthRequestHeaders(additionalHeaders?: Record<string, string>) {
return {
...createDesktopAPIHeaders({ version: PKG.version }),
...createAuthRequestOriginHeaders(env.VITE_WEB_URL),
...additionalHeaders,
}
}
private async applySessionToken(token: string): Promise<void> {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow || !token) {
@ -22,23 +38,33 @@ export class AuthService extends IpcService {
const apiURL = env.VITE_API_URL
const url = new URL(apiURL)
const isSecure = url.protocol === "https:"
const isSecure =
url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1"
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1"
const cookieNames = [
BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []),
]
await mainWindow.webContents.session.cookies.set({
url: apiURL,
name: BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
value: token,
...(isLocalhost ? {} : { domain: url.hostname }),
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "no_restriction",
expirationDate: new Date().setDate(new Date().getDate() + 30),
})
await Promise.all(
cookieNames.map((name) =>
mainWindow.webContents.session.cookies.set({
url: apiURL,
name,
value: token,
...(isLocalhost ? {} : { domain: url.hostname }),
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "no_restriction",
expirationDate: new Date().setDate(new Date().getDate() + 30),
}),
),
)
}
private async clearSessionToken(): Promise<void> {
this.pendingTwoFactorCookieHeader = null
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) {
return
@ -62,8 +88,7 @@ export class AuthService extends IpcService {
method: "POST",
headers: {
"content-type": "application/json",
...createDesktopAPIHeaders({ version: PKG.version }),
...headers,
...this.getAuthRequestHeaders(headers),
},
body: JSON.stringify(payload),
})
@ -72,13 +97,33 @@ export class AuthService extends IpcService {
.json()
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
const setCookie = response.headers.get("set-cookie") || ""
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,
})
}
const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie)
this.pendingTwoFactorCookieHeader =
response.ok && typeof data.twoFactorRedirect === "boolean" && data.twoFactorRedirect
? pendingTwoFactorCookieHeader || null
: null
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
const sessionToken = sessionCookieMatch?.[1] ?? null
const token = typeof data.token === "string" ? data.token : null
const persistedSessionToken = sessionToken ?? token
if (response.ok && persistedSessionToken) {
void this.applySessionToken(persistedSessionToken).catch(() => {})
if (response.ok && persistedSessionToken && !setCookie && mainWindow) {
await this.applySessionToken(persistedSessionToken)
}
if (sessionToken) {
@ -97,11 +142,13 @@ export class AuthService extends IpcService {
}
@IpcMethod()
async sessionChanged(_context: IpcContext): Promise<void> {
async sessionChanged(_context: IpcContext, preferredToken?: string): Promise<void> {
await updateNotificationsToken()
// Sync session token to CLI config
const token = await getSessionTokenFromCookies()
// Sync the current desktop session to the npm CLI login.
const token = await getCliSessionToken({
preferredToken,
})
await syncSessionToCliConfig(token).catch((err) => {
logger.error("Failed to sync session to CLI config:", err)
})
@ -111,7 +158,7 @@ export class AuthService extends IpcService {
async signOut(_context: IpcContext): Promise<void> {
await deleteNotificationsToken()
// Clear CLI config token on sign out
// Clear the synced CLI login on sign out.
await syncSessionToCliConfig().catch((err) => {
logger.error("Failed to clear CLI config token:", err)
})
@ -121,19 +168,90 @@ export class AuthService extends IpcService {
async signOutRemote(_context: IpcContext, token?: string): Promise<void> {
await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, {
method: "POST",
headers: {
...createDesktopAPIHeaders({ version: PKG.version }),
...(token
headers: this.getAuthRequestHeaders(
token
? {
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
}
: {}),
},
: undefined,
),
}).catch(() => {})
await this.clearSessionToken()
}
@IpcMethod()
async verifyTotp(
_context: IpcContext,
payload: { code: string; trustDevice?: boolean; headers?: Record<string, string> },
) {
const mainWindow = WindowManager.getMainWindow()
const cookieHeader =
this.pendingTwoFactorCookieHeader ||
(mainWindow
? buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
}),
)
: "")
const response = await fetch(`${env.VITE_API_URL}/better-auth/two-factor/verify-totp`, {
method: "POST",
headers: this.getAuthRequestHeaders({
"content-type": "application/json",
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...payload.headers,
}),
body: JSON.stringify({
code: payload.code,
...(payload.trustDevice !== undefined ? { trustDevice: payload.trustDevice } : {}),
}),
})
const data = (await response
.json()
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
const setCookie =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie().join(", ")
: response.headers.get("set-cookie") || ""
if (response.ok && setCookie && mainWindow) {
await persistManagedAuthCookiesFromSetCookieHeader({
apiURL: env.VITE_API_URL,
session: mainWindow.webContents.session,
setCookieHeader: setCookie,
})
}
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
const sessionTokenFromCookie = sessionCookieMatch?.[1] ?? null
const sessionTokenFromBody =
data.session && typeof data.session === "object" && "token" in data.session
? (data.session as { token?: unknown }).token
: null
const sessionToken =
typeof sessionTokenFromBody === "string" ? sessionTokenFromBody : sessionTokenFromCookie
if (typeof sessionToken === "string") {
data.sessionToken = sessionToken
}
if (response.ok) {
this.pendingTwoFactorCookieHeader = null
}
return {
data,
error: response.ok
? null
: {
message: typeof data.message === "string" ? data.message : response.statusText,
status: response.status,
},
}
}
@IpcMethod()
async signInWithCredential(
_context: IpcContext,

View File

@ -1,51 +1,26 @@
import { execSync } from "node:child_process"
import { existsSync, lstatSync, readlinkSync } from "node:fs"
import { unlink, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { app } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import path from "pathe"
import { logger } from "../../logger"
const CLI_BINARY_NAME = "folo"
const getDefaultInstallDir = (): string => {
switch (process.platform) {
case "win32": {
return path.join(process.env.LOCALAPPDATA || "", "Folo", "bin")
}
default: {
return "/usr/local/bin"
}
}
}
const getCliSourcePath = (): string => {
if (app.isPackaged) {
return path.join(process.resourcesPath, "cli", "index.js")
}
// In dev, read from the cli workspace directly
return path.resolve(app.getAppPath(), "../../cli/dist/index.js")
}
const getCliInstallPath = (): string => {
return path.join(getDefaultInstallDir(), CLI_BINARY_NAME)
}
/** Check whether the CLI is present at the given path (also checks .cmd on Windows). */
const cliExistsAt = (installPath: string): boolean => {
if (existsSync(installPath)) return true
if (process.platform === "win32" && existsSync(`${installPath}.cmd`)) return true
return false
}
import {
CLI_NPM_PACKAGE_NAME,
getCliConfigPath,
getCliInstallCommand,
getCliLoginCommand,
getCliSessionToken,
getSessionTokenFromCookies,
isCliRunnerAvailable,
readCliConfig,
syncSessionToCliConfig,
} from "../../lib/cli-session-sync"
export interface CliInstallStatus {
installed: boolean
installPath: string | null
cliSourceAvailable: boolean
connected: boolean
configPath: string
hasDesktopSession: boolean
installCommand: string
loginCommand: string
npxAvailable: boolean
packageName: string
}
export class CliService extends IpcService {
@ -53,169 +28,59 @@ export class CliService extends IpcService {
@IpcMethod()
async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> {
const installPath = getCliInstallPath()
const cliSourcePath = getCliSourcePath()
const cliSourceAvailable = existsSync(cliSourcePath)
const [config, npxAvailable, desktopToken] = await Promise.all([
readCliConfig(),
isCliRunnerAvailable(),
getSessionTokenFromCookies(),
])
try {
if (!cliExistsAt(installPath)) {
return { installed: false, installPath: null, cliSourceAvailable }
}
if (existsSync(installPath)) {
const stats = lstatSync(installPath)
if (stats.isSymbolicLink()) {
const target = readlinkSync(installPath)
const isOurs = target.includes("Folo") || target.includes("cli/index.js")
return { installed: isOurs, installPath, cliSourceAvailable }
}
}
// Exists as a regular file (wrapper script or .cmd on Windows)
return { installed: true, installPath, cliSourceAvailable }
} catch {
return { installed: false, installPath: null, cliSourceAvailable }
return {
connected: Boolean(config.token),
configPath: getCliConfigPath(),
hasDesktopSession: Boolean(desktopToken),
installCommand: getCliInstallCommand(),
loginCommand: getCliLoginCommand(),
npxAvailable,
packageName: CLI_NPM_PACKAGE_NAME,
}
}
@IpcMethod()
async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
const cliSource = getCliSourcePath()
if (!existsSync(cliSource)) {
return { success: false, error: "CLI bundle not found in app resources" }
}
const installPath = getCliInstallPath()
const wrapperContent = `#!/bin/sh\nexec /usr/bin/env node "${cliSource}" "$@"\n`
if (process.platform === "win32") {
return this.installCliWindows(cliSource, installPath)
}
async installCli(
_context: IpcContext,
preferredToken?: string,
): Promise<{ success: boolean; error?: string }> {
try {
// Try without elevated permissions first
await writeFile(installPath, wrapperContent, { mode: 0o755 })
logger.info(`CLI installed at ${installPath}`)
if (!(await isCliRunnerAvailable())) {
return { success: false, error: "npx is not available. Install Node.js and npm first." }
}
const token = await getCliSessionToken({
preferredToken,
})
if (!token) {
return { success: false, error: "Sign in to Folo Desktop first." }
}
await syncSessionToCliConfig(token)
return { success: true }
} catch {
// Write to a temp file first, then use admin privileges to copy it.
// This avoids shell-expansion issues with $@ in the wrapper content.
const tmpFile = path.join(tmpdir(), `folo-cli-wrapper-${Date.now()}`)
try {
await writeFile(tmpFile, wrapperContent, { mode: 0o755 })
if (process.platform === "darwin") {
execSync(
`osascript -e 'do shell script "cp \\"${tmpFile}\\" \\"${installPath}\\" && chmod +x \\"${installPath}\\"" with administrator privileges'`,
)
} else {
// Linux: use pkexec
execSync(`pkexec sh -c 'cp "${tmpFile}" "${installPath}" && chmod +x "${installPath}"'`)
}
logger.info(`CLI installed at ${installPath} (with elevated privileges)`)
return { success: true }
} catch (err) {
logger.error("Failed to install CLI with elevated privileges:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to install CLI",
}
} finally {
await unlink(tmpFile).catch(() => {})
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to sync CLI login",
}
}
}
@IpcMethod()
async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
const installPath = getCliInstallPath()
if (!cliExistsAt(installPath)) {
return { success: true }
}
if (process.platform === "win32") {
return this.uninstallCliWindows(installPath)
}
try {
await unlink(installPath)
logger.info(`CLI uninstalled from ${installPath}`)
await syncSessionToCliConfig()
return { success: true }
} catch {
// Needs elevated permissions
if (process.platform === "darwin") {
try {
execSync(
`osascript -e 'do shell script "rm -f \\"${installPath}\\"" with administrator privileges'`,
)
logger.info(`CLI uninstalled from ${installPath} (with admin privileges)`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI with admin privileges:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
}
}
}
try {
execSync(`pkexec rm -f "${installPath}"`)
logger.info(`CLI uninstalled from ${installPath} (with pkexec)`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
}
}
}
}
private async installCliWindows(
cliSource: string,
installPath: string,
): Promise<{ success: boolean; error?: string }> {
const installDir = path.dirname(installPath)
const cmdContent = `@echo off\r\nnode "${cliSource}" %*\r\n`
try {
const { mkdirSync, writeFileSync } = await import("node:fs")
mkdirSync(installDir, { recursive: true })
writeFileSync(`${installPath}.cmd`, cmdContent)
logger.info(`CLI installed at ${installPath}.cmd`)
return { success: true }
} catch (err) {
logger.error("Failed to install CLI on Windows:", err)
} catch (error) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to install CLI",
}
}
}
private async uninstallCliWindows(
installPath: string,
): Promise<{ success: boolean; error?: string }> {
try {
const { unlinkSync } = await import("node:fs")
const cmdPath = `${installPath}.cmd`
if (existsSync(cmdPath)) {
unlinkSync(cmdPath)
}
if (existsSync(installPath)) {
unlinkSync(installPath)
}
logger.info(`CLI uninstalled from ${installPath}`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI on Windows:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
error: error instanceof Error ? error.message : "Failed to clear CLI login",
}
}
}

View File

@ -66,24 +66,29 @@ export class IntegrationService extends IpcService {
author: string
publishedAt: string
vaultPath: string
description?: string
},
) {
try {
const { url, title, content, author, publishedAt, vaultPath } = input
const { url, title, content, author, publishedAt, vaultPath, description } = input
const fileName = `${sanitizeFileName(title || publishedAt)
.trim()
.slice(0, 20)}.md`
.slice(0, 80)}.md`
const filePath = path.join(vaultPath, fileName)
const exists = existsSync(filePath)
if (exists) {
return { success: false, error: "File already exists" }
}
await fsp.mkdir(path.dirname(filePath), { recursive: true })
const yamlEscape = (s: string) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
const markdown = `---
url: ${url}
author: ${author}
publishedAt: ${publishedAt}
url: ${yamlEscape(url)}
author: ${yamlEscape(author)}
publishedAt: ${yamlEscape(publishedAt)}${description ? `\ndescription: ${yamlEscape(description)}` : ""}
---
# ${title}

View File

@ -51,6 +51,7 @@ export class SettingService extends IpcService {
@IpcMethod()
setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void {
nativeTheme.themeSource = appearance
store.set("appearance", appearance)
}
@IpcMethod()

View File

@ -0,0 +1,70 @@
import type { Session } from "electron"
import { describe, expect, it, vi } from "vitest"
import {
buildManagedAuthCookieHeader,
buildManagedAuthCookieHeaderFromSetCookieHeader,
getManagedAuthCookieNames,
persistManagedAuthCookiesFromSetCookieHeader,
} from "./auth-cookies"
describe("auth cookies", () => {
it("builds a cookie header from managed auth cookies only", () => {
const header = buildManagedAuthCookieHeader([
{ name: "__Secure-better-auth.session_token", value: "session-token" },
{ name: "two_factor", value: "two-factor-token" },
{ name: "dont_remember", value: "true" },
{ name: "unrelated", value: "ignore-me" },
])
expect(header).toBe(
"__Secure-better-auth.session_token=session-token; two_factor=two-factor-token; dont_remember=true",
)
})
it("includes the two-factor cookie in managed names", () => {
expect(getManagedAuthCookieNames()).toContain("two_factor")
})
it("keeps prefixed two-factor cookies from a set-cookie header", () => {
const header = buildManagedAuthCookieHeaderFromSetCookieHeader(
[
"__Secure-better-auth.two_factor=signed-two-factor; Path=/; HttpOnly; Secure; SameSite=Lax",
"better-auth.last_used_login_method=email; Path=/; HttpOnly; Secure; SameSite=Lax",
].join(", "),
)
expect(header).toBe(
"__Secure-better-auth.two_factor=signed-two-factor; better-auth.last_used_login_method=email",
)
})
it("persists managed auth cookies and removes expired ones from a set-cookie header", async () => {
const set = vi.fn().mockImplementation(async () => {})
const remove = vi.fn().mockImplementation(async () => {})
await persistManagedAuthCookiesFromSetCookieHeader({
apiURL: "https://api.folo.is",
session: {
cookies: { set, remove },
} as unknown as Session,
setCookieHeader: [
"two_factor=two-factor-token; Path=/; HttpOnly; Secure; SameSite=None",
"__Secure-better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=None",
].join(", "),
})
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.folo.is",
name: "two_factor",
value: "two-factor-token",
path: "/",
httpOnly: true,
secure: true,
sameSite: "no_restriction",
}),
)
expect(remove).toHaveBeenCalledWith("https://api.folo.is", "__Secure-better-auth.session_token")
})
})

View File

@ -0,0 +1,237 @@
import type { Cookie, CookiesSetDetails, Session } from "electron"
const MANAGED_AUTH_COOKIE_NAMES = [
"__Secure-better-auth.session_token",
"better-auth.session_token",
"__Secure-better-auth.session_data",
"better-auth.session_data",
"better-auth.last_used_login_method",
"__Secure-better-auth.dont_remember",
"better-auth.dont_remember",
"__Secure-better-auth.trust_device",
"better-auth.trust_device",
"__Secure-better-auth.two_factor",
"better-auth.two_factor",
] as const
type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number]
type ParsedSetCookie = {
domain?: string
expirationDate?: number
httpOnly: boolean
maxAge?: number
name: string
path: string
sameSite?: CookiesSetDetails["sameSite"]
secure: boolean
value: string
}
const MANAGED_AUTH_COOKIE_NAME_SET = new Set<string>(MANAGED_AUTH_COOKIE_NAMES)
const splitSetCookieHeader = (header: string) => {
const parts: string[] = []
let buffer = ""
for (const char of header) {
if (char === ",") {
const recent = buffer.toLowerCase()
const hasExpires = recent.includes("expires=")
const hasGmt = /gmt/i.test(recent)
if (hasExpires && !hasGmt) {
buffer += char
continue
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
buffer = ""
continue
}
buffer += char
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
return parts
}
const parseSameSite = (value: string) => {
switch (value) {
case "Lax": {
return "lax" as const
}
case "Strict": {
return "strict" as const
}
case "None": {
return "no_restriction" as const
}
default: {
return
}
}
}
const parseSetCookieHeader = (header: string): ParsedSetCookie[] => {
return splitSetCookieHeader(header)
.map((cookie) => {
const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim())
const [name, ...valueParts] = nameValue?.split("=") ?? []
if (!name) {
return null
}
const parsedCookie: ParsedSetCookie = {
name,
value: valueParts.join("="),
path: "/",
httpOnly: false,
secure: false,
}
for (const attribute of attributes) {
const [rawKey, ...rawValueParts] = attribute.split("=")
const key = rawKey?.toLowerCase()
const value = rawValueParts.join("=")
switch (key) {
case "domain": {
parsedCookie.domain = value || void 0
break
}
case "expires": {
const expires = new Date(value)
if (!Number.isNaN(expires.getTime())) {
parsedCookie.expirationDate = expires.getTime() / 1000
}
break
}
case "httponly": {
parsedCookie.httpOnly = true
break
}
case "max-age": {
const maxAge = Number.parseInt(value)
if (!Number.isNaN(maxAge)) {
parsedCookie.maxAge = maxAge
}
break
}
case "path": {
parsedCookie.path = value || "/"
break
}
case "samesite": {
parsedCookie.sameSite = parseSameSite(value)
break
}
case "secure": {
parsedCookie.secure = true
break
}
}
}
return parsedCookie
})
.filter((cookie): cookie is ParsedSetCookie => !!cookie)
}
const isManagedAuthCookie = (cookieName: string): cookieName is ManagedAuthCookieName => {
return MANAGED_AUTH_COOKIE_NAME_SET.has(cookieName)
}
const shouldRemoveCookie = (cookie: ParsedSetCookie) => {
if (cookie.maxAge !== undefined) {
return cookie.maxAge <= 0
}
if (cookie.expirationDate !== undefined) {
return cookie.expirationDate <= Date.now() / 1000
}
return false
}
export const getManagedAuthCookieNames = () => {
return [...MANAGED_AUTH_COOKIE_NAMES]
}
export const buildManagedAuthCookieHeaderFromSetCookieHeader = (setCookieHeader: string) => {
if (!setCookieHeader.trim()) {
return ""
}
return parseSetCookieHeader(setCookieHeader)
.filter((cookie) => isManagedAuthCookie(cookie.name))
.filter((cookie) => !shouldRemoveCookie(cookie))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ")
}
export const buildManagedAuthCookieHeader = (cookies: Array<Pick<Cookie, "name" | "value">>) => {
return cookies
.filter((cookie) => isManagedAuthCookie(cookie.name))
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ")
}
export const getManagedAuthCookies = async ({
apiURL,
session,
}: {
apiURL: string
session: Session
}) => {
const { hostname } = new URL(apiURL)
const cookies = await session.cookies.get({ domain: hostname })
return cookies.filter((cookie) => isManagedAuthCookie(cookie.name))
}
export const persistManagedAuthCookiesFromSetCookieHeader = async ({
apiURL,
session,
setCookieHeader,
}: {
apiURL: string
session: Session
setCookieHeader: string
}) => {
if (!setCookieHeader.trim()) {
return
}
const cookies = parseSetCookieHeader(setCookieHeader).filter((cookie) =>
isManagedAuthCookie(cookie.name),
)
await Promise.all(
cookies.map(async (cookie) => {
if (shouldRemoveCookie(cookie)) {
await session.cookies.remove(apiURL, cookie.name)
return
}
const details: CookiesSetDetails = {
url: apiURL,
name: cookie.name,
value: cookie.value,
path: cookie.path,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}),
...(cookie.domain ? { domain: cookie.domain } : {}),
...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}),
}
await session.cookies.set(details)
}),
)
}

View File

@ -0,0 +1,7 @@
export const resolveCliSessionToken = ({
preferredToken,
cookieToken,
}: {
preferredToken?: string
cookieToken?: string
}) => cookieToken || preferredToken

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest"
import { resolveCliSessionToken } from "./cli-login-token"
describe("resolveCliSessionToken", () => {
it("prefers the desktop session cookie token", () => {
expect(
resolveCliSessionToken({
preferredToken: "one-time-token",
cookieToken: "session-token",
}),
).toBe("session-token")
})
it("falls back to the preferred token when no cookie token exists", () => {
expect(
resolveCliSessionToken({
preferredToken: "session-token",
}),
).toBe("session-token")
})
it("returns undefined when neither token is available", () => {
expect(resolveCliSessionToken({})).toBeUndefined()
})
})

View File

@ -1,23 +1,39 @@
import { execFile } from "node:child_process"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { promisify } from "node:util"
import { env } from "@follow/shared/env.desktop"
import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
import { join } from "pathe"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { logger } from "../logger"
import { buildManagedAuthCookieHeader, getManagedAuthCookies } from "./auth-cookies"
import { resolveCliSessionToken } from "./cli-login-token"
const execFileAsync = promisify(execFile)
export const CLI_NPM_PACKAGE_NAME = "folocli"
const CLI_NPX_PACKAGE_SPEC = `${CLI_NPM_PACKAGE_NAME}@latest`
const CLI_CONFIG_DIR = join(homedir(), ".folo")
const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json")
const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx")
interface CliConfig {
const getCliSyncRequestHeaders = (additionalHeaders?: Record<string, string>) => ({
...createDesktopAPIHeaders({ version: PKG.version }),
...createAuthRequestOriginHeaders(env.VITE_WEB_URL),
...additionalHeaders,
})
export interface CliConfig {
token?: string
apiUrl?: string
}
const readCliConfig = async (): Promise<CliConfig> => {
export const readCliConfig = async (): Promise<CliConfig> => {
try {
const raw = await readFile(CLI_CONFIG_PATH, "utf8")
return JSON.parse(raw) as CliConfig
@ -31,6 +47,43 @@ const writeCliConfig = async (config: CliConfig): Promise<void> => {
await writeFile(CLI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8")
}
export const getCliConfigPath = () => CLI_CONFIG_PATH
export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help`
export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <token>`
const runCliCommand = async (args: string[]) => {
await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], {
windowsHide: true,
timeout: 120_000,
maxBuffer: 1024 * 1024,
})
}
export const isCliRunnerAvailable = async (): Promise<boolean> => {
try {
await execFileAsync(getNpxCommand(), ["--version"], {
windowsHide: true,
timeout: 10_000,
maxBuffer: 128 * 1024,
})
return true
} catch {
return false
}
}
const clearCliConfigToken = async () => {
const config = await readCliConfig()
if (!config.token) {
return
}
delete config.token
await writeCliConfig(config)
}
export const getSessionTokenFromCookies = async (): Promise<string | undefined> => {
const window = WindowManager.getMainWindow()
if (!window) return undefined
@ -46,16 +99,122 @@ export const getSessionTokenFromCookies = async (): Promise<string | undefined>
return sessionCookie?.value
}
export const syncSessionToCliConfig = async (token?: string): Promise<void> => {
const config = await readCliConfig()
const generateOneTimeTokenFromCurrentSession = async (): Promise<string | undefined> => {
const window = WindowManager.getMainWindow()
if (!window) return undefined
if (token) {
config.token = token
config.apiUrl = env.VITE_API_URL
} else {
delete config.token
const cookieHeader = buildManagedAuthCookieHeader(
await getManagedAuthCookies({
apiURL: env.VITE_API_URL,
session: window.webContents.session,
}),
)
if (!cookieHeader) {
return undefined
}
await writeCliConfig(config)
logger.info(`CLI config synced (token ${token ? "set" : "cleared"})`)
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/generate`, {
method: "GET",
headers: getCliSyncRequestHeaders({
Cookie: cookieHeader,
}),
})
if (!response.ok) {
return undefined
}
const data = (await response.json().catch(() => null)) as { token?: unknown } | null
return typeof data?.token === "string" ? data.token : undefined
}
const resolveSessionTokenFromOneTimeToken = async (
oneTimeToken: string,
): Promise<string | undefined> => {
const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/apply`, {
method: "POST",
headers: getCliSyncRequestHeaders({
"content-type": "application/json",
}),
body: JSON.stringify({ token: oneTimeToken }),
})
if (!response.ok) {
return undefined
}
const setCookieValues =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: ([response.headers.get("set-cookie")].filter(Boolean) as string[])
for (const setCookie of setCookieValues) {
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
if (match?.[1]) {
return match[1]
}
}
const data = (await response.json().catch(() => null)) as { session?: { token?: unknown } } | null
return typeof data?.session?.token === "string" ? data.session.token : undefined
}
export const getCliSessionToken = async ({
preferredToken,
}: {
preferredToken?: string
} = {}): Promise<string | undefined> => {
const oneTimeToken = await generateOneTimeTokenFromCurrentSession().catch((error) => {
logger.error("Failed to generate one-time token for CLI sync:", error)
return
})
if (oneTimeToken) {
const sessionToken = await resolveSessionTokenFromOneTimeToken(oneTimeToken).catch((error) => {
logger.error("Failed to resolve session token from one-time token:", error)
return
})
if (sessionToken) {
return sessionToken
}
}
return resolveCliSessionToken({
preferredToken,
cookieToken: await getSessionTokenFromCookies(),
})
}
export const syncSessionToCliConfig = async (token?: string): Promise<void> => {
if (token) {
const config = await readCliConfig()
if (config.token === token && config.apiUrl === env.VITE_API_URL) {
return
}
if (!(await isCliRunnerAvailable())) {
throw new Error("npx is not available")
}
await runCliCommand(["login", "--token", token, "--api-url", env.VITE_API_URL])
logger.info("CLI login synced via npx")
return
}
if (await isCliRunnerAvailable()) {
try {
await runCliCommand(["logout"])
logger.info("CLI login cleared via npx")
return
} catch (error) {
logger.error(
"Failed to clear CLI login via npx, falling back to local config cleanup:",
error,
)
}
}
await clearCliConfigToken()
logger.info("CLI login cleared from local config")
}

View File

@ -1,20 +0,0 @@
import { env } from "@follow/shared/env.desktop"
import { OpenPanel } from "@openpanel/web"
import { app } from "electron"
import { DEVICE_ID } from "~/constants/system"
export const op = new OpenPanel({
clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "",
trackScreenViews: false,
trackOutgoingLinks: false,
trackAttributes: false,
trackHashChanges: false,
apiUrl: env.VITE_OPENPANEL_API_URL,
})
op.setGlobalProperties({
device_id: DEVICE_ID,
app_version: app.getVersion(),
build: "electron",
})

View File

@ -49,7 +49,7 @@ class AppManagerStatic {
registerUpdater()
registerAppTray()
// Sync session to CLI config after window and cookies are ready
// Sync the desktop session to the npm CLI after cookies are ready.
setTimeout(async () => {
try {
const token = await getSessionTokenFromCookies()

View File

@ -23,7 +23,6 @@ import { AppManager } from "./app"
const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL
const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [
env.VITE_OPENPANEL_API_URL || "",
IMAGE_PROXY_URL,
env.VITE_API_URL,
"https://readwise.io",

View File

@ -112,6 +112,7 @@
}
}
</script>
<!-- FOLLOW VITE BUILD INJECT -->
<!-- Check Browser Script Inject -->
<script>

View File

@ -28,7 +28,6 @@
"@lexical/markdown": "0.40.0",
"@lexical/react": "0.40.0",
"@number-flow/react": "0.5.11",
"@openpanel/web": "1.0.7",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",

View File

@ -77,13 +77,13 @@ export const useTimelineList = (options?: {
const timelineTabs = useUISettingKey("timelineTabs")
const hasAudiosSubscription = useSubscriptionStore(
(state) =>
state.feedIdByView[FeedViewType.Audios].size > 0 ||
state.listIdByView[FeedViewType.Audios].size > 0,
(state.feedIdByView[FeedViewType.Audios]?.size ?? 0) > 0 ||
(state.listIdByView[FeedViewType.Audios]?.size ?? 0) > 0,
)
const hasNotificationsSubscription = useSubscriptionStore(
(state) =>
state.feedIdByView[FeedViewType.Notifications].size > 0 ||
state.listIdByView[FeedViewType.Notifications].size > 0,
(state.feedIdByView[FeedViewType.Notifications]?.size ?? 0) > 0 ||
(state.listIdByView[FeedViewType.Notifications]?.size ?? 0) > 0,
)
const { visible, hidden } = useMemo(

View File

@ -16,6 +16,14 @@ export const useRecaptchaToken = () => {
return e2eToken
}
if (
navigator.webdriver ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1"
) {
return "e2e-token"
}
if (!executeRecaptcha) {
return null
}

View File

@ -1,10 +0,0 @@
import { env } from "@follow/shared/env.desktop"
import { OpenPanel } from "@openpanel/web"
export const op = new OpenPanel({
clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "",
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
apiUrl: env.VITE_OPENPANEL_API_URL,
})

View File

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest"
import { createPdfFileName, exportPageAsPdf } from "../export"
describe("createPdfFileName", () => {
it("sanitizes invalid filename characters", () => {
expect(createPdfFileName('A/B:C*D?E"F<G>H|I')).toBe("A B C D E F G H I.pdf")
})
it("falls back to Untitled when title is empty", () => {
expect(createPdfFileName(" ")).toBe("Untitled.pdf")
})
})
describe("exportPageAsPdf", () => {
it("uses Electron PDF export when running in Electron", async () => {
const print = vi.fn()
const exportAsPdf = vi.fn().mockResolvedValue("/tmp/Article.pdf")
await exportPageAsPdf({
title: "Article:/Title",
isElectron: true,
print,
exportAsPdf,
})
expect(exportAsPdf).toHaveBeenCalledWith({ defaultPath: "Article Title.pdf" })
expect(print).not.toHaveBeenCalled()
})
it("falls back to window.print in browser mode", async () => {
const print = vi.fn()
const exportAsPdf = vi.fn()
await exportPageAsPdf({
title: "Article Title",
isElectron: false,
print,
exportAsPdf,
})
expect(print).toHaveBeenCalledOnce()
expect(exportAsPdf).not.toHaveBeenCalled()
})
})

View File

@ -1,3 +1,4 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { whoami } from "@follow/store/user/getters"
import { userActions } from "@follow/store/user/store"
@ -8,7 +9,7 @@ import PKG from "@pkg"
import { NetworkStatus, setApiStatus } from "~/atoms/network"
import { setLoginModalShow } from "~/atoms/user"
import { getClientId, getSessionId } from "./client-session"
import { getAuthSessionToken, getClientId, getSessionId } from "./client-session"
export const followClient = new FollowClient({
credentials: "include",
@ -24,16 +25,24 @@ export const followClient = new FollowClient({
export const followApi = followClient.api
followClient.addRequestInterceptor(async (ctx) => {
const { options } = ctx
const header = options.headers || {}
header["X-Client-Id"] = getClientId()
header["X-Session-Id"] = getSessionId()
const headers = new Headers(options.headers)
headers.set("X-Client-Id", getClientId())
headers.set("X-Session-Id", getSessionId())
const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null
if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) {
headers.set(
"Cookie",
`__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`,
)
}
const apiHeader = createDesktopAPIHeaders({ version: PKG.version })
Object.entries(apiHeader).forEach(([key, value]) => {
headers.set(key, value)
})
options.headers = {
...header,
...apiHeader,
}
options.headers = Object.fromEntries(headers.entries())
return ctx
})
@ -59,7 +68,9 @@ followClient.addErrorInterceptor(async ({ error, response }) => {
followClient.addResponseInterceptor(async ({ response }) => {
if (response.status === 401) {
const shouldPromptForLogin = response.url.includes("/better-auth/get-session") || !whoami()
const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null
const shouldPromptForLogin =
response.url.includes("/better-auth/get-session") || (!whoami() && !authSessionToken)
if (!shouldPromptForLogin) {
return response

View File

@ -32,7 +32,6 @@ export const {
changeEmail,
changePassword,
deleteUserCustom,
forgetPassword,
getAccountInfo,
getProviders,
getSession,
@ -50,4 +49,6 @@ export const {
updateUser,
} = auth.authClient
export const forgetPassword = auth.authClient.requestPasswordReset
export const { loginHandler } = auth

View File

@ -1,3 +1,23 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { ipcServices } from "./client"
const PDF_EXTENSION = ".pdf"
const sanitizeFileName = (value: string) => {
return Array.from(value)
.map((character) => {
const charCode = character.codePointAt(0) ?? 0
if (`<>:"/\\|?*`.includes(character) || charCode <= 31) {
return " "
}
return character
})
.join("")
}
export const downloadJsonFile = (content: string, filename: string) => {
const blob = new Blob([content], { type: "application/json" })
const url = URL.createObjectURL(blob)
@ -32,3 +52,41 @@ export const selectJsonFile = (): Promise<string> => {
input.click()
})
}
export const createPdfFileName = (title?: string) => {
const sanitizedTitle = title?.trim()
? sanitizeFileName(title.trim()).replaceAll(/\s+/g, " ").trim()
: ""
const baseName = sanitizedTitle || "Untitled"
return baseName.toLowerCase().endsWith(PDF_EXTENSION) ? baseName : `${baseName}${PDF_EXTENSION}`
}
interface ExportPageAsPdfOptions {
title?: string
isElectron?: boolean
print?: () => void
exportAsPdf?: (input: { defaultPath: string }) => Promise<string | null>
}
export const exportPageAsPdf = async ({
title,
isElectron = IN_ELECTRON,
print = () => {
window.print()
},
exportAsPdf = async (input) => {
if (!ipcServices) {
throw new Error("Electron IPC is not available")
}
return ipcServices.app.exportCurrentPageAsPdf(input)
},
}: ExportPageAsPdfOptions = {}) => {
if (isElectron) {
return exportAsPdf({ defaultPath: createPdfFileName(title) })
}
print()
return null
}

View File

@ -51,26 +51,8 @@ export const getLevelMultiplier = (level: number) => {
if (level === 0) {
return 0.1
}
const serverConfigs = getServerConfigs()
if (!serverConfigs) {
return 1
}
const level1Range = serverConfigs?.LEVEL_PERCENTAGES[3]! - serverConfigs?.LEVEL_PERCENTAGES[2]!
const percentageIndex = serverConfigs.LEVEL_PERCENTAGES.length - level
let levelCurrentRange
if (percentageIndex - 1 < 0) {
levelCurrentRange = serverConfigs?.LEVEL_PERCENTAGES[percentageIndex]
} else {
levelCurrentRange =
serverConfigs?.LEVEL_PERCENTAGES[percentageIndex]! -
serverConfigs?.LEVEL_PERCENTAGES[percentageIndex - 1]!
}
const rangeMultiplier = levelCurrentRange / level1Range
const poolMultiplier =
serverConfigs?.DAILY_POWER_PERCENTAGES[level]! / serverConfigs?.DAILY_POWER_PERCENTAGES[1]!
return (poolMultiplier / rangeMultiplier).toFixed(0)
return 1
}
export const getBlockchainExplorerUrl = () => {

View File

@ -12,8 +12,8 @@ import { actionActions } from "@follow/store/action/store"
import { nextFrame } from "@follow/utils"
import { JsonObfuscatedCodec } from "@follow/utils/json-codec"
import { cn } from "@follow/utils/utils"
import { repository } from "@pkg"
import { useQueryClient } from "@tanstack/react-query"
import { m } from "motion/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useBlocker } from "react-router"
@ -44,15 +44,14 @@ import {
import { useSetSubViewRightView } from "../app-layout/subview/hooks"
import { generateExportFilename } from "./utils"
const EmptyActionPlaceholder = () => {
const { t } = useTranslation("settings")
const EmptyActionPlaceholder = ({ onCreateRule }: { onCreateRule: () => void }) => {
const { t } = useTranslation(["settings", "common"])
return (
<div className="relative flex min-h-96 w-full items-center justify-center">
<div className="flex flex-col items-center gap-6 text-center">
{/* Simple icon */}
<div className="flex size-14 items-center justify-center rounded-lg border border-fill-secondary bg-fill-quinary">
<i className="i-mgc-magic-2-cute-re size-7 text-text-secondary" />
<div className="flex min-h-96 w-full items-center justify-center py-10">
<div className="flex w-full max-w-xl flex-col items-center gap-6 rounded-3xl border border-fill-secondary bg-material-ultra-thin px-8 py-10 text-center shadow-sm">
<div className="flex size-16 items-center justify-center rounded-2xl border border-fill-secondary bg-fill-quinary">
<i className="i-mgc-magic-2-cute-re size-8 text-text-secondary" />
</div>
<div className="space-y-2">
@ -63,25 +62,23 @@ const EmptyActionPlaceholder = () => {
{t("actions.action_card.empty.description")}
</p>
</div>
</div>
<m.div
className="fixed right-20 top-12 z-[1000]"
animate={{
x: [0, 8, 0],
y: [0, -4, 0],
opacity: [0.5, 1, 0.5],
}}
transition={{
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
}}
>
<div className="flex items-center gap-2 text-text-secondary">
<span className="text-sm font-medium">{t("actions.action_card.empty.start")}</span>
<i className="i-mgc-arrow-right-up-cute-re size-5" />
<div className="flex flex-wrap items-center justify-center gap-3">
<Button onClick={onCreateRule}>
<i className="i-mgc-add-cute-re mr-2 size-4" />
{t("actions.action_card.empty.cta")}
</Button>
<a
href={`${repository.url}/wiki/Actions`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-fill-secondary hover:text-text"
>
<i className="i-mgc-book-6-cute-re size-4" />
<span>{t("words.documentation", { ns: "common" })}</span>
</a>
</div>
</m.div>
</div>
</div>
)
}
@ -164,7 +161,7 @@ export const ActionSetting = () => {
</div>
</div>
) : (
<EmptyActionPlaceholder />
<EmptyActionPlaceholder onCreateRule={handleCreateRule} />
)}
</>
)

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest"
import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles"
describe("getBottomPanelContainerStyle", () => {
it("returns no extra transform after the chat has messages", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: true,
visualOffsetY: "clamp(-10vh, -8vh, -6vh)",
}),
).toBeUndefined()
})
it("uses the base centered transform when no visual offset is provided", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: false,
}),
).toEqual({
transform: "translateY(calc(100% + 1rem))",
})
})
it("merges the visual offset into the centered transform", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: false,
visualOffsetY: "clamp(-10vh, -8vh, -6vh)",
}),
).toEqual({
transform: "translateY(calc(100% + 1rem + clamp(-10vh, -8vh, -6vh)))",
})
})
})

View File

@ -0,0 +1,32 @@
import type { CSSProperties } from "react"
const EMPTY_BOTTOM_PANEL_BASE_OFFSET = "100% + 1rem"
const formatVisualOffsetY = (visualOffsetY: string | number) =>
typeof visualOffsetY === "number" ? `${visualOffsetY}px` : visualOffsetY
interface BottomPanelContainerStyleOptions {
centerInputOnEmpty?: boolean
hasMessages: boolean
visualOffsetY?: string | number
}
export const getBottomPanelContainerStyle = ({
centerInputOnEmpty,
hasMessages,
visualOffsetY,
}: BottomPanelContainerStyleOptions): CSSProperties | undefined => {
if (!centerInputOnEmpty || hasMessages) {
return undefined
}
if (visualOffsetY == null) {
return {
transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET}))`,
}
}
return {
transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET} + ${formatVisualOffsetY(visualOffsetY)}))`,
}
}

View File

@ -10,10 +10,12 @@ import { RateLimitNotice } from "~/modules/ai-chat/components/layouts/RateLimitN
import type { ShortcutData } from "../../editor"
import { useSendAIShortcut } from "../../hooks/useSendAIShortcut"
import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles"
interface ChatBottomPanelProps {
hasMessages: boolean
centerInputOnEmpty?: boolean
visualOffsetY?: string | number
shouldShowInterruptionNotice: boolean
rateLimitMessage: string | null
isRateLimited: boolean
@ -27,6 +29,7 @@ interface ChatBottomPanelProps {
export const ChatBottomPanel = ({
hasMessages,
centerInputOnEmpty,
visualOffsetY,
shouldShowInterruptionNotice,
rateLimitMessage,
isRateLimited,
@ -39,6 +42,11 @@ export const ChatBottomPanel = ({
const panelRef = useRef<HTMLDivElement | null>(null)
const t = useI18n()
const { sendAIShortcut } = useSendAIShortcut()
const containerStyle = getBottomPanelContainerStyle({
centerInputOnEmpty,
hasMessages,
visualOffsetY,
})
useLayoutEffect(() => {
const element = panelRef.current
@ -83,10 +91,9 @@ export const ChatBottomPanel = ({
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
"inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
centerInputOnEmpty &&
!hasMessages &&
"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200",
centerInputOnEmpty && !hasMessages && "bottom-1/2 duration-200",
)}
style={containerStyle}
>
{shouldShowInterruptionNotice && (
<m.div

View File

@ -54,7 +54,7 @@ import { ChatBottomPanel } from "./ChatBottomPanel"
import { ChatMessageContainer } from "./ChatMessageContainer"
const draftMessages = new Map<string, EditorState>()
const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfaceProps) => {
const hasMessages = useHasMessages()
const status = useChatStatus()
const chatActions = useChatActions()
@ -288,6 +288,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
<ChatBottomPanel
hasMessages={hasMessages}
centerInputOnEmpty={centerInputOnEmpty}
visualOffsetY={visualOffsetY}
shouldShowInterruptionNotice={shouldShowInterruptionNotice}
rateLimitMessage={rateLimitMessage}
isRateLimited={isRateLimited}

View File

@ -14,7 +14,7 @@ import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
@ -37,25 +37,44 @@ const getAuthTokenFromResult = (result: unknown) => {
return null
}
if ("token" in result && typeof result.token === "string") {
return result.token
}
if ("sessionToken" in result && typeof result.sessionToken === "string") {
return result.sessionToken
}
if ("token" in result && typeof result.token === "string") {
return result.token
if ("session" in result && result.session && typeof result.session === "object") {
const { token } = result.session as { token?: unknown }
if (typeof token === "string") {
return token
}
}
if (
"data" in result &&
result.data &&
typeof result.data === "object" &&
("sessionToken" in result.data || "token" in result.data)
("sessionToken" in result.data || "token" in result.data || "session" in result.data)
) {
const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown }
if (typeof sessionToken === "string") {
return sessionToken
const { sessionToken, token, session } = result.data as {
sessionToken?: unknown
token?: unknown
session?: { token?: unknown } | unknown
}
return typeof token === "string" ? token : null
if (typeof token === "string") {
return token
}
if (
session &&
typeof session === "object" &&
"token" in session &&
typeof session.token === "string"
) {
return session.token
}
return typeof sessionToken === "string" ? sessionToken : null
}
return null
@ -74,7 +93,15 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => {
return {}
}
return result as ElectronAuthResult
const normalized = result as ElectronAuthResult & Record<string, unknown>
if ("data" in normalized || "error" in normalized) {
return normalized
}
return {
data: normalized,
error: null,
}
}
const setElectronSessionToken = async (token: string) => {
@ -110,6 +137,11 @@ const getElectronAuthService = () => {
callbackURL: string
headers?: Record<string, string>
}) => Promise<unknown>
verifyTotp?: (payload: {
code: string
trustDevice?: boolean
headers?: Record<string, string>
}) => Promise<unknown>
}
}
@ -183,9 +215,24 @@ export function LoginWithPassword({
return (
<TOTPForm
onSubmitMutationFn={async (values) => {
const { data, error } = await twoFactor.verifyTotp({ code: values.code })
if (!data || error) {
throw new Error(error?.message ?? "Invalid TOTP code")
const result = IN_ELECTRON
? normalizeElectronAuthResult(
await getElectronAuthService()?.verifyTotp?.({
code: values.code,
}),
)
: await twoFactor.verifyTotp({ code: values.code })
if (!result?.data || result.error) {
throw new Error(result.error?.message ?? "Invalid TOTP code")
}
if (IN_ELECTRON) {
const token = getAuthTokenFromResult(result)
if (token) {
setAuthSessionToken(token)
await setElectronSessionToken(token)
}
}
}}
onSuccess={() => {
@ -200,7 +247,7 @@ export function LoginWithPassword({
const token = getAuthTokenFromResult(res)
if (token) {
setAuthSessionToken(token)
void setElectronSessionToken(token)
await setElectronSessionToken(token)
}
}
handleSessionChanges()
@ -217,7 +264,16 @@ export function LoginWithPassword({
<FormItem>
<FormLabel>{t("login.email")}</FormLabel>
<FormControl>
<Input data-testid="login-email-input" type="email" {...field} />
<Input
data-testid="login-email-input"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -245,7 +301,15 @@ export function LoginWithPassword({
</a>
</FormLabel>
<FormControl>
<Input data-testid="login-password-input" type="password" {...field} />
<Input
data-testid="login-password-input"
type="password"
autoCapitalize="none"
autoComplete={IN_ELECTRON ? "current-password" : "new-password"}
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -268,17 +332,21 @@ export function LoginWithPassword({
<Divider className="my-4" />
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you don't have an account,{" "}
<button
data-testid="login-switch-register"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("register")}
>
Sign up
<i className="i-mgc-right-cute-fi !text-text" />
</button>
<div className="pb-2 text-center text-sm text-text-secondary">
<Trans
t={t}
i18nKey="login.no_account"
components={{
strong: (
<button
data-testid="login-switch-register"
type="button"
className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={() => onLoginStateChange("register")}
/>
),
}}
/>
</div>
</Form>
)
@ -351,18 +419,20 @@ export function RegisterForm({
headers,
}),
)
: await signUp.email({
email: values.email,
password: values.password,
name: values.email.split("@")[0]!,
callbackURL: "/",
fetchOptions: {
: await signUp.email(
{
email: values.email,
password: values.password,
name: values.email.split("@")[0]!,
callbackURL: "/",
},
{
onError(context) {
toast.error(context.error.message)
},
headers,
},
})
)
if (result?.error) {
return result
@ -372,7 +442,7 @@ export function RegisterForm({
const token = getAuthTokenFromResult(result)
if (token) {
setAuthSessionToken(token)
void setElectronSessionToken(token)
await setElectronSessionToken(token)
}
}
@ -396,7 +466,16 @@ export function RegisterForm({
<FormItem>
<FormLabel>{t("register.email")}</FormLabel>
<FormControl>
<Input data-testid="register-email-input" type="email" {...field} />
<Input
data-testid="register-email-input"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -413,7 +492,15 @@ export function RegisterForm({
: `${t("register.password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input data-testid="register-password-input" type="password" {...field} />
<Input
data-testid="register-password-input"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -430,7 +517,15 @@ export function RegisterForm({
: `${t("register.confirm_password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input data-testid="register-confirm-password-input" type="password" {...field} />
<Input
data-testid="register-confirm-password-input"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -452,17 +547,21 @@ export function RegisterForm({
</Form>
<Divider className="my-4" />
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you already have an account,{" "}
<button
data-testid="register-switch-login"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("login")}
>
Sign in
<i className="i-mgc-right-cute-fi !text-text" />
</button>
<div className="pb-2 text-center text-sm text-text-secondary">
<Trans
t={t}
i18nKey="login.have_account"
components={{
strong: (
<button
data-testid="register-switch-login"
type="button"
className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={() => onLoginStateChange("login")}
/>
),
}}
/>
</div>
</div>
)

View File

@ -31,7 +31,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
const { canClose = true, runtime } = props
const { t } = useTranslation()
const { t } = useTranslation(["app", "common"])
const { data: authProviders, isLoading } = useAuthProviders()
const { status } = useSession()
@ -159,10 +159,11 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{!IN_ELECTRON && (
<button
type="button"
className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent hover:bg-fill/20"
aria-label={t("words.close", { ns: "common" })}
className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent transition-colors hover:bg-fill/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={modal.dismiss}
>
<i className="i-mgc-close-cute-re size-4" />
<i aria-hidden className="i-mgc-close-cute-re pointer-events-none size-4" />
</button>
)}
{isEmail ? (
@ -181,13 +182,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div className="flex flex-col gap-4">
{/* Login Providers */}
<div className="flex flex-col gap-2.5">
{visibleProviders.map(([key, provider], index) => (
<m.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
{visibleProviders.map(([key, provider]) => (
<div key={key}>
<button
data-testid={`login-provider-${key}`}
type="button"
@ -198,28 +194,32 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
loginHandler(key, "app")
}
}}
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-all duration-200 hover:border-folo/30 hover:bg-folo/10"
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-colors duration-200 hover:border-folo/30 hover:bg-folo/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{provider.icon64 ? (
<img
className={cn(
"absolute left-7 size-5 object-contain",
"pointer-events-none absolute left-7 size-5 object-contain",
!provider.iconDark64 &&
"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert",
)}
src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}
alt={provider.name}
alt=""
aria-hidden="true"
/>
) : (
<i className="i-mgc-mail-cute-re absolute left-7 size-5 text-text-secondary" />
<i
aria-hidden
className="i-mgc-mail-cute-re pointer-events-none absolute left-7 size-5 text-text-secondary"
/>
)}
<span className="relative z-10">
<span className="pointer-events-none relative z-10">
{t("login.continueWith", { provider: provider.name })}
</span>
{lastMethod === key && (
<m.div
className="absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
className="pointer-events-none absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.presets.bouncy}
@ -228,7 +228,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
</m.div>
)}
</button>
</m.div>
</div>
))}
</div>
@ -238,9 +238,9 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenToken()}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
<i className="i-mgc-key-2-cute-re size-3.5" />
<i aria-hidden className="i-mgc-key-2-cute-re size-3.5" />
<span>{t("login.enter_token")}</span>
</button>
</div>
@ -249,7 +249,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenLegal("tos")}
className="text-accent transition-colors hover:text-accent/80 hover:underline"
className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{t("login.terms")}
</button>
@ -257,7 +257,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenLegal("privacy")}
className="text-accent transition-colors hover:text-accent/80 hover:underline"
className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{t("login.privacy")}
</button>

View File

@ -69,6 +69,9 @@ export const TokenModalContent = () => {
autoFocus
className="mt-1 dark:text-zinc-200"
placeholder="folo://auth?token=xxx"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>

View File

@ -23,6 +23,7 @@ import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { copyToClipboard } from "~/lib/clipboard"
import { exportPageAsPdf } from "~/lib/export"
import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll"
import { useGalleryModal } from "~/modules/entry-content/hooks"
import { playEntryTts } from "~/modules/player/entry-tts"
@ -177,7 +178,9 @@ export const useRegisterEntryCommands = () => {
return
}
window.print()
void exportPageAsPdf({ title: entry.title || entry.url || undefined }).catch(() => {
toast.error("Failed to export as pdf", { duration: 3000 })
})
},
},
{

View File

@ -11,6 +11,7 @@ import {
import { IN_ELECTRON } from "@follow/shared/constants"
import { getEntry } from "@follow/store/entry/getter"
import type { EntryModel } from "@follow/store/entry/types"
import { getFeedById } from "@follow/store/feed/getter"
import { getSummary } from "@follow/store/summary/getters"
import { tracker } from "@follow/tracker"
import { useMutation, useQuery } from "@tanstack/react-query"
@ -282,6 +283,7 @@ const useRegisterObsidianCommands = () => {
author: string
publishedAt: string
vaultPath: string
description?: string
}) => {
return await ipcServices?.integration.saveToObsidian(data)
},
@ -321,9 +323,10 @@ const useRegisterObsidianCommands = () => {
url: entry.url || "",
title: entry.title || "",
content: markdownContent,
author: entry.author || "",
author: entry.author || getFeedById(entry.feedId)?.title || "",
publishedAt: entry.publishedAt.toISOString() || "",
vaultPath: obsidianVaultPath,
description: getDescription(entry),
})
},
}),

View File

@ -42,9 +42,8 @@ export function DiscoveryContent() {
}
return (
<div className="relative mx-auto w-full max-w-[800px] space-y-6">
{/* Segment Toggle - Centered */}
<div className="relative flex justify-center">
<div className="relative mx-auto w-full max-w-[880px] space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<SegmentGroup
value={activeView}
onValueChanged={(value) => setActiveView(value as DiscoveryView)}
@ -70,28 +69,23 @@ export function DiscoveryContent() {
/>
</SegmentGroup>
{/* Filters Bar - Inside Content Area */}
<div className="absolute right-0 flex items-center justify-end gap-4">
<div className="flex items-center gap-2">
<span className="shrink-0 text-sm font-medium text-text-secondary">
{t("words.language")}:
</span>
<ResponsiveSelect
value={lang}
onValueChange={handleLangChange}
triggerClassName="h-8 rounded border-0"
size="sm"
items={LanguageOptions}
renderItem={(item) => tCommon(item.label as any)}
renderValue={(item) => tCommon(item.label as any)}
/>
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 text-sm font-medium text-text-secondary">
{t("words.language")}:
</span>
<ResponsiveSelect
value={lang}
onValueChange={handleLangChange}
triggerClassName="h-8 rounded border-0 bg-material-ultra-thin"
size="sm"
items={LanguageOptions}
renderItem={(item) => tCommon(item.label as any)}
renderValue={(item) => tCommon(item.label as any)}
/>
</div>
</div>
{/* Content Area with Filters */}
<div className="min-h-[400px]">
{/* Content */}
<div className="min-h-[400px] rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm">
{activeView === "trending" ? (
<Trending center limit={20} hideHeader />
) : (

View File

@ -301,7 +301,7 @@ export function UnifiedDiscoverForm() {
className="w-full max-w-2xl"
data-testid="discover-form"
>
<div className="p-6">
<div className="rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm">
<FormField
control={form.control}
name="keyword"

View File

@ -9,7 +9,7 @@ import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js"
import { Masonry } from "@follow/components/ui/masonry/index.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { useRefValue } from "@follow/hooks"
import { useRefValue, useScrollMarkReadGracePeriod } from "@follow/hooks"
import { getEntry } from "@follow/store/entry/getter"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { clsx } from "@follow/utils/utils"
@ -36,6 +36,7 @@ import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContaine
import type { StoreImageType } from "~/store/image"
import { imageActions } from "~/store/image"
import { useEntriesState } from "../context/EntriesContext"
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
import { PictureWaterFallItem } from "./picture-item"
@ -48,6 +49,10 @@ const gutter = 24
export const PictureMasonry: FC<MasonryProps> = (props) => {
const { data } = props
const entriesState = useEntriesState()
const pauseScrollMarkRead = useScrollMarkReadGracePeriod(
entriesState.isFetching && !entriesState.isFetchingNextPage,
)
const cacheMap = useState(() => new Map<string, object>())[0]
const [isInitDim, setIsInitDim] = useState(false)
const [isInitLayout, setIsInitLayout] = useState(false)
@ -155,6 +160,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
function scrollOutViewMarkRead(entries: IntersectionObserverEntry[]) {
if (!scrollMarkRead) return
if (pauseScrollMarkRead) return
if (!scrollElement) return
let minimumIndex = Number.MAX_SAFE_INTEGER
entries.forEach((entry) => {
@ -210,7 +216,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
return () => {
observer.disconnect()
}
}, [scrollElement, renderMarkRead, scrollMarkRead, dataRef])
}, [dataRef, pauseScrollMarkRead, renderMarkRead, scrollElement, scrollMarkRead])
const [firstScreenReady, setFirstScreenReady] = useState(false)
useEffect(() => {

View File

@ -152,7 +152,7 @@ const VirtualGridImpl: FC<
const rowVirtualizer = useVirtualizer({
count: rows.length + (hasNextPage ? 1 : 0) + (Footer ? 1 : 0),
estimateSize: () => {
return columns[0]! / ratioMap[view] + (!isImageOnly ? 58 : 0)
return columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
},
overscan: 5,
gap: 8,

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest"
import { getVisibleLocalEntryIds } from "./filter-local-entry-ids"
describe("getVisibleLocalEntryIds", () => {
it("keeps previously visible unread entries when they turn read locally", () => {
expect(
getVisibleLocalEntryIds({
sourceIds: ["entry-1", "entry-2"],
entries: {
"entry-1": { id: "entry-1", read: true },
"entry-2": { id: "entry-2", read: false },
},
stickyVisibleIds: new Set(["entry-1", "entry-2"]),
unreadOnly: true,
}),
).toEqual(["entry-1", "entry-2"])
})
it("filters read entries that were not previously visible", () => {
expect(
getVisibleLocalEntryIds({
sourceIds: ["entry-1", "entry-2"],
entries: {
"entry-1": { id: "entry-1", read: true },
"entry-2": { id: "entry-2", read: false },
},
stickyVisibleIds: new Set<string>(),
unreadOnly: true,
}),
).toEqual(["entry-2"])
})
it("removes sticky entries once they leave the source list", () => {
expect(
getVisibleLocalEntryIds({
sourceIds: ["entry-2"],
entries: {
"entry-1": { id: "entry-1", read: true },
"entry-2": { id: "entry-2", read: false },
},
stickyVisibleIds: new Set(["entry-1", "entry-2"]),
unreadOnly: true,
}),
).toEqual(["entry-2"])
})
})

View File

@ -0,0 +1,27 @@
type LocalEntryVisibility = {
id: string
read?: boolean | null
}
export const getVisibleLocalEntryIds = <TEntry extends LocalEntryVisibility>({
sourceIds,
entries,
stickyVisibleIds,
unreadOnly,
}: {
sourceIds: string[]
entries: Record<string, TEntry | null | undefined>
stickyVisibleIds?: ReadonlySet<string>
unreadOnly: boolean
}) => {
return sourceIds.filter((id) => {
const entry = entries[id]
if (!entry) return false
if (unreadOnly && !!entry.read && !stickyVisibleIds?.has(entry.id)) {
return false
}
return true
})
}

View File

@ -19,7 +19,7 @@ import { isBizId } from "@follow/utils/utils"
import { useMutation } from "@tanstack/react-query"
import { debounce } from "es-toolkit/compat"
import { useAtomValue } from "jotai"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { ROUTE_FEED_PENDING } from "~/constants/app"
@ -27,6 +27,7 @@ import { useFeature } from "~/hooks/biz/useFeature"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { aiTimelineEnabledAtom } from "../atoms/ai-timeline"
import { getVisibleLocalEntryIds } from "./filter-local-entry-ids"
import { useIsPreviewFeed } from "./useIsPreviewFeed"
const useRemoteEntries = (): UseEntriesReturn => {
@ -138,6 +139,18 @@ const useLocalEntries = (): UseEntriesReturn => {
!inboxId &&
!listId
const localQueryKey = useMemo(
() => [feedId || "", view, inboxId || "", listId || "", isCollection ? "1" : "0"].join(":"),
[feedId, inboxId, isCollection, listId, view],
)
const stickyVisibleStateRef = useRef<{
queryKey: string
ids: Set<string>
}>({
queryKey: localQueryKey,
ids: new Set<string>(),
})
const allEntries = useEntryStore(
useCallback(
(state) => {
@ -152,16 +165,17 @@ const useLocalEntries = (): UseEntriesReturn => {
entryIdsByInboxId,
) ?? [])
return ids
.map((id) => {
const entry = state.data[id]
if (!entry) return null
if (unreadOnly && entry.read) {
return null
}
return entry.id
})
.filter((id) => typeof id === "string")
const stickyVisibleIds =
unreadOnly && stickyVisibleStateRef.current.queryKey === localQueryKey
? stickyVisibleStateRef.current.ids
: undefined
return getVisibleLocalEntryIds({
sourceIds: ids,
entries: state.data,
stickyVisibleIds,
unreadOnly,
})
},
[
entryIdsByCategory,
@ -171,12 +185,20 @@ const useLocalEntries = (): UseEntriesReturn => {
entryIdsByListId,
entryIdsByView,
isCollection,
localQueryKey,
showEntriesByView,
unreadOnly,
],
),
)
useEffect(() => {
stickyVisibleStateRef.current = {
queryKey: localQueryKey,
ids: unreadOnly ? new Set(allEntries) : new Set<string>(),
}
}, [allEntries, localQueryKey, unreadOnly])
const [page, setPage] = useState(0)
const pageSize = 30
const totalPage = useMemo(

View File

@ -2,46 +2,72 @@ import { getView } from "@follow/constants"
import { entryActions } from "@follow/store/entry/store"
import { unreadSyncService } from "@follow/store/unread/store"
import type { Range } from "@tanstack/react-virtual"
import { useMemo } from "react"
import { useEffect, useMemo, useRef } from "react"
import { useEventCallback } from "usehooks-ts"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export const useEntryMarkReadHandler = (entriesIds: string[]) => {
type EntryMarkReadHandler = (range: Range, enabled?: boolean) => void
export const useEntryMarkReadHandler = (
entriesIds: string[],
{ pauseScrollMarkRead = false }: { pauseScrollMarkRead?: boolean } = {},
) => {
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
const feedView = useRouteParamsSelector((params) => params.view)
const processedEntryIds = useMemo(() => new Set<string>(), [entriesIds])
const processedEntryIds = useRef(new Set<string>())
const handleRenderAsRead = useEventCallback(
useEffect(() => {
processedEntryIds.current.clear()
}, [entriesIds])
const handleRangeMarkRead = useEventCallback(
({ startIndex, endIndex }: Range, enabled?: boolean) => {
if (!enabled) return
const idSlice = entriesIds?.slice(startIndex, endIndex)
if (!idSlice) return
// Filter out entries that have already been processed
const newEntries = idSlice.filter((id) => !processedEntryIds.has(id))
const newEntries = idSlice.filter((id) => !processedEntryIds.current.has(id))
if (newEntries.length === 0) return
// Mark these entries as processed to avoid duplicate processing
newEntries.forEach((id) => processedEntryIds.add(id))
newEntries.forEach((id) => processedEntryIds.current.add(id))
batchMarkRead(newEntries)
},
)
return useMemo(() => {
if (getView(feedView)?.wideMode && renderAsRead) {
return handleRenderAsRead
const handleScrollMarkRead = useEventCallback((range: Range, enabled?: boolean) => {
if (pauseScrollMarkRead) return
handleRangeMarkRead(range, enabled)
})
const renderMarkReadHandler = useMemo<EntryMarkReadHandler | undefined>(() => {
if (!getView(feedView)?.wideMode || !renderAsRead) {
return
}
if (scrollMarkUnread) {
return handleRenderAsRead
return handleRangeMarkRead
}, [feedView, handleRangeMarkRead, renderAsRead])
const scrollMarkReadHandler = useMemo<EntryMarkReadHandler | undefined>(() => {
if (!scrollMarkUnread) {
return
}
return
}, [feedView, handleRenderAsRead, renderAsRead, scrollMarkUnread])
return handleScrollMarkRead
}, [handleScrollMarkRead, scrollMarkUnread])
return useMemo(() => {
return {
handleRenderMarkRead: renderMarkReadHandler,
handleScrollMarkRead: scrollMarkReadHandler,
}
}, [renderMarkReadHandler, scrollMarkReadHandler])
}
export function batchMarkRead(ids: string[]) {

View File

@ -1,5 +1,5 @@
import { FeedViewType, getView } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
@ -80,8 +80,12 @@ function EntryColumnContent() {
}, [activeEntryId, entry?.feedId, isCollection, isPendingEntry, isLoggedIn])
const isInteracted = useRef(false)
const isRefreshing = state.isFetching && !state.isFetchingNextPage
const pauseScrollMarkRead = useScrollMarkReadGracePeriod(isRefreshing)
const handleMarkReadInRange = useEntryMarkReadHandler(entriesIds)
const { handleRenderMarkRead, handleScrollMarkRead } = useEntryMarkReadHandler(entriesIds, {
pauseScrollMarkRead,
})
const handleScroll = useCallback(() => {
if (!isInteracted.current) {
@ -92,7 +96,7 @@ function EntryColumnContent() {
const [first, second] = rangeQueueRef.current
if (first && second && second.startIndex - first.startIndex > 0) {
handleMarkReadInRange?.(
handleScrollMarkRead?.(
{
startIndex: first.startIndex,
endIndex: second.startIndex,
@ -100,7 +104,7 @@ function EntryColumnContent() {
isInteracted.current,
)
}
}, [handleMarkReadInRange, routeFeedId])
}, [handleScrollMarkRead, routeFeedId])
const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond()
const handleCombinedScroll = useCallback(
@ -114,7 +118,6 @@ function EntryColumnContent() {
const navigate = useNavigateEntry()
const rangeQueueRef = useRef<Range[]>([])
const isRefreshing = state.isFetching && !state.isFetchingNextPage
const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom)
const showAiTimelineLoading = aiTimelineEnabled && state.isLoading && !state.isFetchingNextPage
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
@ -134,9 +137,9 @@ function EntryColumnContent() {
return
}
// For gird, render as mark read logic
handleMarkReadInRange?.(e, isInteracted.current)
handleRenderMarkRead?.(e, isInteracted.current)
},
[handleMarkReadInRange, renderAsRead, view],
[handleRenderMarkRead, renderAsRead, view],
)
const fetchNextPage = useCallback(() => {

View File

@ -5,7 +5,7 @@ import {
useMasonryItemWidth,
} from "@follow/components/ui/masonry/contexts.jsx"
import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js"
import type { MediaModel } from "@folo-services/drizzle"
import type { MediaModel } from "@follow/database/schemas/types"
import type { RenderComponentProps } from "masonic"
import { Masonry } from "masonic"
import { useState } from "react"

View File

@ -8,21 +8,33 @@ export const CreateWallet = () => {
const { t } = useTranslation("settings")
return (
<div>
<p className="text-base">
<Trans
i18nKey="wallet.create.description"
ns="settings"
components={{
PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />,
strong: <strong />,
}}
/>
</p>
<div className="mt-4 text-right">
<Button variant="primary" isLoading={mutation.isPending} onClick={() => mutation.mutate()}>
{t("wallet.create.button")}
</Button>
<div className="rounded-2xl border border-fill-secondary bg-material-ultra-thin p-6 shadow-sm">
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-3">
<div className="flex size-12 items-center justify-center rounded-2xl bg-fill-quaternary text-folo">
<i className="i-mgc-power text-2xl" />
</div>
<p className="max-w-2xl text-base text-text-secondary">
<Trans
i18nKey="wallet.create.description"
ns="settings"
components={{
PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />,
strong: <strong className="text-text" />,
}}
/>
</p>
</div>
<div className="shrink-0">
<Button
variant="primary"
isLoading={mutation.isPending}
onClick={() => mutation.mutate()}
>
{t("wallet.create.button")}
</Button>
</div>
</div>
</div>
)

View File

@ -36,9 +36,14 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
return <CreateWallet />
}
return (
<div className={cn(className)}>
<div
className={cn(
"rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm",
className,
)}
>
<SettingSectionTitle title={t("wallet.balance.title")} margin="compact" />
<div className="mb-2 flex items-center justify-between">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-1">
<Balance className="text-xl font-bold text-folo">
@ -46,7 +51,7 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
</Balance>
</div>
<Tooltip>
<TooltipTrigger className="mt-1 block">
<TooltipTrigger className="mt-2 block">
<div className="flex flex-row items-center gap-x-2 text-xs">
<span className="flex items-center gap-1 text-left">
{t("wallet.balance.withdrawable")} <i className="i-mgc-question-cute-re" />

View File

@ -26,9 +26,7 @@ import { toast } from "sonner"
import { z } from "zod"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { followClient } from "~/lib/api-client"
import { defineQuery } from "~/lib/defineQuery"
import { useTOTPModalWrapper } from "~/modules/profile/hooks"
import { Balance } from "~/modules/wallet/balance"
import { useWallet, wallet as walletActions } from "~/queries/wallet"
@ -67,12 +65,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
resolver: zodResolver(formSchema),
})
const powerPrice = useAuthQuery(
defineQuery(["power-price"], async () => {
const res = await followClient.api.wallets.powerPrice()
return res.data
}),
)
const rss3ConversionRate: number | null = null
const mutation = useMutation({
mutationFn: async ({
@ -105,7 +98,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
if (mutation.isError) {
toast.error(t("wallet.withdraw.error", { error: mutation.error?.message }))
}
}, [mutation.isError, t])
}, [mutation.error?.message, mutation.isError, t])
useEffect(() => {
if (mutation.isSuccess) {
@ -180,7 +173,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
<TooltipPortal>
<TooltipContent>
<span className="text-xs text-gray-500">
1 POWER = {powerPrice.data?.rss3 ?? "-"} RSS3
<span>1 POWER = {rss3ConversionRate ?? "-"} RSS3</span>
</span>
</TooltipContent>
</TooltipPortal>
@ -192,12 +185,10 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => {
</span>
</FormControl>
</div>
{field.value && (
{field.value && rss3ConversionRate !== null && (
<span className="text-xs text-gray-500">
{t("wallet.withdraw.receiveRSS3", {
amount: ((form.watch("amount") || 0) * (powerPrice.data?.rss3 ?? 0)).toFixed(
4,
),
amount: ((form.watch("amount") || 0) * rss3ConversionRate).toFixed(4),
})}
</span>
)}

View File

@ -1,6 +1,7 @@
import { LoadingCircle } from "@follow/components/ui/loading/index.js"
import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
import { useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import { TransactionTypes } from "@follow-app/client-sdk"
import { useState } from "react"
import { useTranslation } from "react-i18next"
@ -28,8 +29,15 @@ export const TransactionsSection: Component = ({ className }) => {
if (!myWallet) return null
const hasTransactions = Boolean(transactions.data?.length)
return (
<div className="relative flex min-w-0 grow flex-col">
<div
className={cn(
"relative flex min-w-0 grow flex-col rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm",
className,
)}
>
<SettingSectionTitle title={t("wallet.transactions.title")} />
<Tabs value={type} onValueChange={(val) => setType(val)}>
<TabsList className="relative border-b-transparent">
@ -40,8 +48,8 @@ export const TransactionsSection: Component = ({ className }) => {
))}
</TabsList>
</Tabs>
<TxTable type={type} className={className} />
{!!transactions.data?.length && (
{hasTransactions ? <TxTable type={type} /> : null}
{hasTransactions && (
<a
className="my-2 w-full text-sm text-zinc-400 underline"
href={`${getBlockchainExplorerUrl()}/address/${myWallet.address}`}
@ -51,12 +59,20 @@ export const TransactionsSection: Component = ({ className }) => {
</a>
)}
{(transactions.isFetching || !transactions.data?.length) && (
<div className="my-2 flex w-full justify-center text-sm text-zinc-400">
{(transactions.isFetching || !hasTransactions) && (
<div className="my-4 flex w-full justify-center text-sm text-zinc-400">
{transactions.isFetching ? (
<LoadingCircle size="medium" />
) : (
t("wallet.transactions.noTransactions")
<div className="flex min-h-56 w-full flex-col items-center justify-center rounded-xl border border-dashed border-border bg-background/60 px-6 text-center">
<i className="i-mgc-power mb-3 text-4xl text-text-quaternary" />
<p className="text-sm font-medium text-text">
{t("wallet.transactions.empty.title")}
</p>
<p className="mt-1 max-w-sm text-sm text-text-secondary">
{t("wallet.transactions.empty.description")}
</p>
</div>
)}
</div>
)}

View File

@ -19,7 +19,11 @@ export const TypeRenderer = ({
type: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number]["type"]
}) => {
const { t } = useTranslation("settings")
return <div className="uppercase">{t(`wallet.transactions.types.${type}`)}</div>
return (
<div className="uppercase">
{t(`wallet.transactions.types.${String(type)}` as const, { defaultValue: String(type) })}
</div>
)
}
export const BalanceRenderer = ({

View File

@ -78,7 +78,7 @@ export function TOTPForm({
const updateMutation = useMutation({
mutationFn: onSubmitMutationFn,
onError: (error) => {
const { code } = getFetchErrorInfo(error)
const { code, message } = getFetchErrorInfo(error)
if (error.message === "invalid two factor authentication" || code === 4007) {
form.resetField("code")
form.setError("code", {
@ -90,7 +90,10 @@ export function TOTPForm({
form.setFocus("code")
}, 10)
controls.start("shake")
return
}
toast.error(message || t("profile.totp_code.invalid"))
},
onSuccess,
})
@ -135,6 +138,11 @@ export function TOTPForm({
</FormItem>
)}
/>
<div className="text-right">
<Button type="submit" isLoading={updateMutation.isPending}>
{t("profile.submit")}
</Button>
</div>
</form>
</Form>
)

View File

@ -9,7 +9,6 @@ import { getAvatarUrl } from "@follow/utils"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { getStorageNS } from "@follow/utils/ns"
import { cn } from "@follow/utils/utils"
import type { ListWithStats } from "@follow-app/client-sdk"
import { useQuery } from "@tanstack/react-query"
import { useAtom } from "jotai"
import { atomWithStorage } from "jotai/utils"
@ -63,7 +62,7 @@ const pickUserData = <
}
}
const ListCard = memo(({ list }: { list: ListWithStats }) => {
const ListCard = memo(({ list }: { list: any }) => {
return (
<div className="group/card relative overflow-hidden rounded-lg border border-fill bg-material-ultra-thin transition-all duration-200 hover:border-fill-secondary">
<a
@ -124,7 +123,11 @@ export const UserProfileModalContent: FC<SubscriptionModalContentProps> = ({ use
const user = usePrefetchUser(userId)
const storeUser = useUserById(userId)
const userInfo = user.data ? pickUserData(user.data) : storeUser ? pickUserData(storeUser) : null
const userInfo = user.data
? pickUserData(user.data as any)
: storeUser
? pickUserData(storeUser as any)
: null
const modal = useCurrentModal()
const controller = useAnimationControls()
@ -361,7 +364,7 @@ const useUserListsQuery = (userId: string) => {
})
}
const Lists = ({ lists }: { lists: ListWithStats[] }) => {
const Lists = ({ lists }: { lists: any[] }) => {
const { t } = useTranslation()
if (!lists || lists.length === 0) return null
return (

View File

@ -0,0 +1,24 @@
import type { SVGProps } from "react"
export const Android2CuteReIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path fill="currentColor" fillOpacity="0.01" d="M24 0v24H0V0z" />
<path
fill="currentColor"
d="M9 13.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0m7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="m6 5 1.5 3m9 0L18 5m-9 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM4.833 18h14.334C20.179 18 21 17.18 21 16.167V16a9 9 0 0 0-9-9v0a9 9 0 0 0-9 9v.167C3 17.179 3.82 18 4.833 18Z"
/>
</svg>
)

View File

@ -195,22 +195,24 @@ const Content: FC<{
</SettingSectionHighlightIdContext>
<div className="h-16" />
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">
<Trans
ns="settings"
i18nKey="common.give_star"
components={{
Link: (
<a
href={`${repository.url}`}
className="font-semibold text-accent"
target="_blank"
/>
),
HeartIcon: <i className="i-mgc-heart-cute-fi" />,
}}
/>
</p>
{activeSetting.path === "about" && (
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">
<Trans
ns="settings"
i18nKey="common.give_star"
components={{
Link: (
<a
href={`${repository.url}`}
className="font-semibold text-accent"
target="_blank"
/>
),
HeartIcon: <i className="i-mgc-heart-cute-fi" />,
}}
/>
</p>
)}
</ScrollArea.ScrollArea>
</Suspense>
)

View File

@ -183,6 +183,7 @@ const SettingItemButtonImpl = (props: {
"my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text",
isActive && "!bg-theme-item-active !text-text",
!IN_ELECTRON && "duration-200 hover:bg-theme-item-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30",
disabled && "opacity-50",
disabledByConfig && "cursor-not-allowed",
)}

View File

@ -4,15 +4,47 @@ import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { oneTimeToken } from "~/lib/auth"
import { ipcServices } from "~/lib/client"
import { getAuthSessionToken } from "~/lib/client-session"
import { copyToClipboard } from "~/lib/clipboard"
import { SettingSectionTitle } from "../section"
const getOneTimeTokenFromResult = (result: unknown) => {
if (!result || typeof result !== "object") {
return null
}
if ("token" in result && typeof result.token === "string") {
return result.token
}
if (
"data" in result &&
result.data &&
typeof result.data === "object" &&
"token" in result.data &&
typeof result.data.token === "string"
) {
return result.data.token
}
return null
}
const LATEST_WITH_NPX_COMMAND = "npx --yes folocli@latest --help"
const AGENT_PROMPT = "Read https://api.folo.is/skill.md and follow the instructions to use Folo."
export const SettingCli = () => {
interface CliInstallStatus {
installed: boolean
installPath: string | null
cliSourceAvailable: boolean
connected: boolean
configPath: string
hasDesktopSession: boolean
installCommand: string
loginCommand: string
npxAvailable: boolean
packageName: string
}
const { t } = useTranslation("settings")
const [status, setStatus] = useState<CliInstallStatus | null>(null)
@ -32,7 +64,10 @@ export const SettingCli = () => {
const handleInstall = useCallback(async () => {
setLoading(true)
try {
const result = await ipcServices?.cli.installCli()
const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate())
const result = await ipcServices?.cli.installCli(
generatedOneTimeToken ?? getAuthSessionToken() ?? undefined,
)
if (result?.success) {
toast.success(t("cli.install_success"))
} else {
@ -72,9 +107,9 @@ export const SettingCli = () => {
{status && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">Status:</span>
{status.installed ? (
{status.connected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green">
<i className="i-mingcute-check-line" />
{t("cli.installed")}
@ -84,31 +119,83 @@ export const SettingCli = () => {
{t("cli.not_installed")}
</span>
)}
<span
className={
status.npxAvailable
? "inline-flex items-center gap-1 rounded-full bg-blue/10 px-2 py-0.5 text-xs text-blue"
: "inline-flex items-center gap-1 rounded-full bg-orange/10 px-2 py-0.5 text-xs text-orange"
}
>
{status.npxAvailable ? t("cli.runtime_ready") : t("cli.runtime_missing")}
</span>
</div>
{status.installed && status.installPath && (
<div className="grid gap-3">
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
<span>RUN LATEST WITH NPX</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
onClick={() => {
void copyToClipboard(LATEST_WITH_NPX_COMMAND)
toast.success("Command copied")
}}
>
<i className="i-mgc-copy-2-cute-re text-sm" />
Copy
</button>
</div>
<code className="block break-all text-sm">{LATEST_WITH_NPX_COMMAND}</code>
</div>
<div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium uppercase tracking-wide text-text-secondary">
<span>AGENT PROMPT</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-accent transition-opacity hover:opacity-80"
onClick={() => {
void copyToClipboard(AGENT_PROMPT)
toast.success("Prompt copied")
}}
>
<i className="i-mgc-copy-2-cute-re text-sm" />
Copy
</button>
</div>
<p className="text-sm text-text-secondary">{AGENT_PROMPT}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("cli.path")}:</span>
<code className="rounded bg-fill-quaternary px-2 py-0.5 text-xs">
{status.installPath}
{status.configPath}
</code>
</div>
)}
</div>
{!status.cliSourceAvailable && (
{!status.npxAvailable && (
<p className="text-sm text-orange-500">{t("cli.not_available")}</p>
)}
{!status.hasDesktopSession && (
<p className="text-sm text-text-secondary">{t("cli.require_login")}</p>
)}
<div className="flex gap-2">
{!status.installed ? (
<Button
onClick={handleInstall}
disabled={loading || !status.cliSourceAvailable}
isLoading={loading}
>
{t("cli.install")}
</Button>
) : (
<Button
onClick={handleInstall}
disabled={loading || !status.npxAvailable || !status.hasDesktopSession}
isLoading={loading}
>
{t("cli.install")}
</Button>
{status.connected && (
<Button
variant="outline"
onClick={handleUninstall}

View File

@ -13,6 +13,7 @@ import {
SimpleIconsZotero,
} from "@follow/components/ui/platform-icon/icons.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import type { FC } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -22,12 +23,13 @@ import {
setIntegrationSetting,
useIntegrationSettingValue,
} from "~/atoms/settings/integration"
import { ipcServices } from "~/lib/client"
import { downloadJsonFile, selectJsonFile } from "~/lib/export"
import { getFetchAdapter } from "~/modules/integration/fetch-adapter"
import { createSetting } from "../../helper/builder"
import { useSetSettingCanSync } from "../../modal/hooks"
import { SettingSectionTitle } from "../../section"
import { SettingItemGroup, SettingSectionTitle } from "../../section"
import { CustomIntegrationSection } from "./CustomIntegrationSection"
const { defineSettingItem, SettingBuilder } = createSetting(
@ -35,6 +37,61 @@ const { defineSettingItem, SettingBuilder } = createSetting(
useIntegrationSettingValue,
setIntegrationSetting,
)
const ObsidianVaultPathPicker: FC = () => {
const vaultPath = useIntegrationSettingValue().obsidianVaultPath
const { t } = useTranslation("settings")
const [pathValid, setPathValid] = useState<boolean | null>(null)
useEffect(() => {
if (!vaultPath) {
setPathValid(null)
return
}
ipcServices?.app.checkPathExists(vaultPath).then((exists) => {
setPathValid(exists)
})
}, [vaultPath])
const handleBrowse = async () => {
const selected = await ipcServices?.app.selectDirectory()
if (selected) {
setIntegrationSetting("obsidianVaultPath", selected)
}
}
const buttonText = !vaultPath
? t("integration.obsidian.vaultPath.select")
: pathValid === false
? t("integration.obsidian.vaultPath.reselect")
: t("integration.obsidian.vaultPath.change")
return (
<SettingItemGroup>
<div className="mb-2 mt-4 flex flex-col gap-3">
<label className="shrink-0 text-sm font-medium leading-none">
{t("integration.obsidian.vaultPath.label")}
</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleBrowse}>
{buttonText}
</Button>
{vaultPath && (
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-xs text-text-secondary">{vaultPath}</span>
{pathValid === false && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-red">
<i className="i-mgc-warning-cute-re" />
{t("integration.obsidian.vaultPath.invalid")}
</span>
)}
</span>
)}
</div>
</div>
</SettingItemGroup>
)
}
export const SettingIntegration = () => {
const { t } = useTranslation("settings")
const setSync = useSetSettingCanSync()
@ -100,11 +157,7 @@ export const SettingIntegration = () => {
label: t("integration.obsidian.enable.label"),
description: t("integration.obsidian.enable.description"),
}),
defineSettingItem("obsidianVaultPath", {
label: t("integration.obsidian.vaultPath.label"),
vertical: true,
description: t("integration.obsidian.vaultPath.description"),
}),
ObsidianVaultPathPicker,
],
},
{

View File

@ -60,7 +60,8 @@ export const SettingNotifications = () => {
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">{t.settings("notifications.channel")}</h3>
<span className="text-xs text-text-tertiary">
<span>{data?.data?.length || 0}</span> <span>{t.common("words.items")}</span>
<span>{data?.data?.length || 0}</span>{" "}
<span>{t.common("words.items", { count: data?.data?.length || 0 })}</span>
</span>
</div>
@ -74,7 +75,12 @@ export const SettingNotifications = () => {
{!isLoading && (!data?.data || data.data.length === 0) ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-material-medium py-12">
<i className="i-mgc-notification-cute-re mb-3 text-4xl text-text-quaternary" />
<p className="text-sm text-text-tertiary">No notification channels</p>
<p className="text-sm font-medium text-text">
{t.settings("notifications.empty.title")}
</p>
<p className="mt-1 max-w-sm px-6 text-center text-sm text-text-secondary">
{t.settings("notifications.empty.description")}
</p>
</div>
) : (
<ScrollArea.ScrollArea viewportClassName="max-h-[400px]">

View File

@ -580,22 +580,24 @@ const PlanComparisonTable = ({ plans }: { plans: PaymentPlan[] }) => {
<tbody>
{visibleFeatureKeys.map((featureKey, index) => (
<tr
key={featureKey}
key={String(featureKey)}
className={cn(
"border-b border-fill-tertiary transition-colors hover:bg-fill-secondary/30",
index % 2 === 0 ? "bg-background" : "bg-fill-secondary/20",
)}
>
<td className="sticky left-0 z-10 bg-inherit px-4 py-3 text-sm font-medium">
{t(`plan.features.${featureKey}`, { defaultValue: featureKey })}
{t(`plan.features.${String(featureKey)}` as const, {
defaultValue: String(featureKey),
})}
</td>
{plans.map((plan) => {
const value = plan.limit[featureKey]
const value = plan.limit[featureKey as keyof typeof plan.limit]
const formattedValue = formatFeatureValue(featureKey, value, t)
return (
<td
key={`${plan.name}-${featureKey}`}
key={`${plan.name}-${String(featureKey)}`}
className="px-4 py-3 text-center text-sm"
>
<span

View File

@ -7,7 +7,7 @@ import { m } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router"
import { useNavigate } from "react-router"
import { toast } from "sonner"
import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar"
@ -27,6 +27,7 @@ import { ProfileButton } from "~/modules/user/ProfileButton"
export const SubscriptionColumnHeader = memo(() => {
const timelineId = useRouteParamsSelector((s) => s.timelineId)
const navigateBackHome = useBackHome(timelineId)
const navigate = useNavigate()
const normalStyle = !window.electron || window.electron.process.platform !== "darwin"
const { t } = useTranslation()
return (
@ -52,15 +53,14 @@ export const SubscriptionColumnHeader = memo(() => {
</LogoContextMenu>
)}
<div className="relative flex items-center gap-2" onClick={stopPropagation}>
<Link to="/discover" tabIndex={-1}>
<ActionButton
data-testid="subscription-discover-trigger"
shortcut="$mod+T"
tooltip={t("words.discover")}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>
</Link>
<ActionButton
data-testid="subscription-discover-trigger"
shortcut="$mod+T"
tooltip={t("words.discover")}
onClick={() => navigate("/discover")}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>
<ProfileButton method="modal" animatedAvatar />
<LayoutActionButton />

View File

@ -157,6 +157,7 @@ const ViewAllSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
aria-pressed={isActive}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
key={item.name}
tooltip={t(item.name, { ns: "common" })}
@ -219,6 +220,7 @@ const ViewSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
aria-pressed={isActive}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
ref={setNodeRef}
key={item.name}

View File

@ -18,6 +18,7 @@ import {
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@follow/components/ui/button/index.js"
import { getView } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import type { CSSProperties, ReactNode } from "react"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
@ -27,16 +28,31 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { parseView } from "~/hooks/biz/useRouteParams"
import { useTimelineList } from "~/hooks/biz/useTimelineList"
function ContainerDroppable({ id, children }: { id: "visible" | "hidden"; children: ReactNode }) {
function ContainerDroppable({
id,
children,
emptyLabel,
hasItems,
}: {
id: "visible" | "hidden"
children: ReactNode
emptyLabel: string
hasItems: boolean
}) {
const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } })
return (
<div
ref={setNodeRef}
className={`flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border border-border bg-material-ultra-thin p-2 pb-6 shadow-sm ${
isOver ? "outline outline-1 outline-orange-400" : ""
}`}
className={cn(
"flex min-h-[120px] w-full flex-col items-stretch justify-center rounded-xl border border-border bg-material-ultra-thin p-3 shadow-sm transition-colors",
isOver && "border-accent/50 bg-accent/5 ring-2 ring-accent/20",
)}
>
{children}
{hasItems ? (
children
) : (
<p className="px-3 py-6 text-center text-sm text-text-tertiary">{emptyLabel}</p>
)}
</div>
)
}
@ -55,7 +71,7 @@ function TabItem({ id }: { id: UniqueIdentifier }) {
const meta = getViewMeta(String(id))
const { t } = useTranslation()
return (
<div className="flex w-full items-center gap-2 rounded-md p-2 hover:bg-material-opaque">
<div className="flex w-full items-center gap-2 rounded-lg border border-transparent bg-background/60 p-2.5 hover:bg-material-opaque">
<div className="flex size-6 items-center justify-center text-lg">{meta.icon}</div>
<div className="text-callout text-text-secondary">
{t(meta.name as any, { ns: "common" })}
@ -65,6 +81,8 @@ function TabItem({ id }: { id: UniqueIdentifier }) {
}
function SortableTabItem({ id }: { id: UniqueIdentifier }) {
const { t } = useTranslation("app")
const meta = getViewMeta(String(id))
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
@ -79,7 +97,11 @@ function SortableTabItem({ id }: { id: UniqueIdentifier }) {
<div
ref={setNodeRef}
style={style}
className={isDragging ? "cursor-grabbing" : "cursor-grab"}
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30",
)}
aria-label={`${t("sidebar.timeline_tabs.drag_tab")}: ${t(meta.name as any, { ns: "common" })}`}
{...attributes}
{...listeners}
>
@ -96,6 +118,7 @@ function useResolvedTimelineTabs() {
}
const TimelineTabsSettings = () => {
const { t } = useTranslation(["app", "common", "settings"])
const { visible, hidden } = useResolvedTimelineTabs()
const commitTimelineTabs = useCallback(
@ -175,6 +198,12 @@ const TimelineTabsSettings = () => {
className="mx-auto w-[600px] max-w-full space-y-4 overflow-hidden pt-2"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="space-y-1 px-1">
<p className="text-sm text-text-secondary">
{t("appearance.customize_sub_tabs.description", { ns: "settings" })}
</p>
<p className="text-xs text-text-tertiary">{t("sidebar.timeline_tabs.instructions")}</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -183,8 +212,14 @@ const TimelineTabsSettings = () => {
>
<div className="space-y-4">
<div>
<h3 className="mb-2 text-subheadline font-medium text-text">Visible</h3>
<ContainerDroppable id="visible">
<h3 className="mb-2 text-subheadline font-medium text-text">
{t("sidebar.timeline_tabs.visible")}
</h3>
<ContainerDroppable
id="visible"
emptyLabel={t("sidebar.timeline_tabs.empty_visible")}
hasItems={visible.length > 0}
>
<SortableContext items={visible} strategy={verticalListSortingStrategy}>
{visible.map((id) => (
<SortableTabItem key={id} id={id} />
@ -194,8 +229,14 @@ const TimelineTabsSettings = () => {
</div>
<div>
<h3 className="mb-2 text-subheadline font-medium text-text">Hidden</h3>
<ContainerDroppable id="hidden">
<h3 className="mb-2 text-subheadline font-medium text-text">
{t("sidebar.timeline_tabs.hidden")}
</h3>
<ContainerDroppable
id="hidden"
emptyLabel={t("sidebar.timeline_tabs.empty_hidden")}
hasItems={hidden.length > 0}
>
<SortableContext items={hidden} strategy={verticalListSortingStrategy}>
{hidden.map((id) => (
<SortableTabItem key={id} id={id} />
@ -209,6 +250,7 @@ const TimelineTabsSettings = () => {
<div className="flex justify-end">
<Button
variant="outline"
disabled={visible.length === 0 && hidden.length === 0}
onClick={() => {
setUISetting("timelineTabs", {
visible: [],
@ -216,7 +258,7 @@ const TimelineTabsSettings = () => {
})
}}
>
Reset to default
{t("sidebar.timeline_tabs.reset")}
</Button>
</div>
</div>
@ -225,13 +267,14 @@ const TimelineTabsSettings = () => {
export const useShowTimelineTabsSettingsModal = () => {
const { present } = useModalStack()
const { t } = useTranslation("settings")
return useCallback(() => {
present({
id: "timeline-tabs-settings",
title: "Customize View Tabs",
title: t("appearance.customize_sub_tabs.label"),
content: () => <TimelineTabsSettings />,
overlay: true,
clickOutsideToDismiss: true,
})
}, [present])
}, [present, t])
}

View File

@ -290,7 +290,7 @@ const SubscriptionImpl = ({ ref, className, view, isSubscriptionLoading }: Subsc
<SortableFeedList
view={view}
data={feedsData}
categoryOpenStateData={categoryOpenStateData}
categoryOpenStateData={categoryOpenStateData ?? {}}
/>
) : isSubscriptionLoading ? (
<SubscriptionListSkeleton />

View File

@ -1,5 +1,3 @@
import { cn } from "@follow/utils"
import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { ChatPageHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
@ -7,10 +5,7 @@ import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterfac
export const Component = () => {
return (
<div
className={cn(
"relative flex h-screen w-full flex-col",
"[&_[data-testid=chat-input-container]]:translate-y-32 [&_[data-testid=welcome-screen-header]]:-translate-y-24",
)}
className="relative flex h-screen w-full flex-col [&_[data-testid=welcome-screen-header]]:-translate-y-24"
style={{ "--ai-chat-layout-width": "65rem" } as React.CSSProperties}
>
<AIChatRoot>

View File

@ -19,7 +19,7 @@ interface SectionProps {
}
function Section({ children, className }: SectionProps) {
return <section className={cn("mx-auto w-full max-w-6xl", className)}>{children}</section>
return <section className={cn("mx-auto w-full max-w-5xl", className)}>{children}</section>
}
// ============================================================================
@ -33,25 +33,21 @@ export function Component() {
const hasSearchData = useHasDiscoverSearchData()
return (
<div className="flex size-full flex-col px-6 py-8">
{/* Hero Section */}
<Section className="mb-12">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1>
<p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p>
<div className="flex size-full flex-col p-6">
<Section className="mb-8">
<div className="rounded-[28px] border border-fill-secondary bg-material-ultra-thin px-6 py-8 shadow-sm">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1>
<p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p>
</div>
<div className="mt-6 flex flex-col items-center">
<UnifiedDiscoverForm />
</div>
</div>
</Section>
{/* Search Section */}
<Section className="mb-12">
<div className="flex flex-col items-center">
<UnifiedDiscoverForm />
</div>
</Section>
{/* Discovery Section - Hide when searching */}
{!hasSearchData && (
<Section>
<Section className="mt-8">
<AppErrorBoundary errorType={ErrorComponentType.RSSHubDiscoverError}>
<DiscoveryContent />
</AppErrorBoundary>

View File

@ -15,12 +15,12 @@ export const loader = () => {
const subscriptionState = useSubscriptionStore.getState()
const hasAudiosSubscription =
subscriptionState.feedIdByView[FeedViewType.Audios].size > 0 ||
subscriptionState.listIdByView[FeedViewType.Audios].size > 0
(subscriptionState.feedIdByView[FeedViewType.Audios]?.size ?? 0) > 0 ||
(subscriptionState.listIdByView[FeedViewType.Audios]?.size ?? 0) > 0
const hasNotificationsSubscription =
subscriptionState.feedIdByView[FeedViewType.Notifications].size > 0 ||
subscriptionState.listIdByView[FeedViewType.Notifications].size > 0
(subscriptionState.feedIdByView[FeedViewType.Notifications]?.size ?? 0) > 0 ||
(subscriptionState.listIdByView[FeedViewType.Notifications]?.size ?? 0) > 0
const { visible } = computeTimelineTabLists({
timelineTabs: uiSettings.timelineTabs,

View File

@ -1,18 +1,18 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { Android2CuteReIcon } from "~/modules/settings/icons/Android2CuteReIcon"
import { SettingCli } from "~/modules/settings/tabs/cli"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-terminal-cute-re"
const priority = (1000 << 1) + 25
const CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE = true
export const loader = defineSettingPageData({
icon: iconName,
icon: <Android2CuteReIcon />,
headerIcon: <Android2CuteReIcon className="size-5 text-accent" />,
name: "titles.cli",
priority,
hideIf: () => CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE || !IN_ELECTRON,
hideIf: () => !IN_ELECTRON,
})
export function Component() {

View File

@ -94,7 +94,8 @@ export const useSession = (options?: { enabled?: boolean }) => {
export const handleSessionChanges = () => {
setLoginModalShow(false)
ipcServices?.auth.sessionChanged()
const authSessionToken = getAuthSessionToken()
ipcServices?.auth.sessionChanged(authSessionToken ?? undefined)
window.location.reload()
}

View File

@ -41,9 +41,7 @@ export const discover = {
}),
rsshubAnalytics: ({ lang }: { lang?: string }) =>
defineQuery(["discover", "rsshub", "analytics", lang], async () => {
const res = await followClient.api.discover.rsshubAnalytics({
...(lang !== "all" && { lang }),
})
const res = await followClient.api.discover.rsshubAnalytics({})
return res.data
}),
}

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",
@ -18,7 +18,7 @@
"build:electron-forge:macos": "electron-forge make --arch=x64 --platform=darwin && electron-forge make --arch=arm64 --platform=darwin && tsx scripts/merge-yml.ts",
"build:electron-forge:mas": "electron-forge make --arch=universal --platform=mas",
"build:electron-forge:ms": "tsx scripts/generate-appx-manifest.ts && electron-forge make --platform=win32 --ms=true",
"build:electron-vite": "pnpm run prepare:cli && electron-vite build",
"build:electron-vite": "electron-vite build",
"build:render": "vite build -c vite.config.electron-render.ts",
"build:web": "rm -rf out/web && cross-env WEB_BUILD=1 vite build",
"bump": "vv --minor",
@ -36,7 +36,6 @@
"e2e:web": "playwright test -c e2e/playwright.config.ts --project=web",
"e2e:web:prod": "cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=web",
"hotfix": "vv -c bump.hotfix.config.js --patch",
"prepare:cli": "tsx scripts/prepare-cli.ts",
"publish": "electron-vite build && electron-forge publish",
"start": "electron-vite preview",
"update:main-hash": "tsx plugins/vite/generate-main-hash.ts"
@ -95,5 +94,5 @@
"vite-tsconfig-paths": "6.1.1"
},
"productName": "Folo",
"mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57"
"mainHash": "4778cc1cb43d08ed2a5edf391f0d22694641dc15eb27eaefb45bfa09ff176091"
}

View File

@ -1,27 +0,0 @@
import { execSync } from "node:child_process"
import { cpSync, existsSync, mkdirSync } from "node:fs"
import { resolve } from "pathe"
const rootDir = resolve(import.meta.dirname, "../../..")
const cliDistDir = resolve(rootDir, "apps/cli/dist")
const targetDir = resolve(rootDir, "apps/desktop/resources/cli")
// Build CLI
console.info("Building CLI...")
execSync("pnpm --filter @follow/cli build", {
cwd: rootDir,
stdio: "inherit",
})
// Ensure CLI was built
const cliEntry = resolve(cliDistDir, "index.js")
if (!existsSync(cliEntry)) {
throw new Error(`CLI build output not found at ${cliEntry}`)
}
// Copy to desktop resources
mkdirSync(targetDir, { recursive: true })
cpSync(cliEntry, resolve(targetDir, "index.js"))
console.info("CLI prepared at", targetDir)

View File

@ -290,8 +290,6 @@ export default ({ mode }) => {
"@tanstack/query-sync-storage-persister",
],
["tldts"],
["@openpanel/web"],
["zod", "react-hook-form", "@hookform/resolvers"],
]),

View File

@ -0,0 +1,29 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "folo-web",
"compatibility_date": "2026-02-01",
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": "./out/web",
"not_found_handling": "single-page-application",
},
"workers_dev": true,
"routes": [
{
"pattern": "app.folo.is/*",
"zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63",
},
],
"env": {
"dev": {
"name": "folo-web-dev",
"workers_dev": true,
"routes": [
{
"pattern": "dev.folo.is/*",
"zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63",
},
],
},
},
}

View File

@ -39,8 +39,8 @@
"progressive-blur": "1.0.0",
"radix-ui": "1.4.3",
"re-resizable": "6.11.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "6.0.0",
"react-intersection-observer": "9.16.0",
"react-markdown": "10.1.0",
@ -55,7 +55,7 @@
"unified": "11.0.5",
"usehooks-ts": "3.1.1",
"vaul": "1.1.2",
"vinext": "0.0.9"
"vinext": "0.0.30"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.25.5",

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,39 @@ import * as React from 'react'
import { BuiltOpen } from '~/components/widgets/landing/BuiltOpen'
import { Features } from '~/components/widgets/landing/Features'
import { LandingHero } from '~/components/widgets/landing/Hero'
import { SocialProof } from '~/components/widgets/landing/SocialProof'
import { TrustedBy } from '~/components/widgets/landing/TrustedBy'
import {
DISCOVER_FALLBACK,
getHeroTimelineItems,
getLandingMetrics,
TRUSTED_COMPANIES,
TRUSTED_RESEARCH_INSTITUTIONS,
} from '~/lib/landing-data'
type LocaleParams = { locale?: string }
export default async function Home({
params,
}: {
params: Promise<LocaleParams> | LocaleParams | undefined
}) {
const locale = params ? (await params).locale : undefined
const [heroItems, metrics] = await Promise.all([
getHeroTimelineItems(locale),
getLandingMetrics(),
])
export default async function Home() {
return (
<>
<LandingHero />
<Features />
<LandingHero items={heroItems} />
<TrustedBy
companies={TRUSTED_COMPANIES}
researchers={TRUSTED_RESEARCH_INSTITUTIONS}
metrics={metrics}
/>
<Features discoverSources={DISCOVER_FALLBACK} />
{/* <ViewsShowcase /> */}
{/* <Audience /> */}
<SocialProof />
<BuiltOpen />
</>
)

View File

@ -0,0 +1,35 @@
import type { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { PricingPlans } from '~/components/widgets/pricing/PricingPlans'
import { defaultLocale, locales } from '~/i18n/routing'
import { fetchPricingPlans } from '~/lib/pricing-data'
type LocaleParams = { locale?: string }
const localeSet = new Set(locales)
export async function generateMetadata({
params,
}: {
params: Promise<LocaleParams> | LocaleParams | undefined
}): Promise<Metadata> {
const localeFromParams = params ? (await params).locale : undefined
const locale =
localeFromParams &&
localeSet.has(localeFromParams as (typeof locales)[number])
? localeFromParams
: defaultLocale
const t = await getTranslations({ locale, namespace: 'pricing.metadata' })
return {
title: t('title'),
description: t('description'),
}
}
export default async function PricingPage() {
const plans = await fetchPricingPlans()
return <PricingPlans plans={plans} />
}

View File

@ -0,0 +1,59 @@
import type { RSSHubRoutesIndex } from '~/lib/landing-data'
import {
buildDiscoverSourcesFromIndex,
DISCOVER_FALLBACK,
PRODUCTION_RSSHUB_ROUTES_URL,
} from '~/lib/landing-data'
const CACHE_TTL_MS = 12 * 60 * 60 * 1000
let cached:
| {
expiresAt: number
data: ReturnType<typeof buildDiscoverSourcesFromIndex>
}
| undefined
export async function GET() {
const now = Date.now()
if (cached && cached.expiresAt > now) {
return Response.json(cached.data, {
headers: {
'Cache-Control': 'public, max-age=600',
},
})
}
try {
const response = await fetch(PRODUCTION_RSSHUB_ROUTES_URL, {
headers: {
accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch RSSHub routes: ${response.status}`)
}
const result = (await response.json()) as RSSHubRoutesIndex
const data = buildDiscoverSourcesFromIndex(result)
cached = {
data,
expiresAt: now + CACHE_TTL_MS,
}
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=600',
},
})
} catch {
return Response.json(DISCOVER_FALLBACK, {
headers: {
'Cache-Control': 'public, max-age=60',
},
})
}
}

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