Merge pull request #4953 from RSSNext/release/desktop/1.5.0
release(desktop): Release v1.5.0
This commit is contained in:
commit
ab925fedb6
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=""'
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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=""'
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const resolveCliSessionToken = ({
|
||||
preferredToken,
|
||||
cookieToken,
|
||||
}: {
|
||||
preferredToken?: string
|
||||
cookieToken?: string
|
||||
}) => cookieToken || preferredToken
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<!-- FOLLOW VITE BUILD INJECT -->
|
||||
<!-- Check Browser Script Inject -->
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)))",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)}))`,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ const SubscriptionImpl = ({ ref, className, view, isSubscriptionLoading }: Subsc
|
|||
<SortableFeedList
|
||||
view={view}
|
||||
data={feedsData}
|
||||
categoryOpenStateData={categoryOpenStateData}
|
||||
categoryOpenStateData={categoryOpenStateData ?? {}}
|
||||
/>
|
||||
) : isSubscriptionLoading ? (
|
||||
<SubscriptionListSkeleton />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -290,8 +290,6 @@ export default ({ mode }) => {
|
|||
"@tanstack/query-sync-storage-persister",
|
||||
],
|
||||
["tldts"],
|
||||
|
||||
["@openpanel/web"],
|
||||
["zod", "react-hook-form", "@hookform/resolvers"],
|
||||
]),
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue