Merge pull request #4859 from RSSNext/release/mobile/0.3.0

release(mobile): Release v0.3.0
This commit is contained in:
DIYgod 2026-02-19 00:00:30 +08:00 committed by GitHub
commit d47ed33319
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
735 changed files with 35985 additions and 20982 deletions

View File

@ -0,0 +1,145 @@
---
name: desktop-release
description: Perform a regular desktop release from the dev branch. Gathers commits since last release, updates changelog, evaluates mainHash changes, bumps version, and creates release PR.
disable-model-invocation: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# Desktop Regular Release
Perform a regular desktop release. This skill handles the full release workflow from the `dev` branch.
## Pre-flight checks
1. Confirm the current branch is `dev`. If not, abort with a warning.
2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date.
3. Read `apps/desktop/package.json` to get the current `version` and `mainHash`.
## Step 1: Gather changes since last release
1. Find the last release tag:
```bash
git tag --sort=-creatordate | grep '^desktop/v' | head -1
```
2. Get all commits since that tag on the current branch:
```bash
git log <last-tag>..HEAD --oneline --no-merges
```
3. Categorize commits into:
- **Shiny new things** (feat: commits, new features)
- **Improvements** (refactor:, perf:, chore: improvements, dependency updates)
- **No longer broken** (fix: commits, bug fixes)
- **Thanks** (identify external contributor GitHub usernames from commits)
## Step 2: Update changelog
1. Read `apps/desktop/changelog/next.md`.
2. Present the categorized changes to the user and draft the changelog content.
3. Wait for user confirmation or edits before writing.
4. Write the final content to `apps/desktop/changelog/next.md`, following the template format:
```markdown
# What's new in vNEXT_VERSION
## Shiny new things
- description of new feature
## Improvements
- description of improvement
## No longer broken
- description of fix
## Thanks
Special thanks to volunteer contributors @username for their valuable contributions
```
5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.
## Step 3: Evaluate mainHash
This is critical for determining whether users need a full app update or can use the lightweight renderer hot update.
1. Check what files changed in `apps/desktop/layer/main/` since the last release tag:
```bash
git diff <last-tag>..HEAD --name-only -- apps/desktop/layer/main/
```
2. Also check changes to `apps/desktop/package.json` fields other than version/mainHash (since package.json is included in the hash calculation):
```bash
git diff <last-tag>..HEAD -- apps/desktop/package.json
```
**Decision logic:**
- If there are **NO changes** in `layer/main/` and no meaningful `package.json` changes (only version/mainHash/changelog-related), then mainHash should NOT be updated. Users will get a fast renderer-only hot update.
- If there are **trivial changes** in `layer/main/` (typo fixes, comment changes, logging tweaks) that don't affect runtime behavior, recommend NOT updating mainHash. Present the changes to the user and ask for confirmation.
- If there are **meaningful changes** in `layer/main/` (new features, bug fixes, dependency changes, API changes), mainHash MUST be updated. Users will need a full app update.
Present your analysis to the user with:
- List of changed files in `layer/main/`
- A summary of what changed
- Your recommendation (update or skip mainHash)
- Ask for explicit confirmation
## Step 4: Save old mainHash and execute bump
1. Save the current mainHash from `apps/desktop/package.json` for later comparison.
2. Change directory to `apps/desktop/` and run the bump:
```bash
cd apps/desktop && pnpm bump
```
3. This command will:
- Pull latest changes
- Apply changelog (rename next.md to {version}.md, create new next.md)
- Recalculate mainHash and write to package.json
- Format package.json
- Bump minor version
- Commit with message `release(desktop): release v{NEW_VERSION}`
- Create branch `release/desktop/{NEW_VERSION}`
- Push branch and create PR to `main`
## Step 5: Restore mainHash if skipping update
If Step 3 decided mainHash should NOT be updated, restore the old value now. The bump has already committed, pushed, and created the PR on a new release branch, so we amend the commit and force push. This is safe because the release branch was just created.
1. Change back to the repo root first (Step 4 left the working directory at `apps/desktop/`):
```bash
cd ../..
```
2. Ensure you are on the `release/desktop/{NEW_VERSION}` branch (bump should have switched to it).
3. Replace the recalculated mainHash with the saved old value in `apps/desktop/package.json`.
4. Stage and amend the release commit:
```bash
git add apps/desktop/package.json && git commit --amend --no-edit
```
5. Force push the release branch:
```bash
git push --force origin release/desktop/{NEW_VERSION}
```
If Step 3 decided mainHash SHOULD be updated, skip this step entirely — the bump already wrote the correct new value.
## Step 6: Verify
1. Confirm the PR was created successfully by checking the output.
2. Report the new version number and PR URL to the user.
3. Summarize:
- New version: v{NEW_VERSION}
- mainHash updated: yes/no (and why)
- Changelog highlights
- PR URL
## Reference
- Bump config: `apps/desktop/bump.config.ts`
- Changelog dir: `apps/desktop/changelog/`
- Changelog template: `apps/desktop/changelog/next.template.md`
- mainHash generator: `apps/desktop/plugins/vite/generate-main-hash.ts`
- Hot updater logic: `apps/desktop/layer/main/src/updater/hot-updater.ts`
- CI build workflow: `.github/workflows/build-desktop.yml`
- Tag workflow: `.github/workflows/tag.yml`

View File

@ -0,0 +1,79 @@
---
name: installing-mobile-preview-builds
description: Builds and installs the iOS preview build for apps/mobile using EAS local build and devicectl. Use when the user asks to install a preview/internal iOS build on a connected iPhone for production-like testing.
disable-model-invocation: true
allowed-tools: Bash, Read, Glob, Grep
argument-hint: "[device-udid-or-name(optional)]"
---
# Install Mobile Preview Build (iOS)
Use this skill to create a fresh local `preview` iOS build and install it on a connected iPhone.
## Inputs
- Optional `$ARGUMENTS`: device identifier (UDID or exact device name).
- If no argument is provided, auto-select the first paired iPhone from `xcrun devicectl list devices`.
## Workflow
1. Validate repo and tooling.
- Run from repo root and ensure `apps/mobile` exists.
- Verify `pnpm`, `xcrun`, `xcodebuild`, and `eas-cli` are available.
- Verify EAS login:
```bash
cd apps/mobile
pnpm dlx eas-cli whoami
```
2. Resolve target device.
- List paired devices:
```bash
xcrun devicectl list devices
```
- Choose device in this order:
- `$ARGUMENTS` if provided and matches exactly one device.
- Otherwise, first paired iPhone.
3. Trigger local `preview` iOS build.
```bash
mkdir -p .context/preview-install
cd apps/mobile
pnpm dlx eas-cli build -p ios --profile preview --non-interactive --local --output=./build-preview.ipa
cd ../..
cp apps/mobile/build-preview.ipa .context/preview-install/folo-preview.ipa
```
4. Install to device locally.
```bash
unzip -q -o .context/preview-install/folo-preview.ipa -d .context/preview-install/unpacked
APP_PATH=$(find .context/preview-install/unpacked/Payload -maxdepth 1 -name '*.app' -type d | head -n 1)
xcrun devicectl device install app --device "<device-id>" "$APP_PATH"
```
5. Try launching app.
```bash
xcrun devicectl device process launch --device "<device-id>" is.follow --activate
```
- If launch fails due to locked device, instruct the user to unlock iPhone and open `Folo` manually.
## Failure Handling
- If local build fails, report:
- build mode (`local`)
- failing command
- key error message from command output
- If app config fails with `Assets source directory not found ... /out/rn-web`, prebuild assets then retry once:
```bash
pnpm --filter @follow/rn-micro-web-app build --outDir out/rn-web/html-renderer
```
## Output Format
Always return:
1. Build mode (`local`) and final status.
2. Local IPA path.
3. Target device identifier.
4. Install result (`installed` or `failed`) and launch result.
5. Next action for the user if manual action is required.

View File

@ -0,0 +1,114 @@
---
name: mobile-release
description: Perform a regular mobile release from the dev branch. Gathers commits since last release, updates changelog, bumps version, updates iOS Info.plist, and creates release PR to mobile-main.
disable-model-invocation: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# Mobile Regular Release
Perform a regular mobile release. This skill handles the full release workflow from the `dev` branch.
## Pre-flight checks
1. Confirm the current branch is `dev`. If not, abort with a warning.
2. Run `git pull --rebase` in the repo root to ensure the local branch is up to date.
3. Read `apps/mobile/package.json` to get the current `version`.
## Step 1: Gather changes since last release
1. Find the last release tag (both old `mobile@` and new `mobile/v` prefixes exist):
```bash
git tag --sort=-creatordate | grep -E '^mobile[@/]' | head -1
```
2. If no tag found, find the last release commit by matching only the subject line:
```bash
git log --format="%H %s" | grep "^[a-f0-9]* release(mobile): release v" | head -1 | awk '{print $1}'
```
3. Get all commits since the last release on the current branch:
```bash
git log <last-tag-or-commit>..HEAD --oneline --no-merges
```
4. Categorize commits into:
- **Shiny new things** (feat: commits, new features)
- **Improvements** (refactor:, perf:, chore: improvements, dependency updates)
- **No longer broken** (fix: commits, bug fixes)
- **Thanks** (identify external contributor GitHub usernames from commits)
## Step 2: Update changelog
1. Read `apps/mobile/changelog/next.md`.
2. Present the categorized changes to the user and draft the changelog content.
3. Wait for user confirmation or edits before writing.
4. Write the final content to `apps/mobile/changelog/next.md`, following the template format:
```markdown
# What's New in vNEXT_VERSION
## Shiny new things
- description of new feature
## Improvements
- description of improvement
## No longer broken
- description of fix
## Thanks
Special thanks to volunteer contributors @username for their valuable contributions
```
5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.
## Step 3: Execute bump
1. Change directory to `apps/mobile/` and run the bump:
```bash
cd apps/mobile && pnpm bump
```
2. This is an interactive `nbump` command that prompts for version selection. It will:
- Pull latest changes
- Apply changelog (rename next.md to {version}.md, create new next.md from template)
- Format package.json with eslint + prettier
- Bump version in `package.json`
- Update `ios/Folo/Info.plist`:
- Set `CFBundleShortVersionString` to the new version
- Increment `CFBundleVersion` (build number) by 1
- Commit with message `release(mobile): release v{NEW_VERSION}`
- Create branch `release/mobile/{NEW_VERSION}`
- Push branch and create PR to `mobile-main`
## Step 4: Verify
1. Confirm the PR was created successfully by checking the output.
2. Report the new version number and PR URL to the user.
3. Summarize:
- New version: v{NEW_VERSION}
- Changelog highlights
- PR URL
## Post-release (manual steps, inform user)
After the release PR is merged to `mobile-main`:
1. **Trigger production builds** via GitHub Actions `workflow_dispatch`:
- Go to "Build iOS" workflow, select `mobile-main` branch, profile = `production`
- Go to "Build Android" workflow, select `mobile-main` branch, profile = `production`
2. Production builds auto-submit to App Store (via `eas submit`) and Google Play (as draft).
3. After submission, go to App Store Connect and Google Play Console to complete the review/release process.
## Reference
- Bump config: `apps/mobile/bump.config.ts`
- Changelog dir: `apps/mobile/changelog/`
- Changelog template: `apps/mobile/changelog/next.template.md`
- Apply changelog script: `apps/mobile/scripts/apply-changelog.ts`
- EAS config: `apps/mobile/eas.json`
- App config: `apps/mobile/app.config.ts`
- iOS Info.plist: `apps/mobile/ios/Folo/Info.plist`
- CI build iOS: `.github/workflows/build-ios.yml`
- CI build Android: `.github/workflows/build-android.yml`

View File

@ -0,0 +1,178 @@
---
name: update-deps
description: Update all dependencies across frontend and backend projects. Reads changelogs for breaking changes, checks affected code, runs tests, and provides a summary. Use when updating npm dependencies across the monorepo.
disable-model-invocation: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Task
---
# Update All Dependencies
Update all npm dependencies across the frontend (current repo) and backend projects. This skill handles the full update workflow including changelog review, code impact analysis, testing, and summarization.
## Step 0: Ask for backend directory
Ask the user for the backend project directory path. Do NOT hardcode any path. Example prompt:
> Please provide the backend project directory path (e.g., `/path/to/backend`).
Wait for the user's response before proceeding. Save the path as `BACKEND_DIR` for later use.
## Step 1: Analyze current dependencies
### Frontend (current repo)
1. Run `pnpm outdated --recursive` in the current repo root to identify all outdated dependencies.
2. Save the full output for later analysis.
### Backend
1. Run `pnpm outdated --recursive` in `BACKEND_DIR` to identify all outdated dependencies.
2. Save the full output for later analysis.
Present the user with a summary of how many dependencies are outdated in each project.
## Step 2: Update dependencies
### Strategy
Update in two phases to isolate issues:
**Phase 1 — Patch and minor updates (safer):**
From the `pnpm outdated` output, identify all dependencies where the update is a patch or minor version bump. Update them in batch:
1. Frontend: For each patch/minor outdated package, run `pnpm update <package>@latest --recursive` in the repo root.
2. Backend: For each patch/minor outdated package, run `pnpm update <package>@latest --recursive` in `BACKEND_DIR`.
> **Why `@latest`?** Both projects use `save-exact=true`, so versions are pinned without `^` or `~`. Without `--latest`, `pnpm update` only resolves within the existing range, which for exact versions is a no-op.
**Phase 2 — Major updates (requires careful review):**
For each dependency with a major version update available:
1. Identify the dependency name, current version, and latest version.
2. **Read the changelog** (Step 3) before updating.
3. Only update after confirming no blocking breaking changes.
4. Use `pnpm update <package>@latest --recursive` to update specific packages.
**Important pnpm workspace notes:**
- The frontend project uses `pnpm catalog` in `pnpm-workspace.yaml` for some shared versions. If a dependency is managed via catalog, update the version in `pnpm-workspace.yaml` instead of individual `package.json` files.
- Both projects use `save-exact=true`, so versions are pinned without `^` or `~`.
- Check `patchedDependencies` in `pnpm-workspace.yaml` or `package.json` — if a patched dependency is being updated, verify the patch still applies or remove it if no longer needed.
## Step 3: Review changelogs for major updates
For each dependency with a **major version update**, you MUST read the changelog before updating.
### How to find changelogs
Use these methods in order of preference:
1. **npm registry**: Run `npm view <package> repository.url` to find the repo, then check for `CHANGELOG.md` or GitHub releases.
2. **GitHub releases**: Search `https://github.com/<owner>/<repo>/releases` using WebFetch.
3. **Web search**: Use WebSearch to find `<package> changelog <old-version> to <new-version>`.
### What to look for
- **Breaking changes**: API removals, renamed exports, changed defaults, dropped Node.js version support.
- **Deprecated features**: Features being removed in future versions.
- **Migration guides**: Official upgrade instructions.
- **Peer dependency changes**: New or changed peer dependency requirements.
### Document findings
For each major update, record:
- Package name and version change (e.g., `foo: 2.x → 3.x`)
- Breaking changes summary
- Whether our code is affected (and how)
## Step 4: Check affected code
For each dependency with breaking changes identified in Step 3:
1. Use `Grep` to find all imports and usages of the affected package across the relevant project (frontend or backend).
2. Read the files containing usages.
3. Compare the usage against the breaking change description.
4. If our code uses an affected API:
- Attempt to fix the code following the migration guide.
- If the fix is complex or risky, **skip updating this dependency** and note it in the summary.
5. If our code does NOT use any affected API, proceed with the update.
## Step 5: Run tests and checks
After all updates are applied:
### Frontend
Run these commands sequentially in the repo root and capture results:
```bash
pnpm install
pnpm typecheck
pnpm test
pnpm lint
```
### Backend
Run these commands sequentially in `BACKEND_DIR` and capture results:
```bash
pnpm install
pnpm typecheck
pnpm test
pnpm lint
```
### Handle failures
- **TypeScript errors**: Read the error output, identify which updated dependency caused the issue, and fix the type errors. If unfixable, revert that specific dependency update.
- **Test failures**: Analyze the failure, check if it's related to a dependency update, and fix or revert.
- **Lint errors**: Run `pnpm lint:fix` first. If issues persist, fix manually or revert the causing update.
Repeat the test cycle until all checks pass.
## Step 6: Summary
Present the user with a comprehensive summary:
### Update report
```
## Dependencies Updated
### Frontend
- <package>: <old-version><new-version> (patch/minor/major)
- ...
### Backend
- <package>: <old-version><new-version> (patch/minor/major)
- ...
## Skipped Updates (with reasons)
- <package>: <reason why not updated>
- ...
## Key Changelog Highlights
### Breaking Changes Applied
- <package> <version>: <what changed and how we adapted>
### Notable New Features
- <package> <version>: <brief description>
### Deprecation Warnings
- <package> <version>: <what's deprecated and timeline>
## Test Results
- Frontend typecheck: ✅/❌
- Frontend tests: ✅/❌
- Frontend lint: ✅/❌
- Backend typecheck: ✅/❌
- Backend tests: ✅/❌
- Backend lint: ✅/❌
```
Ask the user if they want to commit the changes.

View File

@ -1,27 +0,0 @@
---
description:
globs:
alwaysApply: true
---
You are writing a UI modernized, AI-driven, user-friendly RSS reader.
This project is a monorepo for web front-end electron and React Native.
Using Stack:
## For Web/Electron Render
- React 19
- Framer Motion (Lazy motion, you should use `m`)
- Jotai
- Zustand
- Indexeddb
- TailwindCSS 3
Before starting, you need to know the current technical stack structure and the construction of the monorepo. Read tailwindcss to understand the design style.
## For React Native
- React 19
- Expo
- Expo Module Core (Some Native modules included)

View File

@ -1,112 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# Role
Act as a highly experienced software developer and coding assistant. You are proficient in all major programming languages and frameworks. Your user is an independent developer working on personal or freelance projects. Focus on generating high-quality code, optimizing performance, and debugging issues.
---
# Objective
Efficiently assist the user in writing and improving code, proactively solving technical issues without needing repeated prompting. Focus on the following core tasks:
- Writing code
- Optimizing code
- Debugging and issue resolution
Ensure all solutions are clearly explained and easy to understand.
---
## Phase 1: Initial Assessment
1. When the user requests a task, check for existing documentation (e.g., `README.md`) to understand the project.
2. If no documentation is found, generate a `README.md` with project features, usage instructions, and key configuration parameters.
3. Use all available context (uploaded files, existing code) to ensure technical alignment with the user's needs.
---
## Phase 2: Implementation
### 1. Clarify Requirements
- Confirm user requirements clearly. Ask questions when uncertain.
- Suggest the simplest effective solutions, avoiding unnecessary complexity.
### 2. Writing Code
- Review existing code and outline implementation steps.
- Choose the appropriate language and framework. Follow best practices (e.g., SOLID principles).
- Write clean, readable, and commented code.
- Optimize for clarity, maintainability, and performance.
- Include unit tests when applicable.
- Follow standard language-specific style guides (e.g., PEP 8 for Python, Airbnb for JavaScript).
### 3. Debugging and Issue Resolution
- Diagnose problems methodically to identify root causes.
- Clearly explain the issue and proposed fix.
- Keep the user informed of progress and adapt quickly to changes.
---
## Phase 3: Completion and Summary
1. Summarize key changes and improvements.
2. Highlight potential risks, edge cases, or performance concerns.
3. Update documentation (e.g., `README.md`) accordingly.
---
# Best Practices
### Sequential Thinking (Step-Based Problem Solving Framework)
Use the [Sequential Thinking](https://github.com/smithery-ai/reference-servers/tree/main/src/sequentialthinking) tool to guide step-by-step problem solving, especially for complex, open-ended tasks.
- Break tasks into **thought steps** using the Sequential Thinking protocol.
- For each step, follow this structure:
1.**Define the current goal or assumption** (e.g., "Evaluate authentication options", "Refactor state handling").
2.**Use a suitable MCP tool** based on context (e.g., `search_docs`, `code_generator`, `error_explainer`).
3.**Record the result/output** clearly.
4.**Determine the next thought step** and continue.
- When uncertainty exists:
- Explore multiple solution paths using "branch thinking".
- Compare trade-offs or competing strategies.
- Allow rollback or edits to previous thought steps.
- Use metadata such as:
-`thought`: current thought text
-`thoughtNumber`: current step index
-`totalThoughts`: number of expected steps
- Encourage interactive feedback and continuous iteration throughout the sequence.
### Context7 (Up-to-Date Documentation Integration)
Utilize [Context7](https://github.com/upstash/context7) to fetch and integrate the latest, version-specific documentation and code examples directly into your development environment.
-**Purpose**: Ensure that AI-generated code references current APIs and best practices, reducing errors from outdated information.
-**Usage**:
1.**Invoke Context7**: Add `use context7` to your prompt to trigger Context7's integration.
2.**Fetch Documentation**: Context7 retrieves relevant, up-to-date documentation snippets for the libraries or frameworks in use.
3.**Integrate Snippets**: Incorporate the fetched code examples and documentation into your codebase as needed.
-**Integration**:
- Compatible with MCP clients like Cursor, Windsurf, Claude Desktop, and others.
- Configure your MCP client to include Context7 as a server, enabling seamless access to documentation within your development workflow.
-**Benefits**:
- Reduces reliance on outdated training data.
- Minimizes code hallucinations and deprecated API usage.
- Enhances code accuracy and relevance.
---
# Communication
- Ask questions when clarification is needed.
- Remain concise, technical, and helpful.
- Include inline code comments where necessary.

View File

@ -1,191 +0,0 @@
---
description:
globs: apps/desktop/**/*,packages/internal/components/**/*
alwaysApply: false
---
# UIKit Colors for Tailwind CSS
You should use @https://github.com/Innei/apple-uikit-colors/blob/main/packages/uikit-colors/macos.ts TailwindCSS atom classname.
## System Colors
red
orange
yellow
green
mint
teal
cyan
blue
indigo
purple
pink
brown
gray
## Fill Colors
fill
fill-secondary
fill-tertiary
fill-quaternary
fill-quinary
fill-vibrant
fill-vibrant-secondary
fill-vibrant-tertiary
fill-vibrant-quaternary
fill-vibrant-quinary
## Text Colors
text
text-secondary
text-tertiary
text-quaternary
text-quinary
text-vibrant
text-vibrant-secondary
text-vibrant-tertiary
text-vibrant-quaternary
text-vibrant-quinary
## Material Colors
material-ultra-thick
material-thick
material-medium
material-thin
material-ultra-thin
material-opaque
## Control Colors
control-enabled
control-disabled
## Interface Colors
menu
popover
titlebar
sidebar
selection-focused
selection-focused-fill
selection-unfocused
selection-unfocused-fill
header-view
tooltip
under-window-background
## Applied Colors
All above tailwind atom will match this colors.
```
@media (prefers-color-scheme: light) {
html {
--color-red: 255 69 58;
--color-orange: 255 149 0;
--color-yellow: 255 204 0;
--color-green: 40 205 65;
--color-mint: 0 199 190;
--color-teal: 89 173 196;
--color-cyan: 85 190 240;
--color-blue: 0 122 255;
--color-indigo: 88 86 214;
--color-purple: 175 82 222;
--color-pink: 255 45 85;
--color-brown: 162 132 94;
--color-gray: 142 142 147;
--color-fill: 0 0 0 / 0.1;
--color-fillSecondary: 0 0 0 / 0.08;
--color-fillTertiary: 0 0 0 / 0.05;
--color-fillQuaternary: 0 0 0 / 0.03;
--color-fillQuinary: 0 0 0 / 0.02;
--color-fillVibrant: 217 217 217;
--color-fillVibrantSecondary: 230 230 230;
--color-fillVibrantTertiary: 242 242 242;
--color-fillVibrantQuaternary: 247 247 247;
--color-fillVibrantQuinary: 251 251 251;
--color-text: 0 0 0 / 0.85;
--color-textSecondary: 0 0 0 / 0.5;
--color-textTertiary: 0 0 0 / 0.25;
--color-textQuaternary: 0 0 0 / 0.1;
--color-textQuinary: 0 0 0 / 0.05;
--color-textVibrant: 76 76 76;
--color-textVibrantSecondary: 128 128 128;
--color-textVibrantTertiary: 191 191 191;
--color-textVibrantQuaternary: 230 230 230;
--color-textVibrantQuinary: 242 242 242;
--color-materialUltraThick: 246 246 246 / 0.84;
--color-materialThick: 246 246 246 / 0.72;
--color-materialMedium: 246 246 246 / 0.6;
--color-materialThin: 246 246 246 / 0.48;
--color-materialUltraThin: 246 246 246 / 0.36;
--color-materialOpaque: 246 246 246;
--color-controlEnabled: 251 251 251;
--color-controlDisabled: 243 243 243;
--color-menu: 40 40 40 / 0.58;
--color-popover: 0 0 0 / 0.28;
--color-titlebar: 234 234 234 / 0.8;
--color-sidebar: 234 234 234 / 0.84;
--color-selectionFocused: 10 130 255 / 0.75;
--color-selectionFocusedFill: 10 130 255;
--color-selectionUnfocused: 0 0 0 / 0.1;
--color-selectionUnfocusedFill: 246 246 246 / 0.84;
--color-headerView: 255 255 255 / 0.8;
--color-tooltip: 246 246 246 / 0.6;
--color-underWindowBackground: 246 246 246 / 0.84;
}
}
@media (prefers-color-scheme: dark) {
html {
--color-red: 255 69 58;
--color-orange: 255 159 10;
--color-yellow: 255 214 10;
--color-green: 50 215 75;
--color-mint: 106 196 220;
--color-teal: 106 196 220;
--color-cyan: 90 200 245;
--color-blue: 10 132 255;
--color-indigo: 94 92 230;
--color-purple: 191 90 242;
--color-pink: 255 55 95;
--color-brown: 172 142 104;
--color-gray: 152 152 157;
--color-fill: 255 255 255 / 0.1;
--color-fillSecondary: 255 255 255 / 0.08;
--color-fillTertiary: 255 255 255 / 0.05;
--color-fillQuaternary: 255 255 255 / 0.03;
--color-fillQuinary: 255 255 255 / 0.02;
--color-fillVibrant: 36 36 36;
--color-fillVibrantSecondary: 20 20 20;
--color-fillVibrantTertiary: 13 13 13;
--color-fillVibrantQuaternary: 9 9 9;
--color-fillVibrantQuinary: 7 7 7;
--color-text: 255 255 255 / 0.85;
--color-textSecondary: 255 255 255 / 0.5;
--color-textTertiary: 255 255 255 / 0.25;
--color-textQuaternary: 255 255 255 / 0.1;
--color-textQuinary: 255 255 255 / 0.05;
--color-textVibrant: 229 229 229;
--color-textVibrantSecondary: 124 124 124;
--color-textVibrantTertiary: 65 65 65;
--color-textVibrantQuaternary: 35 35 35;
--color-textVibrantQuinary: 17 17 17;
--color-materialUltraThick: 40 40 40 / 0.84;
--color-materialThick: 40 40 40 / 0.72;
--color-materialMedium: 40 40 40 / 0.6;
--color-materialThin: 40 40 40 / 0.48;
--color-materialUltraThin: 40 40 40 / 0.36;
--color-materialOpaque: 40 40 40;
--color-controlEnabled: 255 255 255 / 0.2;
--color-controlDisabled: 255 255 255 / 0.1;
--color-menu: 246 246 246 / 0.72;
--color-popover: 246 246 246 / 0.6;
--color-titlebar: 60 60 60 / 0.8;
--color-sidebar: 0 0 0 / 0.45;
--color-selectionFocused: 10 130 255 / 0.75;
--color-selectionFocusedFill: 10 130 255;
--color-selectionUnfocused: 255 255 255 / 0.1;
--color-selectionUnfocusedFill: 40 40 40 / 0.65;
--color-headerView: 30 30 30 / 0.8;
--color-tooltip: 0 0 0 / 0.35;
--color-underWindowBackground: 0 0 0 / 0.45;
}
}
```

View File

@ -1,135 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Header Button Design System
When creating header buttons for media previews, overlays, or modal interfaces, follow this modern glass morphism design pattern:
## Design Principles
### 1. Glass Morphism Style
- Use semi-transparent backgrounds: `bg-black/20` or `bg-white/10`
- Apply backdrop blur: `backdrop-blur-md`
- Add subtle borders: `border border-white/10` for depth
- Include shadow layers: `shadow-lg shadow-black/25`
### 2. Perfect 1:1 Circular Design
- Always use `size-10` (40px × 40px) for consistent sizing
- Apply `rounded-full` for perfect circular shape
- Ensure proper centering with `flex items-center justify-center`
### 3. Layered Depth Effects
```tsx
{/* Glass effect overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-t from-white/5 to-white/20 opacity-0 transition-opacity duration-300 hover:opacity-100" />
{/* Icon container */}
<div className="center relative z-10 flex">{children}</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/10" />
```
### 4. Interactive Animation
- Use Framer Motion `m.button` for smooth animations
- Scale on hover: `whileHover={{ scale: 1.1 }}`
- Scale on tap: `whileTap={{ scale: 0.95 }}`
- Spring transitions: `stiffness: 400, damping: 30`
### 5. Opacity and Visibility
- Start hidden: `opacity-0`
- Show on group hover: `group-hover/left:opacity-100`
- Use `transition-all duration-300 ease-out` for smooth reveals
## Implementation Pattern
```tsx
const HeaderButton: FC<{
description?: string
onClick: () => void
className?: string
children: React.ReactNode
}> = ({ description, onClick, className, children }) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<m.button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(
// Base styles with modern glass morphism - perfect 1:1 circle
"pointer-events-auto relative flex size-10 items-center justify-center rounded-full",
"bg-black/20 text-white backdrop-blur-md",
// Border and shadow for depth
"border border-white/10 shadow-lg shadow-black/25",
// Opacity and transition
"opacity-0 transition-all duration-300 ease-out group-hover/left:opacity-100",
// Text size
"text-lg",
className,
)}
initial={{ scale: 1 }}
whileHover={{
scale: 1.1,
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderColor: "rgba(255, 255, 255, 0.2)",
}}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
>
{/* Glass effect overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-t from-white/5 to-white/20 opacity-0 transition-opacity duration-300 hover:opacity-100" />
{/* Icon container */}
<div className="center relative z-10 flex">{children}</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/10" />
</m.button>
</TooltipTrigger>
{description && (
<TooltipPortal>
<TooltipContent>{description}</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
}
```
## Special Variants
### Close Button (Danger State)
```tsx
className="!bg-red-600/30 !border-red-500/20 !opacity-100 hover:!bg-red-600/50"
```
### Navigation Buttons (Carousel Controls)
- Use smaller sizes: `size-8` for mobile, `lg:size-10` for desktop
- Position absolutely with proper spacing: `left-2 lg:left-4`
- Maintain same glass morphism principles
## Usage Guidelines
1. **Always use with tooltips** for accessibility
2. **Include stopPropagation** on click handlers to prevent modal dismissal
3. **Make description optional** for navigation buttons that don't need tooltips
4. **Use consistent icon sizing**: `text-lg` for standard, `lg:text-xl` for larger variants
5. **Apply proper z-index**: `z-[100]` for overlay buttons
6. **Group hover patterns**: Use `group-hover/left:opacity-100` for contextual visibility
## Icons
- Always use icons from `@/icons` directory (project standard)
- Common patterns: `i-mgc-close-cute-re`, `i-mgc-external-link-cute-re`, `i-mgc-download-2-cute-re`
- Navigation: `i-mingcute-left-line`, `i-mingcute-right-line`
This design system ensures consistent, modern, and accessible header buttons across all media preview and overlay interfaces.

View File

@ -1,17 +0,0 @@
---
description:
globs: locales/**/*.json
alwaysApply: false
---
i18n Coding Standards.
1. Read and follow https://www.i18next.com/translation-function/formatting
2. Use flat keys. Use `.` to separate. Do not use object form nesting.
3. For languages sensitive to singular and plural, distinguish between them using the `_one` and `_other` forms.
4. In the build stage, flattened dot-separated keys (such as 'exif.custom.rendered.custom') will be automatically converted to nested object objects, which may cause conflicts. For example, 'exif.custom.rendered.custom' may conflict with 'exif.custom.rendered'. Please avoid using such dot-separated flat keys.
5. @locales is located at the root directory and needs to handle all existing languages at the same time.

View File

@ -1,12 +0,0 @@
---
description:
globs: apps/mobile/**/*
alwaysApply: false
---
1. This is an app written in React native.
2. You need to use @/apps/mobile/icons for icons, do not use other icon libraries.
3. You need to use NativewindCSS to write styles, not external StyleSheet.create.
4. You need to use https://github.com/Innei/apple-uikit-colors/tree/main/packages/react-native-uikit-colors for color design.

View File

@ -1,14 +0,0 @@
---
description:
globs: apps/desktop/layer/renderer/src/**/*
alwaysApply: false
---
You need to find the available UI components in the project.
For web applications (apps/dekstop)
Components are located at `packages/internal/components` and `apps/desktop/layer/renderer/src/components/ui`
For rn applications (apps/mobile)
Components are located at `apps/mobile/src/components`

View File

@ -32,8 +32,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 🧹 Claim disk space
run: |
df -h /
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/julia*
df -h /
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
@ -68,7 +78,7 @@ jobs:
- name: 📤 Upload apk Artifact
if: github.event.inputs.profile != 'production'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-android
path: ${{ github.workspace }}/build.apk
@ -76,7 +86,7 @@ jobs:
- name: 📤 Upload aab Artifact
if: github.event.inputs.profile == 'production'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: aab-android
path: ${{ github.workspace }}/build.aab

View File

@ -56,13 +56,13 @@ jobs:
steps:
- name: Check out Git repository Fully
uses: actions/checkout@v5
uses: actions/checkout@v6
if: env.PROD == 'true'
with:
fetch-depth: 0
lfs: true
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v6
if: env.PROD == 'false'
with:
fetch-depth: 1
@ -183,7 +183,7 @@ jobs:
run: pnpm build:render
- name: Upload file (macos-arm64-dmg)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'macOS'
with:
name: macos-arm64-dmg
@ -193,7 +193,7 @@ jobs:
retention-days: 90
- name: Upload file (macos-x64-dmg)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'macOS'
with:
name: macos-x64-dmg
@ -203,7 +203,7 @@ jobs:
retention-days: 90
- name: Upload file (macos-mas-pkg)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'macOS'
with:
name: macos-mas-pkg
@ -212,7 +212,7 @@ jobs:
retention-days: 90
- name: Upload file (windows-x64-exe unsigned)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
id: upload-unsigned-windows-x64-exe
if: runner.os == 'windows'
with:
@ -222,7 +222,7 @@ jobs:
apps/desktop/out/make/**/latest.yml
retention-days: 90
- uses: signpath/github-action-submit-signing-request@v1.3
- uses: signpath/github-action-submit-signing-request@v2.0
continue-on-error: true
if: runner.os == 'windows' && env.RELEASE == 'true'
with:
@ -239,7 +239,7 @@ jobs:
run: npx tsx apps/desktop/scripts/update-windows-yml.ts
- name: Upload file (windows-x64-exe signed)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'windows' && env.RELEASE == 'true'
with:
name: windows-x64-exe
@ -250,7 +250,7 @@ jobs:
overwrite: true
- name: Upload file (windows-x64-appx)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'windows'
with:
name: windows-x64-appx
@ -259,7 +259,7 @@ jobs:
retention-days: 90
- name: Upload file (linux-x64-appimage)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: runner.os == 'linux'
with:
name: linux-x64-appimage
@ -271,7 +271,7 @@ jobs:
- name: Generate artifact attestation
if: env.RELEASE == 'true'
continue-on-error: true
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@v3
with:
subject-path: |
apps/desktop/out/make/**/Folo-*.dmg

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 📱 Setup EAS
uses: expo/expo-github-action@v8
@ -60,7 +60,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -78,7 +78,7 @@ jobs:
steps:
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 🔧 Setup Xcode
uses: ./.github/actions/setup-xcode
@ -109,7 +109,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -122,7 +122,7 @@ jobs:
steps:
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 🔧 Setup Xcode
uses: ./.github/actions/setup-xcode
@ -153,7 +153,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-ios-development-simulator
path: apps/mobile/build-simulator.ipa

View File

@ -47,7 +47,7 @@ jobs:
steps:
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 📱 Setup EAS
uses: expo/expo-github-action@v8
@ -67,7 +67,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-ios
path: apps/mobile/build.ipa
@ -90,7 +90,7 @@ jobs:
steps:
- name: 📦 Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: 🔧 Setup Xcode
uses: ./.github/actions/setup-xcode
@ -121,7 +121,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-ios
path: apps/mobile/build.ipa

View File

@ -17,11 +17,11 @@ jobs:
node-version: [lts/*]
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
lfs: true
- name: Cache turbo build setup
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Parse issue form
uses: stefanbuck/github-issue-parser@v3

View File

@ -22,11 +22,11 @@ jobs:
node-version: [lts/*]
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
lfs: true
- name: Cache turbo build setup
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Prepare prompt variables
id: prepare_input

View File

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@ -17,7 +17,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -51,6 +51,12 @@ pnpm run test
- Run the above at the root, or use per-package variants as needed.
- Follow this order strictly: typecheck → lint → test.
- After every modification, run the following checks to catch errors early:
```bash
npm exec turbo run format:check typecheck lint
npm exec turbo run test
```
## Code style and conventions
@ -58,6 +64,7 @@ pnpm run test
- Prefer CSS transitions/animations for simple UI interactions. Use JS-driven motion only when necessary to avoid frame drops.
- Imports: use `pathe` instead of `node:path` for crossplatform paths.
- Organize shared, reusable UI in `packages/internal/components`; app-specific UI stays in its app.
- **Style extraction**: Avoid inline styles in JSX. Extract complex styles (especially those using CSS variables, gradients, or multiple properties) to external style objects similar to React Native's `StyleSheet.create`. Place style objects in a `styles.ts` file alongside the component, using `CSSProperties` type for type safety.
## Team preferences

View File

@ -153,11 +153,11 @@ To develop native iOS modules, follow these steps:
Join our community to discuss ideas, ask questions, and share your contributions:
- [Discord](https://discord.gg/followapp)
- [Discord](https://discord.gg/AwWcAQ7euc)
- [Twitter](https://x.com/intent/follow?screen_name=folo_is)
We look forward to your contributions!
## License
By contributing to Folo, you agree that your contributions will be licensed under the GNU General Public License version 3, with the special exceptions noted in the `README.md`.
By contributing to Folo, you agree that your contributions will be licensed under the GNU Affero General Public License version 3, with the special exceptions noted in the `README.md`.

153
LICENSE
View File

@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -631,50 +629,39 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{one line to give the program's name and a brief idea of what it does.}
Copyright (C) 2024-Present RSSNext
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Folo Copyright (C) 2024-Present RSSNext
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
---
Follow Desktop is licensed under the GNU General Public License version 3 with the addition of the following special exception:
Folo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception:
All content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed.

View File

@ -15,14 +15,12 @@
<a href="https://status.follow.is/" target="_blank"><img src="https://status.follow.is/api/badge/18/uptime?color=%2344CC10&labelColor=black&style=flat-square"/></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/downloads/RSSNext/Folo/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
<a href="https://x.com/intent/follow?screen_name=folo_is"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<a href="https://discord.gg/followapp" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>
<a href="https://discord.gg/AwWcAQ7euc" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>
<br />
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fmobile%2Fpackage.json&style=flat-square&logo=folo&logoColor=white&label=Mobile&labelColor=black&color=FF5C00" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black" /></a>
<a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dis.follow%26gl%3DUS%26hl%3Den%26l%3DAndroid%26m%3D%24version&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5C00"/></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=folo&logoColor=white&label=Desktop&labelColor=black&color=FF5C00" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-mac-app-store-version.rss3.workers.dev%2F&query=version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-microsoft-store-version.rss3.workers.dev%2F&query=version&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600&prefix=v" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmas&query=data.storeVersion&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.folo.is%2Fupdates%2Fdistribution%2Fmss&query=data.storeVersion&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5C00&cacheSeconds=3600&prefix=v" /></a>
<br />
<br />
<!-- <a href="https://github.com/RSSNext/Folo" target="_blank"><img src="https://github.com/user-attachments/assets/59b957fb-59ed-4ef0-994e-f6a402a6fe2b" alt="GitHub Trending" height="55"/></a>
@ -32,6 +30,7 @@
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604" target="_blank"><img src="https://github.com/user-attachments/assets/198a0165-b8c9-45c1-9116-b473a13a8d0c" alt="Folo Desktop" width="46%"/></a>
<br />
<br />
</p>
</div>
@ -54,14 +53,14 @@ Feel free to try it using the following methods:
You can also install using the following methods maintained by our community:
- If you are using Arch Linux, you can install package [folo-appimage](https://aur.archlinux.org/packages/folo-appimage) that maintained by [timochan](https://github.com/ttimochan) and [grtsinry43](https://github.com/grtsinry43).
- If you are using Nix, you can install package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that maintained by [iosmanthus](https://github.com/iosmanthus).
- If you are using macOS with [Homebrew](https://brew.sh), you can install cask [folo](https://formulae.brew.sh/cask/folo) that maintained by [realSunyz](https://github.com/realSunyz).
- If you are using Windows with [Scoop](https://scoop.sh), you can install manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that maintained by [cscnk52](https://github.com/cscnk52).
- If you are using Arch Linux, you can install the package [folo-appimage](https://aur.archlinux.org/packages/folo-appimage) that is maintained by [timochan](https://github.com/ttimochan) and [grtsinry43](https://github.com/grtsinry43).
- If you are using Nix, you can install the package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that is maintained by [iosmanthus](https://github.com/iosmanthus).
- If you are using macOS with [Homebrew](https://brew.sh), you can install the cask [folo](https://formulae.brew.sh/cask/folo) that is maintained by [realSunyz](https://github.com/realSunyz).
- If you are using Windows with [Scoop](https://scoop.sh), you can install the manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that is maintained by [cscnk52](https://github.com/cscnk52).
| [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/followapp) | Join our Discord server to connect with developers, request features, and receive support. |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |
| [![](https://img.shields.io/badge/any_text-Follow-blue?color=2CA5E0&label=_&logo=x&labelColor=black&style=flat-square)](https://x.com/intent/follow?screen_name=folo_is) | Follow us on X/Twitter for product updates and to join in on reward activities. |
| [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/AwWcAQ7euc) | Join our Discord server to connect with developers, request features, and receive support. |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |
| [![](https://img.shields.io/badge/any_text-Follow-blue?color=2CA5E0&label=_&logo=x&labelColor=black&style=flat-square)](https://x.com/intent/follow?screen_name=folo_is) | Follow us on X/Twitter for product updates and to join in on reward activities. |
> \[!IMPORTANT]
>
@ -108,16 +107,14 @@ You are welcome to join the open source community to build together, please chec
## 🔏 Code signing policy
Folo for Windows uses free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
Folo for Windows uses free code signing provided by [SignPath.io](https://about.signpath.io/), a certificate by [SignPath Foundation](https://signpath.org/).
Folo for macOS and iOS are signed and notarized by [Apple Developer Program](https://developer.apple.com/programs/).
Folo for macOS and iOS is signed and notarized by [Apple Developer Program](https://developer.apple.com/programs/).
All released files are verified with [GitHub artifact attestations](https://github.com/RSSNext/Folo/attestations) to ensure their provenance and integrity.
## 📝 License
Folo is licensed under the GNU General Public License version 3 with the addition of the following special exception:
Folo is licensed under the GNU Affero General Public License version 3 with the addition of the following special exception:
All content in the `icons/mgc` directory is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed.
All content in the `lottie` directory is distributed under the [Lottie Simple License](https://lottiefiles.com/page/license).

View File

@ -121,12 +121,12 @@ Follow uses a sophisticated glassmorphic depth design system for elevated UI com
### Color Usage
- **Primary Accent**: `#FF5C00` (orange) at 5-20% opacity for borders, glows, and highlights
- **Border**: `rgba(255, 92, 0, 0.2)` for main borders
- **Inner Glow**: `rgba(255, 92, 0, 0.05)` for subtle radial/linear gradients inside containers
- **Primary Accent**: Use CSS variable `--fo-a` (HSL: `331.7 84% 67%`) at 5-20% opacity for borders, glows, and highlights
- **Border**: `hsl(var(--fo-a) / 0.2)` for main borders
- **Inner Glow**: `hsl(var(--fo-a) / 0.05)` for subtle radial/linear gradients inside containers
- **Shadows**: Layered shadows with accent tint:
- `0 8px 32px rgba(255, 92, 0, 0.08)` - large soft glow
- `0 4px 16px rgba(255, 92, 0, 0.06)` - medium shadow
- `0 8px 32px hsl(var(--fo-a) / 0.08)` - large soft glow
- `0 4px 16px hsl(var(--fo-a) / 0.06)` - medium shadow
- `0 2px 8px rgba(0, 0, 0, 0.1)` - close depth
### Component Structure
@ -139,9 +139,9 @@ Follow uses a sophisticated glassmorphic depth design system for elevated UI com
"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "rgba(255, 92, 0, 0.2)",
borderColor: "hsl(var(--fo-a) / 0.2)",
boxShadow:
"0 8px 32px rgba(255, 92, 0, 0.08), 0 4px 16px rgba(255, 92, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)",
"0 8px 32px hsl(var(--fo-a) / 0.08), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px rgba(0, 0, 0, 0.1)",
}}
>
{/* Inner glow layer */}
@ -149,7 +149,7 @@ Follow uses a sophisticated glassmorphic depth design system for elevated UI com
className="absolute inset-0 rounded-2xl"
style={{
background:
"linear-gradient(to bottom right, rgba(255, 92, 0, 0.05), transparent, rgba(255, 92, 0, 0.05))",
"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.05), transparent, hsl(var(--fo-a) / 0.05))",
}}
/>
@ -166,7 +166,7 @@ For hover states on buttons or interactive areas within glass containers:
<button
onMouseEnter={(e) => {
e.currentTarget.style.background =
"linear-gradient(to right, rgba(255, 92, 0, 0.08), rgba(255, 140, 0, 0.05))"
"linear-gradient(to right, hsl(var(--fo-a) / 0.08), hsl(var(--fo-a) / 0.05))"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent"
@ -185,7 +185,7 @@ Use gradient dividers within glass containers:
<div
className="mx-4 h-px"
style={{
background: "linear-gradient(to right, transparent, rgba(255, 92, 0, 0.2), transparent)",
background: "linear-gradient(to right, transparent, hsl(var(--fo-a) / 0.2), transparent)",
}}
/>
```

View File

@ -0,0 +1,12 @@
# What's new in v0.9.0
## Improvements
- Removed the limit on the maximum number of views (b69a935)
- Allow hiding the “All” view (0882e47)
- Introduced a right-click menu for views, allowing quick access to hide or open settings (60dcd42)
- Added a smooth sliding animation when opening entry details (1720ebb)
## No longer broken
- Fixed an issue where the scrollbar didnt reset when switching between entry details (21124f5)

View File

@ -0,0 +1,19 @@
# What's new in v1.0.0
## Shiny new things
- 🌟 **Folo is now the AI Reader** — a smarter way to follow everything.
## Improvements
- Added support for multilingual `params` in YouTube video previews (#4610)
- Redirects now go to your configured first view instead of always defaulting to All (a82b0e9)
## No longer broken
- Fixed the hover indicator style on the audio player progress bar (#4600)
- Fixed video autoplay in media previews (#4531)
## Thanks
Special thanks to volunteer contributors @yeeway0609 @kovsu @unixzii for their valuable contributions

View File

@ -0,0 +1,28 @@
# What's new in v1.1.0
## Shiny new things
- Reintroduced the clean, efficient three-column layout for a smoother reading experience.
- Added out-of-the-box Fabric integration for MCP users.
## Improvements
- Automatically generate chat titles during live sessions.
- Simplified AI usage metrics display with improved styling.
- Added a progress bar to the onboarding feed subscription step.
- Adopted a more reliable mechanism for checking software updates.
- Windows now starts minimized to the system tray by default.
- Timeline AI summaries now trigger automatically in all views.
- Simplified the AI reasoning display for better clarity.
- Onboarding flow now allows manual skipping.
- Merged AI chat history and AI task history into a single dropdown for easier access.
## No longer broken
- Fixed an issue where the Windows EXE software update check failed to run properly.
- Fixed a bug preventing category movement in the “All” view.
- Fixed occasional AI chat interruptions under certain conditions.
## Thanks
Special thanks to volunteer contributors @kovsu for their valuable contributions

View File

@ -0,0 +1,40 @@
# What's new in v1.2
## Shiny new things
- **AI**
- Added **BYOK (Bring Your Own Key)** support — choose your own AI provider freely.
- Improved **AI Memory**: you can now update or refine past memories whenever you need.
- Reduced the “guiding tone” in **AI Summary** so summaries feel more natural and personal.
- Introduced **AI Timeline Sort (Beta)**: your timeline can now rearrange itself based on your reading preferences.
![](https://cdn.follow.is/ai-memory.mp4)
![](https://cdn.follow.is/share.mp4)
- **UI**
- Several UI components have been refined with an improved design system for a cleaner, more consistent feel.
- **Subscription**
- Added a new **Basic Plan (non-AI version)** for users who prefer a lighter subscription option.
- **Selection**
- Added **underline sharing** and **Ask AI** directly from selected text to make reading actions smoother.
## Improvements
- **Feed**
- RSSHub subscriptions now include a clear identifier, making source management easier.
- **Web**
- You can now browse recommended content without logging in — explore before committing.
- **Onboarding**
- The onboarding flow has been fully redesigned for a smoother, more intuitive first-time experience.
- **Entry**
- Entries now come with AI-generated labels to help you sort and revisit content effortlessly.
## No longer broken
We fixed several bugs to make everything feel more stable and reliable.

View File

@ -0,0 +1,19 @@
# What's new in v1.2.6
## Shiny new things
- Optimize Stripe subscription management UI with dynamic billing portal and status indicators
## Improvements
- Update AI SDK to v6.0.5
- Refresh translation cache on mode changes
## No longer broken
- Fix AI chat message types alignment
- Fix default custom integration fetch
## Thanks
Special thanks to volunteer contributors @TonyRL for their valuable contributions

View File

@ -0,0 +1,16 @@
# What's new in v1.3.0
## Shiny new things
- Markdown link supports open share feed link directly in the app
## No longer broken
- Fix preserve session when switching API domain
- Fix handle invalid URL parsing in useFeedSafeUrl hook
- Fix shortcuts page freeze
- Fix icon service URL and image proxy URL
## Thanks
Special thanks to volunteer contributors @cuikaipeng @yjl9903 for their valuable contributions

View File

@ -2,10 +2,12 @@
## Shiny new things
- Supported French localization.
## Improvements
## No longer broken
## Thanks
Special thanks to volunteer contributors @ for their valuable contributions
Special thanks to volunteer contributors @AnthonyMahe for their valuable contributions

View File

@ -1,7 +1,6 @@
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { sentryVitePlugin } from "@sentry/vite-plugin"
import react from "@vitejs/plugin-react"
import { codeInspectorPlugin } from "code-inspector-plugin"
import { dirname, resolve } from "pathe"
@ -17,9 +16,6 @@ import i18nCompleteness from "../plugins/vite/utils/i18n-completeness"
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const pkg = JSON.parse(readFileSync(resolve(pkgDir, "./package.json"), "utf8"))
const isCI = process.env.CI === "true" || process.env.CI === "1"
const mode = process.argv.find((arg) => arg.startsWith("--mode"))?.split("=")[1]
const isStaging = mode === "staging"
const getChangelogFileContent = () => {
const { version: pkgVersion } = pkg
@ -76,36 +72,6 @@ export const viteRenderBaseConfig = {
}),
circularImportRefreshPlugin(),
sentryVitePlugin({
org: "follow-rg",
project: "follow",
disable: !isCI,
bundleSizeOptimizations: {
excludeDebugStatements: true,
// Only relevant if you added `replayIntegration`
excludeReplayIframe: true,
excludeReplayShadowDom: true,
excludeReplayWorker: true,
},
moduleMetadata: {
appVersion: process.env.NODE_ENV === "development" ? "dev" : pkg.version,
electron: false,
},
sourcemaps: {
filesToDeleteAfterUpload: isStaging
? []
: [
"out/web/assets/*.js.map",
"out/web/vendor/*.js.map",
"out/rn-web/assets/*.js.map",
"out/rn-web/vendor/*.js.map",
"dist/renderer/assets/*.js.map",
"dist/renderer/vendor/*.css.map",
],
},
}),
astPlugin,
customI18nHmrPlugin(),
],

View File

@ -0,0 +1,2 @@
provider: custom
channel: latest

View File

@ -121,7 +121,7 @@ const config: ForgeConfig = {
asar: true,
ignore: [ignorePattern],
prune: true,
prune: false,
extendInfo: {
ITSAppUsesNonExemptEncryption: false,
},

View File

@ -27,35 +27,34 @@
"@follow-app/readability": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@openpanel/web": "1.0.1",
"@sentry/electron": "7.2.0",
"builder-util-runtime": "9.3.1",
"@openpanel/web": "1.0.7",
"builder-util-runtime": "9.5.1",
"electron-context-menu": "4.1.1",
"electron-ipc-decorator": "0.2.0",
"electron-log": "5.4.3",
"electron-squirrel-startup": "1.0.1",
"electron-store": "11.0.2",
"electron-updater": "6.6.2",
"es-toolkit": "1.40.0",
"font-list": "2.0.1",
"i18next": "25.6.0",
"js-yaml": "4.1.0",
"ky": "1.12.0",
"linkedom": "0.18.11",
"electron-updater": "6.7.3",
"es-toolkit": "1.44.0",
"font-list": "2.0.2",
"i18next": "25.8.6",
"js-yaml": "4.1.1",
"ky": "1.14.3",
"linkedom": "0.18.12",
"lowdb": "7.0.1",
"msedge-tts": "2.0.2",
"msedge-tts": "2.0.4",
"node-machine-id": "1.1.12",
"ofetch": "1.4.1",
"ofetch": "1.5.1",
"pathe": "2.0.3",
"semver": "7.7.3",
"tar": "7.5.1",
"semver": "7.7.4",
"tar": "7.5.7",
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22"
},
"devDependencies": {
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@types/js-yaml": "4.0.9",
"@types/node": "24.8.1",
"@types/node": "25.2.3",
"electron": "38.3.0",
"electron-devtools-installer": "4.0.0",
"typescript": "catalog:"

View File

@ -1,14 +1,8 @@
import { app, protocol } from "electron"
import path from "pathe"
import { initializeSentry } from "./sentry"
if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
protocol.registerSchemesAsPrivileged([
{
scheme: "sentry-ipc",
privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
},
{
scheme: "app",
privileges: {
@ -19,5 +13,3 @@ protocol.registerSchemesAsPrivileged([
},
},
])
// Solve Sentry SDK should be initialized before the Electron app 'ready' event is fired
initializeSentry()

View File

@ -81,6 +81,13 @@ export class AppService extends IpcService {
}, 1000)
}
@IpcMethod()
async openExternal(_context: IpcContext, url: string): Promise<void> {
if (!url) return
await shell.openExternal(url)
}
@IpcMethod()
windowAction(context: IpcContext, input: WindowActionInput): void {
if (context.sender.getType() === "window") {

View File

@ -1,10 +1,12 @@
import { env } from "@follow/shared/env.desktop"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import { FollowClient } from "@follow-app/client-sdk"
import PKG from "@pkg"
import PKG, { mainHash, version as appVersion } from "@pkg"
import { gte } from "semver"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { getCurrentRendererManifest } from "~/updater/hot-updater"
import { logger } from "../logger"
@ -20,13 +22,17 @@ export const followClient = new FollowClient({
}),
})
export const followApi = followClient.api
export const apiClient = followClient.api
followClient.addRequestInterceptor(async (ctx) => {
const { options } = ctx
const header = options.headers || {}
const apiHeader = createDesktopAPIHeaders({ version: PKG.version })
const rendererManifest = getCurrentRendererManifest()
const rendererVersion = gte(rendererManifest?.version ?? appVersion, appVersion)
? (rendererManifest?.version ?? appVersion)
: appVersion
// Get cookies for authentication
const window = WindowManager.getMainWindow()
@ -44,10 +50,14 @@ followClient.addRequestInterceptor(async (ctx) => {
...apiHeader,
Cookie: headerCookie,
"User-Agent": userAgent,
"X-Follow-Main-Hash": mainHash,
"X-Follow-Renderer-Version": rendererVersion,
"X-Follow-App-Version": appVersion,
"X-Follow-Platform": process.platform,
}
return ctx
})
followClient.addResponseInterceptor(({ response }) => {
logger.info(`API Response: ${response.status} ${response.statusText}`)
return response
@ -67,14 +77,10 @@ followClient.addResponseInterceptor(async ({ response }) => {
}
try {
const json = await response.clone().json()
logger.error("API Error details:", json)
} catch {
// ignore JSON parsing errors
await response.clone().json()
} catch (error) {
logger.error("API Error details:", error)
}
return response
})
// Legacy export for compatibility
export const apiClient = followApi

View File

@ -0,0 +1,91 @@
import type { Cookie, CookiesSetDetails, Session } from "electron"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { logger } from "../logger"
const LEGACY_PROD_API_URL = "https://api.follow.is"
const BETTER_AUTH_SESSION_DATA_COOKIE_NAME = "better-auth.session_data"
const isBetterAuthSessionTokenCookie = (cookieName: string) => {
return cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN)
}
const isBetterAuthSessionCookie = (cookieName: string) => {
return (
cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) ||
cookieName.includes(BETTER_AUTH_SESSION_DATA_COOKIE_NAME)
)
}
const toCookieSetDetails = (cookie: Cookie, url: string, domain: string): CookiesSetDetails => {
const details: CookiesSetDetails = {
url,
name: cookie.name,
value: cookie.value,
domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
}
if (!cookie.session && cookie.expirationDate) {
details.expirationDate = cookie.expirationDate
}
return details
}
export const migrateAuthCookiesToNewApiDomain = async (
cookieSession: Session,
options: {
currentApiURL: string
legacyApiURL?: string
},
) => {
const legacyApiURL = options.legacyApiURL ?? LEGACY_PROD_API_URL
if (!options.currentApiURL || options.currentApiURL === legacyApiURL) {
return
}
const currentHost = new URL(options.currentApiURL).hostname
const legacyHost = new URL(legacyApiURL).hostname
if (currentHost === legacyHost) {
return
}
const currentDomainCookies = await cookieSession.cookies.get({
domain: currentHost,
})
const hasCurrentDomainSessionTokenCookie = currentDomainCookies.some((cookie) =>
isBetterAuthSessionTokenCookie(cookie.name),
)
if (hasCurrentDomainSessionTokenCookie) {
return
}
const legacyDomainCookies = await cookieSession.cookies.get({
domain: legacyHost,
})
const legacySessionCookies = legacyDomainCookies.filter((cookie) =>
isBetterAuthSessionCookie(cookie.name),
)
if (legacySessionCookies.length === 0) {
return
}
await Promise.all(
legacySessionCookies.map((cookie) => {
return cookieSession.cookies.set(
toCookieSetDetails(cookie, options.currentApiURL, currentHost),
)
}),
)
logger.info(
`Migrated ${legacySessionCookies.length} auth cookie(s) from ${legacyHost} to ${currentHost}`,
)
}

View File

@ -113,7 +113,7 @@ const destroyAppTray = () => {
}
}
const DEFAULT_MINIMIZE_TO_TRAY = isMacOS ? false : true
const DEFAULT_MINIMIZE_TO_TRAY = false
export const getTrayConfig = () => store.get("minimizeToTray") ?? DEFAULT_MINIMIZE_TO_TRAY

View File

@ -13,6 +13,7 @@ import { join } from "pathe"
import { WindowManager } from "~/manager/window"
import { isMacOS } from "../env"
import { migrateAuthCookiesToNewApiDomain } from "../lib/auth-cookie-migration"
import { handleUrlRouting } from "../lib/router"
import { store } from "../lib/store"
import { updateNotificationsToken } from "../lib/user"
@ -81,6 +82,10 @@ export class BootstrapManager {
callback({ cancel: false, requestHeaders: details.requestHeaders })
})
await migrateAuthCookiesToNewApiDomain(session.defaultSession, {
currentApiURL: env.VITE_API_URL,
})
// Bypass CORS for PostHog analytics
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const url = new URL(details.url)

View File

@ -316,7 +316,7 @@ class WindowManagerStatic {
} else {
// Production entry
const dynamicRenderEntry = loadDynamicRenderEntry()
logger.info("load dynamic render entry", dynamicRenderEntry)
if (dynamicRenderEntry) logger.info("load dynamic render entry", dynamicRenderEntry)
const appLoadFileEntry =
dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")

View File

@ -1,41 +0,0 @@
import { captureConsoleIntegration, init, setTag } from "@sentry/electron/main"
import { app } from "electron"
import { FetchError } from "ofetch"
import { DEVICE_ID } from "./constants/system"
export const initializeSentry = () => {
init({
dsn: process.env.VITE_SENTRY_DSN,
integrations: [
captureConsoleIntegration({
levels: ["error"],
}),
],
beforeSend(event, hint) {
const error = hint.originalException
if (
error instanceof Error &&
(/Network Error/i.test(error.message) ||
/Fetch Error/i.test(error.message) ||
/XHR Error/i.test(error.message) ||
/adsbygoogle/i.test(error.message) ||
/Failed to fetch/i.test(error.message) ||
error.message.includes("fetch failed"))
) {
return null
}
if (error instanceof FetchError) {
return null
}
return event
},
})
setTag("device_id", DEVICE_ID)
setTag("app_version", app.getVersion())
setTag("build", "electron")
}

View File

@ -0,0 +1,26 @@
import type {
DistributionStatusPayload,
GetLatestReleaseQuery,
LatestReleasePayload,
} from "@follow-app/client-sdk"
import { apiClient } from "~/lib/api-client"
let cachedLatestRelease: LatestReleasePayload | null = null
export const getUpdateInfo = async (
query: GetLatestReleaseQuery = {},
): Promise<LatestReleasePayload> => {
const response = await apiClient.updates.getLatestRelease(query)
cachedLatestRelease = response.data
return cachedLatestRelease
}
export const getDistributionUpdateInfo = async (): Promise<DistributionStatusPayload | null> => {
const distribution = process.mas ? "mas" : process.windowsStore ? "mss" : undefined
if (!distribution) {
return null
}
const response = await apiClient.updates.getDistributionStatus({ distribution })
return response.data
}

View File

@ -1,21 +1,15 @@
import { DEV, MICROSOFT_STORE_BUILD, MODE, ModeEnum } from "@follow/shared/constants"
import path from "pathe"
import { isWindows } from "../env"
const isStoreDistribution = Boolean(process.mas || MICROSOFT_STORE_BUILD)
export const appUpdaterConfig = {
// Disable renderer hot update will trigger app update when available
enableRenderHotUpdate: !DEV && MODE !== ModeEnum.staging,
enableCoreUpdate:
!process.mas &&
!MICROSOFT_STORE_BUILD &&
// Disable core update if platfrom is windows and application is't executing from default installion path.
// If the process is not executed from the default installation path,
// it is usually managed through a package manager like Scoop.
// In this case, updates need to be disabled.
!(isWindows && path.resolve(process.execPath, "../../") !== process.env.LOCALAPPDATA),
enableCoreUpdate: !isStoreDistribution,
// Disable app update will also disable renderer hot update and core update
enableAppUpdate: !DEV,
enableAppUpdate: true,
enableDistributionStoreUpdate: isStoreDistribution,
app: {
autoCheckUpdate: true,

View File

@ -1,413 +0,0 @@
// credits: migrated from https://github.com/toeverything/AFFiNE/blob/a802dc4fd6aa720f7a7a995816c78c0b24c514f5/packages/frontend/electron/src/main/updater/custom-github-provider.ts
import { URL } from "node:url"
import type {
CustomPublishOptions,
GithubOptions,
ReleaseNoteInfo,
XElement,
} from "builder-util-runtime"
import { HttpError, newError, parseXml } from "builder-util-runtime"
import type { AppUpdater, ResolvedUpdateFileInfo, UpdateInfo } from "electron-updater"
import { CancellationToken } from "electron-updater"
import { BaseGitHubProvider } from "electron-updater/out/providers/GitHubProvider"
import type { ProviderRuntimeOptions } from "electron-updater/out/providers/Provider"
import { parseUpdateInfo, resolveFiles } from "electron-updater/out/providers/Provider"
import * as semver from "semver"
import { isWindows } from "../env"
import { githubProviderLogger as logger, logObject } from "./logger"
import { isSquirrelBuild } from "./utils"
interface GithubUpdateInfo extends UpdateInfo {
tag: string
}
interface GithubRelease {
id: number
tag_name: string
target_commitish: string
name: string
draft: boolean
prerelease: boolean
created_at: string
published_at: string
}
const hrefRegExp = /\/tag\/([^/]+)$/
export class CustomGitHubProvider extends BaseGitHubProvider<GithubUpdateInfo> {
constructor(
options: CustomPublishOptions,
private readonly updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions,
) {
super(options as unknown as GithubOptions, "github.com", runtimeOptions)
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
logger.info("Starting getLatestVersion")
logObject(logger, "Provider Configuration", {
"Base URL": this.baseUrl.href,
"Base Path": this.basePath,
"Current Version": this.updater.currentVersion,
"Allow Prerelease": this.updater.allowPrerelease,
})
const cancellationToken = new CancellationToken()
const feedUrl = newUrlFromBase(`${this.basePath}.atom`, this.baseUrl)
logger.info(`Fetching feed from: ${feedUrl.href}`)
const feedXml = await this.httpRequest(
feedUrl,
{
accept: "application/xml, application/atom+xml, text/xml, */*",
},
cancellationToken,
)
if (!feedXml) {
throw new Error(`Cannot find feed in the remote server (${this.baseUrl.href})`)
}
logger.info(`Feed fetched successfully, length: ${feedXml.length} bytes`)
const feed = parseXml(feedXml)
// noinspection TypeScriptValidateJSTypes
let latestRelease = feed.element("entry", false, `No published versions on GitHub`)
let tag: string | null = null
try {
const currentChannel =
this.options.channel ||
this.updater?.channel ||
(semver.prerelease(this.updater.currentVersion)?.[0] as string) ||
null
logger.info(`Current Channel: ${currentChannel}`)
if (currentChannel === null) {
throw newError(
`Cannot parse channel from version: ${this.updater.currentVersion}`,
"ERR_UPDATER_INVALID_VERSION",
)
}
logger.info(`Fetching latest tag by release API for channel: ${currentChannel}`)
const releaseTag = await this.getLatestTagByRelease(currentChannel, cancellationToken)
logger.info(`Release tag from API: ${releaseTag || "null (will use feed matching)"}`)
logger.info("Iterating through feed entries to find matching release")
let entryCount = 0
for (const element of feed.getElements("entry")) {
entryCount++
// noinspection TypeScriptValidateJSTypes
const href = element.element("link").attribute("href")
const hrefElement = hrefRegExp.exec(href)
// If this is null then something is wrong and skip this release
if (hrefElement === null) {
logger.warn(`Entry #${entryCount}: Invalid href format: ${href}`)
continue
}
// This Release's Tag
const hrefTag = hrefElement[1]!
logger.debug(`Entry #${entryCount}: Processing tag: ${hrefTag}`)
// Get Channel from this release's tag
// Handle new format: desktop/v1.2.3 or mobile/v1.2.3
let hrefChannel = "stable"
if (hrefTag.startsWith("desktop/")) {
// For desktop tags, extract the version and check if it's a prerelease
const version = hrefTag.replace("desktop/", "")
hrefChannel = (semver.prerelease(version)?.[0] as string) || "stable"
logger.debug(
`Entry #${entryCount}: Desktop tag detected, version: ${version}, channel: ${hrefChannel}`,
)
} else if (hrefTag.startsWith("mobile/")) {
// Skip mobile releases for desktop updater
logger.debug(`Entry #${entryCount}: Skipping mobile tag`)
continue
} else {
// Legacy format: check for prerelease directly
hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || "stable"
logger.debug(`Entry #${entryCount}: Legacy tag format, channel: ${hrefChannel}`)
}
let isNextPreRelease = false
if (releaseTag) {
isNextPreRelease = releaseTag === hrefTag
logger.debug(`Entry #${entryCount}: Matching by release tag: ${releaseTag === hrefTag}`)
} else {
isNextPreRelease = hrefChannel === currentChannel
logger.debug(
`Entry #${entryCount}: Matching by channel: ${hrefChannel} === ${currentChannel} = ${isNextPreRelease}`,
)
}
if (isNextPreRelease) {
tag = hrefTag
latestRelease = element
logger.info(`✓ Found matching release at entry #${entryCount}: ${hrefTag}`)
break
}
}
logger.info(`Processed ${entryCount} feed entries total`)
} catch (e: any) {
logger.error(`Failed to parse releases feed: ${e.stack || e.message}`)
throw newError(
`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`,
"ERR_UPDATER_INVALID_RELEASE_FEED",
)
}
if (tag === null || tag === undefined) {
logger.error("No matching published versions found on GitHub")
throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS")
}
let rawData: string | null = null
let channelFile = ""
let channelFileUrl: any = ""
const fetchData = async (channelName: string) => {
channelFile = getChannelFilename(channelName)
channelFileUrl = newUrlFromBase(
this.getBaseDownloadPath(String(tag), channelFile),
this.baseUrl,
)
logger.info(`Fetching channel file: ${channelFile} from ${channelFileUrl}`)
const requestOptions = this.createRequestOptions(channelFileUrl)
try {
const data = await this.executor.request(requestOptions, cancellationToken)
logger.info(`Successfully fetched ${channelFile}`)
return data
} catch (e: any) {
if (e instanceof HttpError && e.statusCode === 404) {
logger.warn(`Channel file not found: ${channelFile} (404)`)
throw newError(
`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${
e.stack || e.message
}`,
"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
)
}
logger.error(`Failed to fetch channel file: ${e.message}`)
throw e
}
}
try {
const channel = this.updater.allowPrerelease
? this.getCustomChannelName(String(semver.prerelease(tag)?.[0] || "latest"))
: this.getDefaultChannelName()
logger.info(`Attempting to fetch channel: ${channel}`)
rawData = await fetchData(channel)
} catch (e: any) {
if (this.updater.allowPrerelease) {
// Allow fallback to `latest.yml`
logger.info("Falling back to default channel (latest.yml)")
rawData = await fetchData(this.getDefaultChannelName())
} else {
throw e
}
}
const result = parseUpdateInfo(rawData, channelFile, channelFileUrl)
if (result.releaseName == null) {
result.releaseName = latestRelease.elementValueOrEmpty("title")
}
if (result.releaseNotes == null) {
result.releaseNotes = computeReleaseNotes(
this.updater.currentVersion,
this.updater.fullChangelog,
feed,
latestRelease,
)
}
logger.info(`Update info parsed successfully`)
logObject(logger, "Update Result", {
Tag: tag,
Version: result.version,
"Release Name": result.releaseName || "N/A",
"Release Date": result.releaseDate || "N/A",
Files: result.files?.length || 0,
})
return {
tag,
...result,
}
}
private get basePath(): string {
return `/${this.options.owner}/${this.options.repo}/releases`
}
/**
* Use release api to get latest version to filter draft version.
* But this api have low request limit 60-times/1-hour, use this to help, not depend on it
* https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28
* https://api.github.com/repos/toeverything/affine/releases
* https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2022-11-28#about-rate-limits
*/
private async getLatestTagByRelease(
currentChannel: string,
cancellationToken: CancellationToken,
) {
try {
const apiUrl = newUrlFromBase(`/repos${this.basePath}`, this.baseApiUrl)
logger.debug(`Fetching releases from GitHub API: ${apiUrl}`)
const releasesStr = await this.httpRequest(
apiUrl,
{
accept: "Accept: application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
cancellationToken,
)
if (!releasesStr) {
logger.warn("GitHub API returned empty response")
return null
}
const releases: GithubRelease[] = JSON.parse(releasesStr)
logger.debug(`Received ${releases.length} releases from GitHub API`)
let checkedCount = 0
for (const release of releases) {
if (release.draft) {
logger.debug(`Skipping draft release: ${release.tag_name}`)
continue
}
checkedCount++
const releaseTag = release.tag_name
// Handle new format: desktop/v1.2.3 or mobile/v1.2.3
let releaseChannel = "stable"
if (releaseTag.startsWith("desktop/")) {
// For desktop tags, extract the version and check if it's a prerelease
const version = releaseTag.replace("desktop/", "")
releaseChannel = (semver.prerelease(version)?.[0] as string) || "stable"
logger.debug(`API Release: ${releaseTag} (desktop, channel: ${releaseChannel})`)
} else if (releaseTag.startsWith("mobile/")) {
// Skip mobile releases for desktop updater
logger.debug(`Skipping mobile release: ${releaseTag}`)
continue
} else {
// Legacy format: check for prerelease directly
releaseChannel = (semver.prerelease(releaseTag)?.[0] as string) || "stable"
logger.debug(`API Release: ${releaseTag} (legacy, channel: ${releaseChannel})`)
}
if (releaseChannel === currentChannel) {
logger.info(`✓ Found matching release via API: ${release.tag_name}`)
return release.tag_name
}
}
logger.info(`No matching release found via API (checked ${checkedCount} non-draft releases)`)
} catch (e: any) {
logger.warn(`Cannot parse release from API: ${e.message}`)
}
return null
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
logger.info(`Resolving files for tag: ${updateInfo.tag}`)
logger.debug(`Total files in update info: ${updateInfo.files.length}`)
const filteredUpdateInfo = structuredClone(updateInfo)
// for windows, we need to determine its installer type (nsis or squirrel)
if (isWindows && updateInfo.files.length > 1) {
const isSquirrel = isSquirrelBuild()
logger.info(`Windows build detected, installer type: ${isSquirrel ? "Squirrel" : "NSIS"}`)
// @ts-expect-error we should be able to modify the object
filteredUpdateInfo.files = updateInfo.files.filter((file) =>
isSquirrel ? !file.url.includes("nsis.exe") : file.url.includes("nsis.exe"),
)
logger.debug(
`Filtered to ${filteredUpdateInfo.files.length} files after Windows installer type filtering`,
)
}
// still replace space to - due to backward compatibility
const resolved = resolveFiles(filteredUpdateInfo, this.baseUrl, (p) =>
this.getBaseDownloadPath(filteredUpdateInfo.tag, p.replaceAll(" ", "-")),
)
logger.info(`Resolved ${resolved.length} file(s) for download`)
resolved.forEach((file, index) => {
logger.debug(` File ${index + 1}: ${file.url}`)
})
return resolved
}
private getBaseDownloadPath(tag: string, fileName: string): string {
return `${this.basePath}/download/${tag}/${fileName}`
}
}
export interface CustomGitHubOptions {
channel: string
repo: string
owner: string
releaseType: "release" | "prerelease"
}
function getNoteValue(parent: XElement): string {
const result = parent.elementValueOrEmpty("content")
// GitHub reports empty notes as <content>No content.</content>
return result === "No content." ? "" : result
}
export function computeReleaseNotes(
currentVersion: semver.SemVer,
isFullChangelog: boolean,
feed: XElement,
latestRelease: any,
): string | Array<ReleaseNoteInfo> | null {
if (!isFullChangelog) {
return getNoteValue(latestRelease)
}
const releaseNotes: Array<ReleaseNoteInfo> = []
for (const release of feed.getElements("entry")) {
// noinspection TypeScriptValidateJSTypes
const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))?.[1]
if (versionRelease && semver.lt(currentVersion, versionRelease)) {
releaseNotes.push({
version: versionRelease,
note: getNoteValue(release),
})
}
}
return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version))
}
// addRandomQueryToAvoidCaching is false by default because in most cases URL already contains version number,
// so, it makes sense only for Generic Provider for channel files
function newUrlFromBase(pathname: string, baseUrl: URL, addRandomQueryToAvoidCaching = false): URL {
const result = new URL(pathname, baseUrl)
// search is not propagated (search is an empty string if not specified)
const { search } = baseUrl
if (search != null && search.length > 0) {
result.search = search
} else if (addRandomQueryToAvoidCaching) {
result.search = `noCache=${Date.now().toString(32)}`
}
return result
}
function getChannelFilename(channel: string): string {
return `${channel}.yml`
}

View File

@ -0,0 +1,203 @@
import { URL } from "node:url"
import type {
AppUpdate,
LatestReleasePayload,
PlatformUpdate,
PlatformUpdateFile,
} from "@follow-app/client-sdk"
import type { UpdateFileInfo, UpdateInfo } from "builder-util-runtime"
import { newError } from "builder-util-runtime"
import type { AppUpdater } from "electron-updater"
import type { ProviderRuntimeOptions } from "electron-updater/out/providers/Provider"
import { Provider } from "electron-updater/out/providers/Provider"
import type { ResolvedUpdateFileInfo } from "electron-updater/out/types"
import { logger } from "../logger"
import { getUpdateInfo } from "./api"
interface FollowProviderOptions {
provider: "custom"
}
type FollowProviderContext = {
payload: LatestReleasePayload
platform: PlatformUpdate
}
export class FollowUpdateProvider extends Provider<UpdateInfo> {
private static context: FollowProviderContext | null = null
static setContext(context: FollowProviderContext) {
FollowUpdateProvider.context = context
}
static clearContext() {
FollowUpdateProvider.context = null
}
static getContext() {
return FollowUpdateProvider.context
}
constructor(
_options: FollowProviderOptions,
_updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions,
) {
super(runtimeOptions)
}
async getLatestVersion(): Promise<UpdateInfo> {
const context = await this.ensureContext()
return this.buildUpdateInfo(context)
}
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((file) => ({
info: file,
url: new URL(file.url),
}))
}
private buildUpdateInfo(context: FollowProviderContext): UpdateInfo {
const { payload, platform } = context
const files = this.mapFiles(platform.files)
if (files.length === 0) {
throw newError(
`No downloadable files found for platform ${platform.platform}`,
"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
)
}
const primaryFile = files[0]
if (!primaryFile) {
throw newError(
`Platform ${platform.platform} provides no downloadable file`,
"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
)
}
let primaryPath = primaryFile.url
const primaryUrl = this.safeParseUrl(primaryFile.url)
if (primaryUrl) {
const filename = primaryUrl.pathname.split("/").pop()
if (filename) {
primaryPath = filename
}
}
const { release } = payload
return {
version: platform.version,
files,
path: primaryPath,
sha512: primaryFile.sha512,
releaseName: release.name,
releaseNotes: release.body,
releaseDate: platform.releaseDate || release.publishedAt || new Date().toISOString(),
}
}
private mapFiles(files: PlatformUpdate["files"]): UpdateFileInfo[] {
if (!files) return []
return files
.map((file) => this.mapFile(file))
.filter((file): file is UpdateFileInfo => file !== null)
}
private mapFile(file: PlatformUpdateFile): UpdateFileInfo | null {
if (!file.downloadUrl || !file.sha512) {
logger.warn("Skip platform file without downloadUrl or sha512", file)
return null
}
const mapped: UpdateFileInfo = {
url: file.downloadUrl,
sha512: file.sha512,
}
if (typeof file.size === "number") {
mapped.size = file.size
}
return mapped
}
private safeParseUrl(value: string): URL | null {
try {
return new URL(value)
} catch (error) {
logger.debug?.("Unable to parse update file URL", error)
return null
}
}
private async ensureContext(): Promise<FollowProviderContext> {
const context = FollowUpdateProvider.getContext()
if (context) return context
const fetched = await this.fetchContext()
FollowUpdateProvider.setContext(fetched)
return fetched
}
private async fetchContext(): Promise<FollowProviderContext> {
const payload = await getUpdateInfo({})
const { decision } = payload
if (!decision || decision.type !== "app" || !decision.app) {
throw newError(
"No app update metadata available from provider",
"ERR_UPDATER_NO_PUBLISHED_VERSIONS",
)
}
const platform = this.pickPlatform(decision.app)
if (!platform) {
throw newError(
`No matching platform update for ${process.platform}/${process.arch}`,
"ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
)
}
return { payload, platform }
}
private pickPlatform(appDecision: AppUpdate): PlatformUpdate | null {
const platforms = appDecision.platforms ?? []
const selected = appDecision.selectedPlatform
if (selected) {
return selected
}
const candidates = this.resolvePlatformCandidates()
const matched = platforms.find((platform) =>
candidates.includes(platform.platform.toLowerCase()),
)
return matched ?? platforms[0] ?? null
}
private resolvePlatformCandidates(): string[] {
const base = new Set<string>()
base.add(process.platform)
base.add(`${process.platform}-${process.arch}`)
base.add(process.arch)
if (process.platform === "darwin") {
base.add("mac")
base.add("macos")
}
if (process.platform === "win32") {
base.add("windows")
base.add("win")
}
return Array.from(base).map((value) => value.toLowerCase())
}
}

View File

@ -1,271 +1,279 @@
/**
* @description This file handles hot updates for the electron renderer layer
*/
import { existsSync, readFileSync } from "node:fs"
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import { callWindowExpose } from "@follow/shared/bridge"
import type { LatestReleasePayload, RendererUpdate } from "@follow-app/client-sdk"
import { mainHash, version as appVersion } from "@pkg"
import log from "electron-log"
import { load } from "js-yaml"
import { dump, load } from "js-yaml"
import path from "pathe"
import { x } from "tar"
import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app"
import { HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app"
import { downloadFileWithProgress } from "~/lib/download"
import { WindowManager } from "~/manager/window"
import { appUpdaterConfig } from "./configs"
const logger = log.scope("hot-updater")
declare const GIT_COMMIT_HASH: string | undefined
const url = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`
const releasesUrl = `${url}/releases`
const releaseApiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`
export type RendererManifest = RendererUpdate & {
downloadUrl: string
downloadedAt?: string
}
const getLatestReleaseTag = async () => {
// First try to get the latest release
try {
const latestRes = await fetch(`${releaseApiUrl}/latest`)
if (latestRes.ok) {
const latestRelease = await latestRes.json()
// Check if the latest release is a desktop release
if (
latestRelease.tag_name &&
latestRelease.tag_name.startsWith("desktop/") &&
!latestRelease.draft
) {
return latestRelease.tag_name
export enum RendererEligibilityStatus {
NoManifest,
RequiresFullAppUpdate,
AlreadyCurrent,
Eligible,
}
export interface RendererEligibilityResult {
status: RendererEligibilityStatus
manifest?: RendererManifest
reason?: string
}
class RendererHotUpdater {
private readonly logger = log.scope("updater:renderer")
private readonly tempDir = path.resolve(os.tmpdir(), "follow-render-update")
private readonly manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
extractManifest(payload: LatestReleasePayload | null): RendererManifest | null {
if (!payload) return null
const { decision } = payload
if (!decision || decision.type !== "renderer") {
return null
}
return this.toManifest(decision.renderer)
}
extractManifestFromRendererUpdate(renderer: RendererUpdate | null): RendererManifest | null {
return this.toManifest(renderer)
}
evaluateManifest(manifest: RendererManifest | null): RendererEligibilityResult {
if (!manifest) {
return { status: RendererEligibilityStatus.NoManifest }
}
if (manifest.mainHash && manifest.mainHash !== mainHash) {
return {
status: RendererEligibilityStatus.RequiresFullAppUpdate,
manifest,
reason: `Renderer payload requires main hash ${manifest.mainHash}, current main hash is ${mainHash}`,
}
}
} catch (error) {
logger.warn("Failed to fetch latest release, falling back to all releases", error)
if (manifest.version === appVersion) {
return {
status: RendererEligibilityStatus.AlreadyCurrent,
reason: "Renderer version matches current app version",
}
}
if (manifest.commit && GIT_COMMIT_HASH && manifest.commit === GIT_COMMIT_HASH) {
return {
status: RendererEligibilityStatus.AlreadyCurrent,
reason: "Renderer commit matches current main commit",
}
}
const installedManifest = this.getCurrentManifest()
if (installedManifest) {
if (installedManifest.version === manifest.version) {
return {
status: RendererEligibilityStatus.AlreadyCurrent,
reason: "Installed renderer manifest already at target version",
}
}
if (
installedManifest.commit &&
manifest.commit &&
installedManifest.commit === manifest.commit
) {
return {
status: RendererEligibilityStatus.AlreadyCurrent,
reason: "Installed renderer manifest commit matches target commit",
}
}
}
return {
status: RendererEligibilityStatus.Eligible,
manifest,
}
}
// If latest release is not a desktop release or fetch failed, get all releases
const res = await fetch(releaseApiUrl)
private toManifest(renderer: RendererUpdate | null): RendererManifest | null {
if (!renderer) {
this.logger.debug("Renderer decision payload missing renderer field")
return null
}
if (!res.ok) {
throw new Error(`GitHub API request failed: ${res.status} ${res.statusText}`)
if (!renderer.downloadUrl) {
this.logger.warn("Renderer decision missing downloadUrl, skip renderer hot update")
return null
}
if (!renderer.filename) {
this.logger.warn("Renderer decision missing filename, skip renderer hot update")
return null
}
if (!renderer.hash) {
this.logger.warn("Renderer decision missing hash, skip renderer hot update")
return null
}
return {
...renderer,
downloadUrl: renderer.downloadUrl,
}
}
const releases = await res.json()
async applyManifest(manifest: RendererManifest): Promise<void> {
if (!appUpdaterConfig.enableRenderHotUpdate) {
this.logger.info("Renderer hot update skipped because it is disabled in config")
return
}
// Check if the response contains an error message
if (releases.message) {
throw new Error(`GitHub API error: ${releases.message}`)
const archivePath = await this.downloadArchive(manifest)
await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
this.logger.info(`Extracting renderer bundle to ${HOTUPDATE_RENDER_ENTRY_DIR}`)
await x({
f: archivePath,
cwd: HOTUPDATE_RENDER_ENTRY_DIR,
})
const extractedDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer")
const targetDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)
const extractedStats = await stat(extractedDir).catch(() => null)
if (!extractedStats) {
throw new Error(`Extracted renderer directory not found at ${extractedDir}`)
}
await rm(targetDir, { recursive: true, force: true })
await rename(extractedDir, targetDir)
await this.writeManifest({ ...manifest, downloadedAt: new Date().toISOString() })
try {
await rm(archivePath, { force: true })
} catch (error) {
this.logger.warn("Failed to clean renderer archive", error)
}
this.logger.info(`Renderer hot update applied successfully: ${manifest.version}`)
const mainWindow = WindowManager.getMainWindow()
if (mainWindow) {
callWindowExpose(mainWindow).readyToUpdate()
}
}
// Ensure releases is an array
if (!Array.isArray(releases)) {
throw new TypeError("Invalid response format from GitHub API")
}
getCurrentManifest(): RendererManifest | null {
if (!existsSync(this.manifestPath)) {
return null
}
// Filter for desktop releases and find the latest one
const desktopReleases = releases.filter(
(release: any) => release.tag_name && release.tag_name.startsWith("desktop/") && !release.draft,
)
try {
const content = readFileSync(this.manifestPath, "utf-8")
const parsed = load(content)
if (parsed && typeof parsed === "object") {
return parsed as RendererManifest
}
} catch (error) {
this.logger.warn("Failed to read renderer manifest from disk", error)
}
if (desktopReleases.length === 0) {
throw new Error("No desktop releases found")
}
// Sort by created_at date in descending order to get the most recent first
desktopReleases.sort((a: any, b: any) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// Return the most recent desktop release
return desktopReleases[0].tag_name
}
const getFileDownloadUrl = async (filename: string) => {
const tag = await getLatestReleaseTag()
return `${releasesUrl}/download/${tag}/${filename}`
}
type Manifest = {
/** Render version */
version: string
hash: string
commit: string
filename: string
/** Only electron main hash equal to this value, renderer will can be updated */
mainHash: string
}
const getLatestReleaseManifest = async () => {
const url = await getFileDownloadUrl("manifest.yml")
logger.info(`Fetching manifest from ${url}`)
const res = await fetch(url)
if (!res.ok) {
logger.error(`Failed to fetch manifest: ${res.status} ${res.statusText}`)
return null
}
const text = await res.text()
const manifest = load(text) as Manifest
if (typeof manifest !== "object") {
logger.error("Invalid manifest", text)
return null
}
return manifest
}
const downloadTempDir = path.resolve(os.tmpdir(), "follow-render-update")
export enum CanUpdateRenderState {
// If version is equal, no need to update
NO_NEEDED,
// Can be only update render layer, not fully upgrade app
NEEDED,
// App not support, should trigger app force update
APP_NOT_SUPPORT,
// Network error, can fetch manifest
NETWORK_ERROR,
}
export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest | null]> => {
const manifest = await getLatestReleaseManifest()
logger.info("fetched manifest", manifest)
if (!manifest) return [CanUpdateRenderState.NETWORK_ERROR, null]
const appSupport = mainHash === manifest.mainHash
if (!appSupport) {
logger.info("app not support, should trigger app force update, app version: ", appVersion)
return [CanUpdateRenderState.APP_NOT_SUPPORT, null]
}
const isVersionEqual = appVersion === manifest.version
if (isVersionEqual) {
logger.info("version is equal, skip update")
return [CanUpdateRenderState.NO_NEEDED, null]
}
const isCommitEqual = GIT_COMMIT_HASH === manifest.commit
if (isCommitEqual) {
logger.info("commit is equal, skip update")
return [CanUpdateRenderState.NO_NEEDED, null]
}
const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
const manifestExist = existsSync(manifestFilePath)
const oldManifest: Manifest | null = manifestExist
? (load(readFileSync(manifestFilePath, "utf-8")) as Manifest)
: null
if (oldManifest) {
if (oldManifest.version === manifest.version) {
logger.info("manifest version is equal, skip update")
return [CanUpdateRenderState.NO_NEEDED, null]
async cleanup(): Promise<void> {
const manifest = this.getCurrentManifest()
if (!manifest) {
await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })
return
}
if (oldManifest.commit === manifest.commit) {
logger.info("manifest commit is equal, skip update")
return [CanUpdateRenderState.NO_NEEDED, null]
const keepDir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)
let entries: string[] = []
try {
entries = await readdir(HOTUPDATE_RENDER_ENTRY_DIR)
} catch (error) {
this.logger.warn("Failed to read renderer directory for cleanup", error)
return
}
}
return [CanUpdateRenderState.NEEDED, manifest]
}
const downloadRenderAsset = async (manifest: Manifest) => {
const { filename } = manifest
const url = await getFileDownloadUrl(filename)
const filePath = path.resolve(downloadTempDir, filename)
logger.info(`Downloading ${url}, Save to ${filePath}`)
const success = await downloadFileWithProgress({
url,
outputPath: filePath,
expectedHash: manifest.hash,
onLog: (message) => {
logger.info(message)
},
})
if (!success) throw new Error("Download hot update render asset failed")
return filePath
}
export const hotUpdateRender = async (manifest: Manifest) => {
if (!appUpdaterConfig.enableRenderHotUpdate) return false
if (!manifest) return false
const filePath = await downloadRenderAsset(manifest)
logger.info(`Downloaded render asset to ${filePath}`)
if (!filePath) return false
// Extract the tar.gz file
await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
logger.info(`Extracting render asset to ${HOTUPDATE_RENDER_ENTRY_DIR}`)
await x({
f: filePath,
cwd: HOTUPDATE_RENDER_ENTRY_DIR,
})
logger.info(
`Extracted render asset to ${HOTUPDATE_RENDER_ENTRY_DIR}, rename to ${manifest.version}`,
)
// Rename `renderer` folder to `manifest.version`
await rename(
path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer"),
path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version),
)
const manifestPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
logger.info(`Write manifest to ${manifestPath}`)
await writeFile(manifestPath, JSON.stringify(manifest))
logger.info(`Hot update render success, update to ${manifest.version}`)
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) return false
const caller = callWindowExpose(mainWindow)
caller.readyToUpdate()
return true
}
export const getCurrentRenderManifest = () => {
const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
const manifestExist = existsSync(manifestFilePath)
if (!manifestExist) return null
return load(readFileSync(manifestFilePath, "utf-8")) as Manifest
}
export const cleanupOldRender = async () => {
const manifest = getCurrentRenderManifest()
if (!manifest) {
// Empty the directory
await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })
return
await Promise.all(
entries.map(async (entryName) => {
const entryPath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, entryName)
const entryStat = await stat(entryPath).catch(() => null)
if (!entryStat?.isDirectory()) return
if (entryPath === keepDir) return
await rm(entryPath, { recursive: true, force: true })
}),
)
}
const currentRenderVersion = manifest.version
// Clean all not current version
const dirs = await readdir(HOTUPDATE_RENDER_ENTRY_DIR)
for (const dir of dirs) {
const isDir = (await stat(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir))).isDirectory()
if (!isDir) continue
if (dir === currentRenderVersion) continue
await rm(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir), { recursive: true, force: true })
loadDynamicEntry() {
if (!appUpdaterConfig.enableRenderHotUpdate) return
const manifest = this.getCurrentManifest()
if (!manifest) return
if (manifest.mainHash && manifest.mainHash !== mainHash) return
const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version)
const entryFile = path.resolve(dir, "index.html")
if (!existsSync(entryFile)) return
return entryFile
}
private async downloadArchive(manifest: RendererManifest) {
const archivePath = path.resolve(this.tempDir, manifest.filename)
this.logger.info(
`Downloading renderer bundle ${manifest.filename} from ${manifest.downloadUrl}`,
)
const success = await downloadFileWithProgress({
url: manifest.downloadUrl,
outputPath: archivePath,
expectedHash: manifest.hash,
onLog: (message) => this.logger.info(message),
})
if (!success) {
throw new Error("Failed to download renderer bundle")
}
return archivePath
}
private async writeManifest(manifest: RendererManifest) {
await writeFile(this.manifestPath, dump(manifest), "utf-8")
}
}
export const loadDynamicRenderEntry = () => {
if (!appUpdaterConfig.enableRenderHotUpdate) return
const manifest = getCurrentRenderManifest()
if (!manifest) return
// check main hash is equal to manifest.mainHash
const appSupport = mainHash === manifest.mainHash
if (!appSupport) return
export const rendererUpdater = new RendererHotUpdater()
const currentRenderVersion = manifest.version
const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, currentRenderVersion)
const entryFile = path.resolve(dir, "index.html")
const entryFileExists = existsSync(entryFile)
export const getCurrentRendererManifest = () => rendererUpdater.getCurrentManifest()
if (!entryFileExists) return
return entryFile
export const cleanupOldRenderer = async () => {
await rendererUpdater.cleanup()
}
export const cleanupOldRender = cleanupOldRenderer
export const loadDynamicRenderEntry = () => rendererUpdater.loadDynamicEntry()

View File

@ -1,189 +1,554 @@
import { fileURLToPath } from "node:url"
import { callWindowExpose } from "@follow/shared/bridge"
import { DEV } from "@follow/shared/constants"
import type {
DistributionStatusPayload,
LatestReleasePayload,
PlatformUpdate,
RendererUpdate,
} from "@follow-app/client-sdk"
import { mainHash, version as appVersion } from "@pkg"
import log from "electron-log"
import type { AppUpdater } from "electron-updater"
import { autoUpdater as defaultAutoUpdater } from "electron-updater"
import { join } from "pathe"
import { gt, valid as isValidSemver } from "semver"
import { GITHUB_OWNER, GITHUB_REPO } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { canUpdateRender, CanUpdateRenderState, hotUpdateRender } from "~/updater/hot-updater"
import type { RendererManifest } from "~/updater/hot-updater"
import { RendererEligibilityStatus, rendererUpdater } from "~/updater/hot-updater"
import { channel, isWindows } from "../env"
import { logger } from "../logger"
import { getDistributionUpdateInfo, getUpdateInfo } from "./api"
import { appUpdaterConfig } from "./configs"
import { CustomGitHubProvider } from "./custom-github-provider"
import { FollowUpdateProvider } from "./follow-update-provider"
import { WindowsUpdater } from "./windows-updater"
// skip auto update in dev mode
// const disabled = DEV
const disabled = !appUpdaterConfig.enableAppUpdate
const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater
export const quitAndInstall = () => {
const mainWindow = WindowManager.getMainWindow()
logger.info("Quit and install update, close main window, ", mainWindow?.id)
WindowManager.destroyMainWindow()
setTimeout(() => {
logger.info("Window is closed, quit and install update")
autoUpdater.quitAndInstall()
}, 1000)
const logger = log.scope("app-updater")
type UpdateCheckOptions = {
refresh?: boolean
}
let downloading = false
let checkingUpdate = false
const checkRenderUpdateAvailable = async () => {
const [state, manifest] = await canUpdateRender()
if (state === CanUpdateRenderState.NEEDED && manifest) {
return true
}
return false
type UpdateCheckResult = {
hasUpdate: boolean
error?: string
}
const upgradeRenderIfNeeded = async () => {
const [state, manifest] = await canUpdateRender()
if (state === CanUpdateRenderState.NO_NEEDED) {
return { upgraded: false }
class FollowUpdater {
private readonly disabled: boolean
private checkingUpdate = false
private downloadingUpdate = false
private pollingTimer: NodeJS.Timeout | null = null
constructor(
private readonly autoUpdater: AppUpdater,
private readonly renderer = rendererUpdater,
) {
this.disabled = !appUpdaterConfig.enableAppUpdate
}
if (state === CanUpdateRenderState.NEEDED && manifest) {
await hotUpdateRender(manifest)
return { upgraded: true }
register() {
if (this.disabled) {
logger.info("App auto-update disabled; updater not registered")
return
}
this.autoUpdater.autoDownload = false
this.autoUpdater.allowPrerelease = channel !== "stable"
this.autoUpdater.autoInstallOnAppQuit = true
this.autoUpdater.autoRunAppAfterInstall = true
this.autoUpdater.forceDevUpdateConfig = DEV
if (import.meta.env.DEV) {
const __dirname = fileURLToPath(new URL(".", import.meta.url))
this.autoUpdater.updateConfigPath = join(__dirname, "../../dev-only/dev-app-update.yml")
}
this.autoUpdater.setFeedURL({
provider: "custom",
updateProvider: FollowUpdateProvider,
})
this.registerAutoUpdaterEvents()
if (appUpdaterConfig.app.autoCheckUpdate) {
logger.info("Initial update check, mainHash:", mainHash)
void this.checkForUpdates().catch((error) =>
logger.error("Initial update check failed", error),
)
}
if (this.pollingTimer) {
clearInterval(this.pollingTimer)
}
const updatePollingHandler = async () => {
if (!appUpdaterConfig.app.autoCheckUpdate) {
return
}
void this.checkForUpdates().catch((error) => {
logger.error("Scheduled update check failed", error)
})
}
updatePollingHandler()
this.pollingTimer = setInterval(updatePollingHandler, appUpdaterConfig.app.checkUpdateInterval)
}
return { upgraded: false }
}
export const checkForAppUpdates = async (): Promise<{ hasUpdate: boolean; error?: string }> => {
if (disabled || checkingUpdate) {
async checkForUpdates(options: UpdateCheckOptions = {}): Promise<UpdateCheckResult> {
if (this.disabled) {
return { hasUpdate: false }
}
if (this.checkingUpdate) {
logger.info("Update check already in progress, skipping")
return { hasUpdate: false }
}
this.checkingUpdate = true
try {
if (appUpdaterConfig.enableDistributionStoreUpdate) {
logger.info("Distribution store update enabled, checking for distribution update")
return this.handleDistributionAppDecision()
}
const payload = await getUpdateInfo(options.refresh ? { refresh: true } : {})
return this.handleDirectAppDecision(payload)
} catch (error) {
logger.error("Failed to check for updates", error)
return { hasUpdate: false, error: error instanceof Error ? error.message : "Unknown error" }
} finally {
this.checkingUpdate = false
}
}
async handleDirectAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {
const { decision } = payload
if (!decision || decision.type === "none") {
logger.info("Update decision: none")
return { hasUpdate: false }
}
if (decision.type === "renderer") {
logger.info("Update decision: renderer")
return await this.handleRendererDecision(payload)
}
if (decision.type === "app") {
logger.info("Update decision: app")
return await this.handleAppDecision(payload)
}
logger.warn("Unknown update decision type", { type: decision.type })
return { hasUpdate: false }
}
checkingUpdate = true
try {
let hasUpdate = false
if (appUpdaterConfig.enableRenderHotUpdate) {
const hasRenderUpdate = await checkRenderUpdateAvailable()
if (hasRenderUpdate) {
hasUpdate = true
// Auto upgrade renderer
upgradeRenderIfNeeded()
return { hasUpdate }
}
async downloadAppUpdate(): Promise<void> {
if (this.disabled || this.downloadingUpdate) {
return
}
// Check for core app updates
if (appUpdaterConfig.enableCoreUpdate) {
const result = await autoUpdater.checkForUpdates()
if (result !== null && result.updateInfo !== null) {
hasUpdate = true
}
this.downloadingUpdate = true
try {
await this.autoUpdater.downloadUpdate()
logger.info("App update download requested")
} catch (error) {
this.downloadingUpdate = false
logger.error("Failed to download app update", error)
throw error
}
return { hasUpdate }
} catch (e) {
logger.error("Error checking for updates", e)
return { hasUpdate: false, error: e instanceof Error ? e.message : "Unknown error" }
} finally {
checkingUpdate = false
}
}
export const downloadAppUpdate = async () => {
if (disabled || downloading) {
return
}
downloading = true
autoUpdater.downloadUpdate().catch((e) => {
downloading = false
logger.error("Failed to download update", e)
})
logger.info("Update available, downloading...")
return
}
export const registerUpdater = async () => {
if (disabled) {
return
}
// Disable there, control this in event
autoUpdater.autoDownload = false
autoUpdater.allowPrerelease = channel !== "stable"
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.autoRunAppAfterInstall = true
const feedUrl: Exclude<Parameters<typeof autoUpdater.setFeedURL>[0], string> = {
channel,
// hack for custom provider
provider: "custom" as "github",
repo: GITHUB_REPO,
owner: GITHUB_OWNER,
releaseType: channel === "stable" ? "release" : "prerelease",
// @ts-expect-error hack for custom provider
updateProvider: CustomGitHubProvider,
}
logger.debug("auto-updater feed config", {
...feedUrl,
updateProvider: undefined,
})
autoUpdater.setFeedURL(feedUrl)
// register events for checkForUpdates
autoUpdater.on("checking-for-update", () => {
logger.info("Checking for update")
})
autoUpdater.on("update-available", async (info) => {
logger.info("Update available", info)
// The app hotfix strategy is as follows:
// Determine whether the app should be updated in full or only the renderer layer based on the version number.
// https://www.notion.so/rss3/Follow-Hotfix-Electron-Renderer-layer-RFC-fe2444b9ac194c2cb38f9fa0bb1ef3c1?pvs=4#12e35ea049b480f1b268f1e605d86a62
if (appUpdaterConfig.enableRenderHotUpdate) {
const renderResult = await upgradeRenderIfNeeded()
if (renderResult.upgraded) {
return
}
}
if (appUpdaterConfig.app.autoDownloadUpdate && appUpdaterConfig.enableCoreUpdate) {
downloadAppUpdate().catch((err) => {
logger.error(err)
})
}
})
autoUpdater.on("update-not-available", (info) => {
logger.info("Update not available", info)
})
autoUpdater.on("download-progress", (e) => {
logger.info(`Download progress: ${e.percent}`)
})
autoUpdater.on("update-downloaded", () => {
downloading = false
logger.info("Update downloaded, ready to install")
quitAndInstall() {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) return
const handlers = callWindowExpose(mainWindow)
logger.info("Quit and install triggered", { windowId: mainWindow?.id })
WindowManager.destroyMainWindow()
handlers.updateDownloaded()
})
autoUpdater.on("error", (e) => {
logger.error("Error while updating client", e)
})
autoUpdater.forceDevUpdateConfig = DEV
setTimeout(() => {
logger.info("Main window closed, quitting to install update")
this.autoUpdater.quitAndInstall()
}, 1000)
}
setInterval(() => {
if (appUpdaterConfig.app.autoCheckUpdate) {
checkForAppUpdates().catch((err) => {
logger.error("Error checking for updates", err)
})
private resolvePlatformCandidates() {
const base = new Set<string>()
base.add(process.platform)
base.add(`${process.platform}-${process.arch}`)
base.add(process.arch)
if (process.platform === "darwin") {
base.add("mac")
base.add("macos")
}
}, appUpdaterConfig.app.checkUpdateInterval)
if (appUpdaterConfig.app.autoCheckUpdate) {
checkForAppUpdates().catch((err) => {
logger.error("Error checking for updates", err)
if (process.platform === "win32") {
base.add("windows")
base.add("win")
}
return Array.from(base).map((value) => value.toLowerCase())
}
private pickPlatformUpdate(
platforms: PlatformUpdate[] | null | undefined,
selected?: PlatformUpdate | null,
): PlatformUpdate | null {
if (!platforms || platforms.length === 0) {
return null
}
if (selected) {
return selected
}
const candidates = this.resolvePlatformCandidates()
const matched = platforms.find((platform) =>
candidates.includes(platform.platform.toLowerCase()),
)
return matched ?? platforms[0] ?? null
}
private async handleAppDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {
const appDecision = payload.decision.app
if (!appUpdaterConfig.enableCoreUpdate) {
logger.info("Core app update disabled by configuration")
return { hasUpdate: false }
}
if (!appDecision) {
logger.warn("App update decision missing app payload")
return { hasUpdate: false, error: "App update metadata unavailable" }
}
const platformUpdate = this.pickPlatformUpdate(
appDecision.platforms,
appDecision.selectedPlatform,
)
if (!platformUpdate) {
logger.warn("No matching platform update found", {
platform: process.platform,
arch: process.arch,
})
return { hasUpdate: false, error: "No installer available for this platform" }
}
FollowUpdateProvider.setContext({ payload, platform: platformUpdate })
logger.info("FollowUpdateProvider context set", { platform: platformUpdate.platform })
try {
await this.autoUpdater.checkForUpdates()
} catch (error) {
logger.warn(
"autoUpdater.checkForUpdates failed after preparing FollowUpdateProvider context",
error,
)
return {
hasUpdate: false,
error: error instanceof Error ? error.message : "Failed to check app update",
}
} finally {
FollowUpdateProvider.clearContext()
}
return { hasUpdate: true }
}
private async handleDistributionAppDecision(): Promise<UpdateCheckResult> {
try {
if (!appUpdaterConfig.enableDistributionStoreUpdate) {
return { hasUpdate: false }
}
const info = await getDistributionUpdateInfo()
if (!info) {
logger.info(
"Distribution update info unavailable for current build, falling back to direct app decision",
)
const payload = await getUpdateInfo()
return this.handleDirectAppDecision(payload)
}
const rendererResult = await this.tryDistributionRendererUpdate(info.rendererUpdate)
if (rendererResult) {
return rendererResult
}
if (!this.shouldPromptDistributionStoreUpdate(info)) {
logger.info("Distribution update does not require store action")
return { hasUpdate: false }
}
logger.info("Distribution store update required")
return await this.notifyDistributionUpdate(info)
} catch (error) {
logger.error("Failed to handle distribution app update", error)
return {
hasUpdate: false,
error: error instanceof Error ? error.message : "Failed to handle distribution update",
}
}
}
private async tryDistributionRendererUpdate(
renderer: RendererUpdate | null,
): Promise<UpdateCheckResult | null> {
if (!renderer) {
return null
}
if (!appUpdaterConfig.enableRenderHotUpdate) {
logger.info("Renderer hot update disabled for distribution build")
return null
}
const manifest = this.renderer.extractManifestFromRendererUpdate(renderer)
if (!manifest) {
logger.warn("Distribution renderer update missing manifest")
return null
}
const eligibility = this.renderer.evaluateManifest(manifest)
switch (eligibility.status) {
case RendererEligibilityStatus.NoManifest: {
if (eligibility.reason) {
logger.warn("Distribution renderer update missing manifest data", {
reason: eligibility.reason,
})
}
return null
}
case RendererEligibilityStatus.AlreadyCurrent: {
if (eligibility.reason) {
logger.info(eligibility.reason)
}
return { hasUpdate: false }
}
case RendererEligibilityStatus.RequiresFullAppUpdate: {
logger.info(
eligibility.reason ??
"Renderer payload requires main process update, delegating to distribution store flow",
)
return null
}
case RendererEligibilityStatus.Eligible: {
const manifestToApply = eligibility.manifest as RendererManifest | undefined
if (!manifestToApply) {
logger.warn("Distribution renderer update missing manifest payload")
return null
}
try {
await this.renderer.applyManifest(manifestToApply)
return { hasUpdate: true }
} catch (error) {
logger.error("Renderer hot update failed for distribution build", error)
return {
hasUpdate: false,
error: error instanceof Error ? error.message : "Renderer hot update failed",
}
}
}
default: {
return null
}
}
}
private shouldPromptDistributionStoreUpdate(info: DistributionStatusPayload): boolean {
if (!info.storeUrl) {
logger.info("Distribution store update skipped: missing store URL", {
distribution: info.distribution,
})
return false
}
const { storeVersion } = info
const currentVersion = appVersion
if (!storeVersion) {
logger.info("Distribution store update skipped: missing store version")
return false
}
if (!currentVersion) {
return true
}
const storeValid = isValidSemver(storeVersion)
const currentValid = isValidSemver(currentVersion)
if (storeValid && currentValid) {
const needsUpdate = gt(storeVersion, currentVersion)
if (!needsUpdate) {
logger.info("Distribution store version matches current version", {
storeVersion,
currentVersion,
})
}
return needsUpdate
}
if (storeVersion === currentVersion) {
logger.info("Distribution store version identical to current version", {
storeVersion,
currentVersion,
})
return false
}
return true
}
private async notifyDistributionUpdate(
info: DistributionStatusPayload,
): Promise<UpdateCheckResult> {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) {
logger.warn("Main window unavailable when notifying distribution update")
return { hasUpdate: true }
}
if (!info.storeUrl) {
logger.warn("Distribution update missing store URL", {
distribution: info.distribution,
})
return { hasUpdate: false }
}
await callWindowExpose(mainWindow).distributionUpdateAvailable({
distribution: info.distribution,
storeUrl: info.storeUrl,
storeVersion: info.storeVersion ?? null,
currentVersion: appVersion,
})
return { hasUpdate: true }
}
private async handleRendererDecision(payload: LatestReleasePayload): Promise<UpdateCheckResult> {
if (!appUpdaterConfig.enableRenderHotUpdate) {
logger.info("Renderer hot update disabled; falling back to app decision if present")
if (payload.decision.app) {
return this.handleAppDecision(payload)
}
return { hasUpdate: false }
}
const manifest = this.renderer.extractManifest(payload)
const eligibility = this.renderer.evaluateManifest(manifest)
switch (eligibility.status) {
case RendererEligibilityStatus.NoManifest: {
return { hasUpdate: false, error: eligibility.reason }
}
case RendererEligibilityStatus.AlreadyCurrent: {
if (eligibility.reason) {
logger.info(eligibility.reason)
}
return { hasUpdate: false }
}
case RendererEligibilityStatus.RequiresFullAppUpdate: {
logger.info(
eligibility.reason,
"Renderer payload requires main process update, delegating to app updater",
)
if (payload.decision.app) {
return this.handleAppDecision(payload)
}
logger.warn("Renderer update requested full app upgrade but no app payload provided")
return { hasUpdate: false, error: "Renderer update requires full app upgrade" }
}
case RendererEligibilityStatus.Eligible: {
const manifestToApply = eligibility.manifest as RendererManifest | undefined
if (!manifestToApply) {
return { hasUpdate: false }
}
try {
await this.renderer.applyManifest(manifestToApply)
return { hasUpdate: true }
} catch (error) {
logger.error("Renderer hot update failed", error)
return {
hasUpdate: false,
error: error instanceof Error ? error.message : "Renderer hot update failed",
}
}
}
default: {
return { hasUpdate: false }
}
}
}
private registerAutoUpdaterEvents() {
this.autoUpdater.on("checking-for-update", () => {
logger.info("autoUpdater: checking for update")
})
this.autoUpdater.on("update-available", (info) => {
logger.info("autoUpdater: update available", info)
if (appUpdaterConfig.app.autoDownloadUpdate && appUpdaterConfig.enableCoreUpdate) {
void this.downloadAppUpdate().catch((error) =>
logger.error("Automatic download failed", error),
)
}
})
this.autoUpdater.on("update-not-available", (info) => {
logger.info("autoUpdater: update not available", info)
})
this.autoUpdater.on("download-progress", (progress) => {
logger.info(`autoUpdater: download progress ${progress.percent.toFixed(2)}%`)
})
this.autoUpdater.on("update-downloaded", (ev) => {
this.downloadingUpdate = false
logger.info("autoUpdater: update downloaded", ev.downloadedFile, ev.version)
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) return
callWindowExpose(mainWindow).updateDownloaded()
})
this.autoUpdater.on("error", (error) => {
logger.error("autoUpdater: error", error)
})
}
}
const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater
const followUpdater = new FollowUpdater(autoUpdater)
export const registerUpdater = () => {
followUpdater.register()
}
export const checkForAppUpdates = (options: UpdateCheckOptions = {}) =>
followUpdater.checkForUpdates(options)
export const quitAndInstall = () => followUpdater.quitAndInstall()

View File

@ -1,91 +0,0 @@
export interface GitHubReleasesItem {
url: string
assets_url: string
upload_url: string
html_url: string
id: number
author: Author
node_id: string
tag_name: string
target_commitish: string
name: string
draft: boolean
prerelease: boolean
created_at: string
published_at: string
assets: AssetsItem[]
tarball_url: string
zipball_url: string
body: string
reactions?: Reactions
mentions_count?: number
}
interface Author {
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: string
user_view_type: string
site_admin: boolean
}
interface AssetsItem {
url: string
id: number
node_id: string
name: string
label: string | null
uploader: Uploader
content_type: string
state: string
size: number
download_count: number
created_at: string
updated_at: string
browser_download_url: string
}
interface Uploader {
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: string
user_view_type: string
site_admin: boolean
}
interface Reactions {
url: string
total_count: number
"+1": number
"-1": number
laugh: number
hooray: number
confused: number
heart: number
rocket: number
eyes: number
}

View File

@ -1,55 +0,0 @@
import fs from "node:fs"
import { app } from "electron"
import path from "pathe"
import { major, minor } from "semver"
let _isSquirrelBuild: boolean | null = null
export function isSquirrelBuild() {
if (typeof _isSquirrelBuild === "boolean") {
return _isSquirrelBuild
}
// if it is squirrel build, there will be 'squirrel.exe'
// otherwise it is in nsis web mode
const files = fs.readdirSync(path.dirname(app.getPath("exe")))
_isSquirrelBuild = files.some((it) => it.includes("squirrel.exe"))
return _isSquirrelBuild
}
// The following scenario only requires updating the renderer, so the app update is skipped:
// In x.y.z, the update of z will only trigger renderer hotfix, while the update of y requires updating the entire app.
// The hotfix version of x.y.z-beta.0 adds a suffix number. It triggers renderer update. If the main code is modified and the entire app update needs to be triggered, the hotfix version adds a suffix like x.y.z-beta.0.app.
// For subsequent minor versions that require updating the main code, the suffix .app needs to be added.
export const shouldUpdateApp = (currentVersion: string, nextVersion: string) => {
if (nextVersion.includes("app")) {
return true
}
// x.y.z 's y or x not equal, need update app
const [x1, x2] = [safeMajor(currentVersion), safeMajor(nextVersion)]
const [y1, y2] = [safeMinor(currentVersion), safeMinor(nextVersion)]
// Here, it is not determined whether it is a problem of version number downgrade; the updater will handle it automatically.
if (x1 !== x2 || y1 !== y2) {
return true
}
return false
}
const safeMajor = (version: string) => {
try {
return major(version)
} catch {
return "0.0.0"
}
}
const safeMinor = (version: string) => {
try {
return minor(version)
} catch {
return "0.0.0"
}
}

View File

@ -33,7 +33,7 @@
<!-- Apple Meta Tags -->
<meta name="apple-itunes-app" content="app-id=6739802604" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Folo" />

View File

@ -22,99 +22,99 @@
"@follow/shared": "workspace:*",
"@follow/store": "workspace:*",
"@follow/tracker": "workspace:*",
"@fontsource/sn-pro": "5.2.5",
"@hcaptcha/react-hcaptcha": "1.13.0",
"@fontsource/sn-pro": "5.2.6",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@lexical/markdown": "0.33.1",
"@lexical/react": "0.33.1",
"@lottiefiles/dotlottie-react": "0.17.5",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.10",
"@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",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.3",
"@sentry/react": "10.20.0",
"@shikijs/transformers": "3.13.0",
"@radix-ui/react-slot": "1.2.4",
"@shikijs/transformers": "3.22.0",
"@splinetool/react-spline": "4.1.0",
"@tanstack/query-sync-storage-persister": "5.90.7",
"@tanstack/react-query": "5.90.5",
"@tanstack/react-query-devtools": "5.90.2",
"@tanstack/react-query-persist-client": "5.90.7",
"@tanstack/react-virtual": "3.13.12",
"@tanstack/query-sync-storage-persister": "5.90.22",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-query-persist-client": "5.90.22",
"@tanstack/react-virtual": "3.13.18",
"@toon-format/toon": "2.1.0",
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@xyflow/react": "12.8.6",
"@yornaath/batshit": "0.11.1",
"ai": "5.0.76",
"camelcase-keys": "10.0.0",
"@xyflow/react": "12.10.0",
"@yornaath/batshit": "0.14.0",
"ai": "6.0.85",
"camelcase-keys": "10.0.2",
"chrono-node": "2.9.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie-es": "2.0.0",
"dayjs": "1.11.18",
"dnum": "2.15.0",
"dayjs": "1.11.19",
"dnum": "2.17.0",
"electron-ipc-decorator": "0.2.0",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"es-toolkit": "1.40.0",
"firebase": "11.9.1",
"foxact": "0.2.49",
"es-toolkit": "1.44.0",
"firebase": "12.9.0",
"foxact": "0.2.52",
"franc-min": "6.2.0",
"fuse.js": "7.1.0",
"hast-util-to-jsx-runtime": "2.3.6",
"hast-util-to-mdast": "10.1.2",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"i18next": "25.8.6",
"i18next-browser-languagedetector": "8.2.1",
"idb-keyval": "6.2.2",
"immer": "10.1.3",
"jotai": "2.15.0",
"immer": "11.1.4",
"jotai": "2.17.1",
"lethargy": "1.0.9",
"lexical": "0.33.1",
"lexical": "0.40.0",
"masonic": "4.1.0",
"mdast-util-gfm-table": "2.0.0",
"mdast-util-to-markdown": "2.1.2",
"motion": "12.23.24",
"motion": "12.34.0",
"nanoid": "5.1.6",
"ofetch": "1.4.1",
"plain-shiki": "0.3.1",
"progressive-blur": "1.0.0",
"ofetch": "1.5.1",
"plain-shiki": "0.3.2",
"re-resizable": "6.11.2",
"react": "19.0.0",
"react-blurhash": "0.3.0",
"react-dom": "19.0.0",
"react-error-boundary": "6.1.0",
"react-fast-compare": "3.2.2",
"react-fast-marquee": "1.6.5",
"react-hook-form": "7.65.0",
"react-hotkeys-hook": "5.2.1",
"react-i18next": "16.1.0",
"react-intersection-observer": "9.16.0",
"react-google-recaptcha-v3": "1.11.0",
"react-hook-form": "7.71.1",
"react-hotkeys-hook": "5.2.4",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-ios-pwa-prompt": "2.0.6",
"react-markdown": "10.1.0",
"react-qr-code": "2.0.18",
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
"react-router": "7.9.4",
"react-router": "7.13.0",
"react-selecto": "1.26.3",
"react-shadow": "20.6.0",
"react-zoom-pan-pinch": "3.7.0",
"rehype-raw": "7.0.0",
"shiki": "3.13.0",
"shiki": "3.22.0",
"sonner": "2.0.7",
"tinykeys": "3.0.0",
"title-case": "4.3.2",
"tldts": "7.0.17",
"ufo": "1.6.1",
"tldts": "7.0.23",
"ufo": "1.6.3",
"use-context-selector": "2.0.0",
"use-sync-external-store": "1.6.0",
"usehooks-ts": "3.1.1",
"zod": "3.25.75",
"zustand": "5.0.8"
"zod": "3.25.76",
"zustand": "5.0.11"
},
"devDependencies": {
"@follow/atoms": "workspace:*",
@ -125,11 +125,11 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@folo-services/ai-tools": "0.2.46",
"@types/node": "24.8.1",
"@folo-services/ai-tools": "catalog:",
"@types/node": "25.2.3",
"@vite-pwa/assets-generator": "1.0.2",
"fake-indexeddb": "6.2.4",
"happy-dom": "20.0.7",
"fake-indexeddb": "6.2.5",
"happy-dom": "20.6.1",
"react-scan": "0.4.3",
"typescript": "catalog:"
}

View File

@ -1,5 +1,5 @@
// DONT EDIT THIS FILE MANUALLY
const langs = ["en", "zh-CN", "zh-TW", "ja"] as const
const langs = ["en", "zh-CN", "zh-TW", "ja", "fr-FR"] as const
export const currentSupportedLanguages = langs as readonly string[]
export type RendererSupportedLanguages = (typeof langs)[number]
@ -8,6 +8,7 @@ export const dayjsLocaleImportMap = {
["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")],
["ja"]: ["ja", () => import("dayjs/locale/ja")],
["zh-TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")],
["fr-FR"]: ["fr", () => import("dayjs/locale/fr")],
}
export const ns = ["common", "lang", "errors", "app", "settings", "shortcuts", "ai"] as const
export const defaultNS = "app" as const

View File

@ -1,27 +1,34 @@
// DONT EDIT THIS FILE MANUALLY
import ai_en from "@locales/ai/en.json"
import ai_frFR from "@locales/ai/fr-FR.json"
import ai_ja from "@locales/ai/ja.json"
import en from "@locales/app/en.json"
import app_frFR from "@locales/app/fr-FR.json"
import app_ja from "@locales/app/ja.json"
import app_zhCN from "@locales/app/zh-CN.json"
import app_zhTW from "@locales/app/zh-TW.json"
import common_en from "@locales/common/en.json"
import common_frFR from "@locales/common/fr-FR.json"
import common_ja from "@locales/common/ja.json"
import common_zhCN from "@locales/common/zh-CN.json"
import common_zhTW from "@locales/common/zh-TW.json"
import errors_en from "@locales/errors/en.json"
import errors_frFR from "@locales/errors/fr-FR.json"
import errors_ja from "@locales/errors/ja.json"
import errors_zhCN from "@locales/errors/zh-CN.json"
import errors_zhTW from "@locales/errors/zh-TW.json"
import lang_en from "@locales/lang/en.json"
import lang_frFR from "@locales/lang/fr-FR.json"
import lang_ja from "@locales/lang/ja.json"
import lang_zhCN from "@locales/lang/zh-CN.json"
import lang_zhTW from "@locales/lang/zh-TW.json"
import settings_en from "@locales/settings/en.json"
import settings_frFR from "@locales/settings/fr-FR.json"
import settings_ja from "@locales/settings/ja.json"
import settings_zhCN from "@locales/settings/zh-CN.json"
import settings_zhTW from "@locales/settings/zh-TW.json"
import shortcuts_en from "@locales/shortcuts/en.json"
import shortcuts_frFR from "@locales/shortcuts/fr-FR.json"
import shortcuts_ja from "@locales/shortcuts/ja.json"
import shortcuts_zhCN from "@locales/shortcuts/zh-CN.json"
import shortcuts_zhTW from "@locales/shortcuts/zh-TW.json"
@ -70,6 +77,15 @@ export const defaultResources = {
errors: errors_zhTW,
ai: ai_en, // Fallback to English until Traditional Chinese translation is available
},
"fr-FR": {
app: app_frFR,
lang: lang_frFR,
common: common_frFR,
settings: settings_frFR,
shortcuts: shortcuts_frFR,
errors: errors_frFR,
ai: ai_frFR,
},
} satisfies Record<
RendererSupportedLanguages,
Partial<Record<(typeof ns)[number], Record<string, string>>>

View File

@ -2,11 +2,13 @@
import ai_en from "@locales/ai/en.json"
import en from "@locales/app/en.json"
import common_en from "@locales/common/en.json"
import common_frFR from "@locales/common/fr-FR.json"
import common_ja from "@locales/common/ja.json"
import common_zhCN from "@locales/common/zh-CN.json"
import common_zhTW from "@locales/common/zh-TW.json"
import errors_en from "@locales/errors/en.json"
import lang_en from "@locales/lang/en.json"
import lang_frFR from "@locales/lang/fr-FR.json"
import lang_ja from "@locales/lang/ja.json"
import lang_zhCN from "@locales/lang/zh-CN.json"
import lang_zhTW from "@locales/lang/zh-TW.json"
@ -42,6 +44,7 @@ export const defaultResources = {
common: common_ja,
},
"zh-TW": { lang: lang_zhTW, common: common_zhTW },
"fr-FR": { lang: lang_frFR, common: common_frFR },
} satisfies Record<
RendererSupportedLanguages,
Partial<Record<(typeof ns)[number], Record<string, string>>>

View File

@ -3,6 +3,7 @@ import { getOS, transformShortcut } from "@follow/utils/utils"
import { atom } from "jotai"
import { useCallback } from "react"
import { useRequireLogin } from "~/hooks/common/useRequireLogin"
import { ipcServices } from "~/lib/client"
import { createAtomHooks } from "~/lib/jotai"
import type { ElectronMenuItem } from "~/lib/native-menu"
@ -136,10 +137,32 @@ export enum MenuItemType {
export const useShowContextMenu = () => {
const showWebContextMenu = useShowWebContextMenu()
const { withLoginGuard } = useRequireLogin()
const guardMenuItems = useCallback(
(items: FollowMenuItem[]): FollowMenuItem[] =>
items.map((item) => {
if (item instanceof MenuItemSeparator) {
return item
}
const nextSubmenu = item.submenu.length > 0 ? guardMenuItems(item.submenu) : item.submenu
let nextItem = nextSubmenu !== item.submenu ? item.extend({ submenu: nextSubmenu }) : item
if (item.requiresLogin) {
nextItem = nextItem.extend({
click: withLoginGuard(nextItem.click),
})
}
return nextItem
}),
[withLoginGuard],
)
const showContextMenu = useCallback(
async (inputMenu: Array<MenuItemInput>, e: MouseEvent | React.MouseEvent) => {
const menuItems = filterNullableMenuItems(inputMenu)
const menuItems = guardMenuItems(filterNullableMenuItems(inputMenu))
// only show native menu on macOS electron, because in other platform, the native ui is not good
if (IN_ELECTRON && getOS() === "macOS") {
withDebugMenu(menuItems, e)
@ -148,7 +171,7 @@ export const useShowContextMenu = () => {
}
await showWebContextMenu(menuItems, e)
},
[showWebContextMenu],
[guardMenuItems, showWebContextMenu],
)
return showContextMenu
@ -170,6 +193,7 @@ export type BaseMenuItemTextConfig = {
disabled?: boolean
checked?: boolean
supportMultipleSelection?: boolean
requiresLogin?: boolean
}
export class BaseMenuItemText {
@ -213,6 +237,10 @@ export class BaseMenuItemText {
public get supportMultipleSelection() {
return this.configs.supportMultipleSelection
}
public get requiresLogin() {
return this.configs.requiresLogin || false
}
}
export type MenuItemTextConfig = Prettify<

View File

@ -20,6 +20,7 @@ type PlayerAtomValue = {
playbackRate?: number
/** the listId from the route to indicate that the audio is triggered from a list */
listId?: string
isStream?: boolean
}
const playerInitialValue: PlayerAtomValue = {
@ -27,6 +28,7 @@ const playerInitialValue: PlayerAtomValue = {
volume: 0.8,
duration: 0,
playbackRate: 1,
isStream: false,
}
const jsonStorage = createJSONStorage<PlayerAtomValue>()
@ -35,9 +37,13 @@ const patchedLocalStorage: SyncStorage<PlayerAtomValue> = {
setItem: jsonStorage.setItem,
getItem: (key, initialValue) => {
const value = jsonStorage.getItem(key, initialValue)
if (value.isStream) {
return playerInitialValue
}
if (value && !hydrationDone) {
// patch status to `paused` when hydration
value.status = "paused"
value.isStream = false
hydrationDone = true
}
return value
@ -80,6 +86,7 @@ export const AudioPlayer = {
status: "loading",
show: true,
listId: routeParams.listId,
isStream: false,
})
const currentUrl = parseSafeUrl(this.audio.src)?.toString() ?? this.audio.src
const newUrl = parseSafeUrl(v.src)?.toString() ?? v.src
@ -127,6 +134,15 @@ export const AudioPlayer = {
++this.__currentActionId
const curV = getAudioPlayerAtomValue()
if (curV.isStream) {
void this.audio.play().catch(noop)
setAudioPlayerAtomValue({
...curV,
status: "playing",
})
return
}
this.mount(curV)
},
pause() {
@ -146,6 +162,15 @@ export const AudioPlayer = {
},
togglePlayAndPause() {
const curV = getAudioPlayerAtomValue()
if (curV.isStream) {
if (curV.status === "playing") {
return this.pause()
}
if (curV.status === "paused") {
return this.play()
}
return this.pause()
}
if (curV.status === "playing") {
return this.pause()
} else if (curV.status === "paused") {
@ -159,11 +184,15 @@ export const AudioPlayer = {
...getAudioPlayerAtomValue(),
show: false,
status: "paused",
isStream: false,
})
this.teardown()
},
seek(time: number) {
if (getAudioPlayerAtomValue().isStream) {
return
}
this.audio.currentTime = time
setAudioPlayerAtomValue({
...getAudioPlayerAtomValue(),
@ -171,6 +200,9 @@ export const AudioPlayer = {
})
},
setPlaybackRate(speed: number) {
if (getAudioPlayerAtomValue().isStream) {
return
}
this.audio.playbackRate = speed
setAudioPlayerAtomValue({
...getAudioPlayerAtomValue(),
@ -178,9 +210,15 @@ export const AudioPlayer = {
})
},
back(time: number) {
if (getAudioPlayerAtomValue().isStream) {
return
}
this.seek(Math.max(this.audio.currentTime - time, 0))
},
forward(time: number) {
if (getAudioPlayerAtomValue().isStream) {
return
}
this.seek(Math.min(this.audio.currentTime + time, this.audio.duration))
},
toggleMute() {

View File

@ -16,6 +16,10 @@ export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = crea
),
)
export type ServerConfigs = ExtractResponseData<GetStatusConfigsResponse>
export type PaymentPlan = ServerConfigs["PAYMENT_PLAN_LIST"][number]
export type PaymentFeature = PaymentPlan["limit"]
export const useIsInMASReview = () => {
const serverConfigs = useServerConfigs()
return (
@ -24,3 +28,24 @@ export const useIsInMASReview = () => {
serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
)
}
export const getIsInMASReview = () => {
const serverConfigs = getServerConfigs()
return (
typeof process !== "undefined" &&
process.mas &&
serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
)
}
export const useIsPaymentEnabled = () => {
const serverConfigs = useServerConfigs()
const isInMASReview = useIsInMASReview()
return !isInMASReview && serverConfigs?.PAYMENT_ENABLED
}
export const getIsPaymentEnabled = () => {
const serverConfigs = getServerConfigs()
const isInMASReview = getIsInMASReview()
return !isInMASReview && serverConfigs?.PAYMENT_ENABLED
}

View File

@ -111,7 +111,7 @@ export const isServerShortcut = (shortcut: AIShortcut) => !!shortcut.defaultProm
export const createDefaultSettings = (): WebAISettings => ({
...defaultAISettings,
shortcuts: normalizeShortcuts(defaultAISettings.shortcuts),
panelStyle: AIChatPanelStyle.Fixed,
panelStyle: AIChatPanelStyle.Floating,
showSplineButton: true,
})
@ -224,5 +224,4 @@ export const removeMCPService = (id: string) => {
//// Enhance Init Ai Settings
export const initializeDefaultAISettings = () => {
initializeDefaultSettings()
if (getAISettings().panelStyle === AIChatPanelStyle.Fixed) setAIPanelVisibility(true)
}

View File

@ -1,7 +1,7 @@
import { createSettingAtom } from "@follow/atoms/helper/setting.js"
import { defaultGeneralSettings } from "@follow/shared/settings/defaults"
import { hookEnhancedSettings as baseHookEnhancedSettings } from "@follow/shared/settings/hook"
import type { GeneralSettings as BaseGeneralSettings } from "@follow/shared/settings/interface"
import type { GeneralSettings } from "@follow/shared/settings/interface"
import type { SupportedLanguages } from "@follow-app/client-sdk"
import { jotaiStore } from "~/lib/jotai"
@ -9,13 +9,9 @@ import { getDefaultLanguage } from "~/lib/language"
export const DEFAULT_ACTION_LANGUAGE = "default"
export interface GeneralSettings extends BaseGeneralSettings {
showCompactTimelineInSub: boolean
}
export const createDefaultGeneralSettings = (): GeneralSettings => ({
...defaultGeneralSettings,
language: getDefaultLanguage(),
showCompactTimelineInSub: false,
})
const {
@ -93,7 +89,6 @@ export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [
export const enhancedGeneralSettingKeys = new Set<keyof GeneralSettings>([
"groupByDate",
"autoExpandLongSocialMedia",
"showCompactTimelineInSub",
])
const [

View File

@ -1,5 +1,4 @@
import { atom } from "jotai"
import type { ReactNode } from "react"
import { createAtomHooks } from "~/lib/jotai"
@ -26,21 +25,3 @@ export const [
getSubscriptionColumnTempShow,
setSubscriptionColumnTempShow,
] = createAtomHooks(atom(false))
export const [
,
,
useSubscriptionColumnApronNode,
,
getSubscriptionColumnApronNode,
setSubscriptionColumnApronNode,
] = createAtomHooks(atom<ReactNode | null>(null))
export const [
,
,
useSubscriptionEntryPlaneVisible,
,
getSubscriptionEntryPlaneVisible,
setSubscriptionEntryPlaneVisible,
] = createAtomHooks(atom(true))

View File

@ -1,13 +1,36 @@
import type { StoreDistribution } from "@follow-app/client-sdk"
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
export type UpdaterStatus = "ready"
export type UpdaterStatusAtom = {
type: "app" | "renderer" | "pwa"
type UpdaterStatusKind = "app" | "renderer" | "pwa" | "distribution"
type BaseUpdaterStatus<T extends UpdaterStatusKind> = {
type: T
status: UpdaterStatus
finishUpdate?: () => void
} | null
}
type AppUpdaterStatus = BaseUpdaterStatus<"app">
type RendererUpdaterStatus = BaseUpdaterStatus<"renderer">
type PwaUpdaterStatus = BaseUpdaterStatus<"pwa">
type DistributionUpdaterStatus = BaseUpdaterStatus<"distribution"> & {
distribution: StoreDistribution
storeUrl: string
storeVersion: string | null
currentVersion: string | null
}
export type UpdaterStatusAtom =
| AppUpdaterStatus
| RendererUpdaterStatus
| PwaUpdaterStatus
| DistributionUpdaterStatus
| null
export const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks(
atom(null as UpdaterStatusAtom),
)

View File

@ -1,11 +1,11 @@
import type { FallbackRender } from "@sentry/react"
import { ErrorBoundary } from "@sentry/react"
import type { FC, PropsWithChildren } from "react"
import { createElement, Suspense, useCallback } from "react"
import { getErrorFallback } from "../errors"
import type { ErrorComponentType } from "../errors/enum"
import PageErrorFallback from "../errors/PageError"
import type { FallbackRender } from "./ErrorBoundary"
import { ErrorBoundary } from "./ErrorBoundary"
export interface AppErrorBoundaryProps extends PropsWithChildren {
height?: number | string

View File

@ -0,0 +1,55 @@
import { tracker } from "@follow/tracker"
import type { PropsWithChildren, ReactNode } from "react"
import type { FallbackProps } from "react-error-boundary"
import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"
export type ErrorFallbackProps = Omit<FallbackProps, "resetErrorBoundary"> &
FallbackProps & {
resetError: () => void
}
export type FallbackRender = (props: ErrorFallbackProps) => ReactNode
interface ErrorBoundaryProps extends PropsWithChildren {
fallback?: FallbackRender
fallbackRender?: FallbackRender
handled?: boolean
beforeCapture?: (scope: unknown, error: unknown) => unknown
}
const emptyFallback: FallbackRender = () => null
export const ErrorBoundary = ({
children,
fallback,
fallbackRender,
beforeCapture,
}: ErrorBoundaryProps) => {
const renderFallback = fallbackRender ?? fallback ?? emptyFallback
const handleError = (rawError: unknown, info: { componentStack?: string | null }) => {
const error = rawError instanceof Error ? rawError : new Error(String(rawError))
if (beforeCapture?.(info, error) === false) {
return
}
void tracker.manager.captureException(error, {
source: "desktop_error_boundary",
component_stack: info.componentStack,
})
}
return (
<ReactErrorBoundary
onError={handleError}
fallbackRender={(props) =>
renderFallback({
...props,
resetError: props.resetErrorBoundary,
})
}
>
{children}
</ReactErrorBoundary>
)
}

View File

@ -1,5 +1,5 @@
import { Button } from "@follow/components/ui/button/index.js"
import { captureException } from "@sentry/react"
import { tracker } from "@follow/tracker"
import { useEffect, useRef } from "react"
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router"
import { toast } from "sonner"
@ -27,8 +27,9 @@ export function ErrorElement() {
useEffect(() => {
console.error("Error handled by React Router default ErrorBoundary:", error)
captureException(error)
void tracker.manager.captureException(error, {
source: "desktop_router_error_element",
})
}, [error])
const reloadRef = useRef(false)

View File

@ -1,17 +0,0 @@
import { Skeleton } from "@follow/components/ui/skeleton/index.js"
import type { DotLottieReactProps } from "@lottiefiles/dotlottie-react"
import type { PropsWithoutRef } from "react"
import { lazy, Suspense } from "react"
const LazyDotLottieComponent = lazy(async () => {
const { DotLottieReact } = await import("@lottiefiles/dotlottie-react")
return { default: DotLottieReact }
})
export const LazyDotLottie = (props: PropsWithoutRef<DotLottieReactProps>) => {
return (
<Suspense fallback={<Skeleton className={props.className} />}>
<LazyDotLottieComponent {...props} />
</Suspense>
)
}

View File

@ -1,7 +1,6 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { ELECTRON_BUILD } from "@follow/shared/constants"
import { captureException } from "@sentry/react"
import { useEffect } from "react"
import type { Location } from "react-router"
import { Navigate, useLocation, useNavigate } from "react-router"
@ -33,7 +32,7 @@ export const NotFound = () => {
if (!ELECTRON_BUILD) {
return
}
captureException(
console.error(
new AccessNotFoundError(
"Electron app got to a 404 page, this should not happen",
location.pathname,

View File

@ -4,16 +4,7 @@ import { getAccentColorValue } from "@follow/shared/settings/constants"
import { hexToHslString } from "@follow/utils"
import { nanoid } from "nanoid"
import type { FC, PropsWithChildren, ReactNode } from "react"
import {
createContext,
createElement,
use,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { createContext, createElement, use, useLayoutEffect, useMemo, useState } from "react"
import root from "react-shadow"
import { useUISettingKeys } from "~/atoms/settings/ui"
@ -87,15 +78,16 @@ export const ShadowDOM: FC<
injectHostStyles ? cloneStylesElement() : [],
)
const shadowRootRef = useRef<ShadowRoot | null>(null)
const [el, setEl] = useState<{ shadowRoot: ShadowRoot } | null>(null)
useLayoutEffect(() => {
if (!textSelectionEnabled || !shadowRootRef.current || !onTextSelect) return
if (!el) return
const { shadowRoot } = el
const cleanup = addTextSelectionListener(shadowRootRef.current, onTextSelect, onSelectionClear)
if (!textSelectionEnabled || !shadowRoot || !onTextSelect) return
return cleanup
}, [textSelectionEnabled, onTextSelect, onSelectionClear])
return addTextSelectionListener(shadowRoot, onTextSelect, onSelectionClear)
}, [textSelectionEnabled, onTextSelect, onSelectionClear, el])
useLayoutEffect(() => {
if (!injectHostStyles) return
@ -123,14 +115,7 @@ export const ShadowDOM: FC<
return (
// @ts-expect-error
<root.div
{...rest}
ref={useCallback((element) => {
if (element) {
shadowRootRef.current = element.shadowRoot
}
}, [])}
>
<root.div {...rest} ref={setEl}>
<ShadowDOMContext value={true}>
<div
style={useMemo(

View File

@ -1,12 +1,15 @@
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { cn } from "@follow/utils/utils"
import { FollowAPIError } from "@follow-app/client-sdk"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import { useIsPaymentEnabled } from "~/atoms/server-configs"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { useFeature } from "~/hooks/biz/useFeature"
import { useSettingModal } from "~/modules/settings/modal/useSettingModal"
interface AISummaryCardBaseProps {
/** Summary content to display */
@ -21,8 +24,6 @@ interface AISummaryCardBaseProps {
footerContent?: ReactNode
/** Custom loading state component */
loadingComponent?: ReactNode
/** Custom empty state component */
emptyComponent?: ReactNode
/** Title text for the AI Summary header */
title?: string
/** Whether to show the copy button */
@ -31,6 +32,8 @@ interface AISummaryCardBaseProps {
showAskAIButton?: boolean
/** Callback when Ask AI button is clicked */
onAskAI?: () => void
error?: Error | null
}
const DefaultLoadingState = () => (
@ -41,12 +44,55 @@ const DefaultLoadingState = () => (
</div>
)
const DefaultEmptyState = ({ message }: { message: string }) => (
<div className="py-4 text-center">
<i className="i-mingcute-document-line mb-2 text-2xl text-text-tertiary" />
<p className="text-sm text-text-secondary">{message}</p>
</div>
)
const DefaultEmptyState = ({
message,
shouldSuggestUpgrade,
}: {
message: string
shouldSuggestUpgrade?: boolean
}) => {
const settingModalPresent = useSettingModal()
const { t } = useTranslation("app")
if (shouldSuggestUpgrade) {
return (
<button
type="button"
onClick={() => settingModalPresent("plan")}
className="group/upgrade relative flex items-start gap-3 text-left"
>
{/* Icon with glow */}
<div className="relative flex-shrink-0">
<div className="center relative size-9 rounded-lg bg-gradient-to-br from-purple-500 to-blue-500">
<i className="i-mgc-power-mono text-xl text-white" />
</div>
</div>
<div className="flex-1 space-y-2">
{/* Title */}
<h3 className="text-sm font-medium leading-snug text-text">{message}</h3>
{/* Description */}
<p className="text-xs leading-relaxed text-text-tertiary">
{t("ai.summary_upgrade_required_description")}
</p>
{/* CTA */}
<div className="flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400">
<span>{t("ai.summary_upgrade_view_plans")}</span>
<i className="i-mgc-right-cute-re text-sm" />
</div>
</div>
</button>
)
}
return (
<div className="text-center">
<p className="text-sm text-text-secondary">{message}</p>
</div>
)
}
export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
content,
@ -55,23 +101,26 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
headerContent,
footerContent,
loadingComponent,
emptyComponent,
title = "AI Summary",
showCopyButton = true,
showAskAIButton = false,
onAskAI,
error,
}) => {
const { t } = useTranslation("app")
const aiEnabled = useFeature("ai")
const hasContent = !isLoading && content
const shouldSuggestUpgrade =
useIsPaymentEnabled() && error instanceof FollowAPIError ? error.status === 402 : undefined
return (
<div
className={cn(
"group relative overflow-hidden rounded-2xl border border-neutral-200/50 p-5 backdrop-blur-xl",
"bg-gradient-to-b from-neutral-50/80 to-white/40 dark:from-neutral-900/80 dark:to-neutral-900/40",
"dark:border-neutral-800/50",
"group relative overflow-hidden rounded-2xl border p-5 shadow-sm backdrop-blur-xl transition-shadow duration-300",
"border-purple-200/30 bg-gradient-to-b from-purple-50/30 via-white/50 to-blue-50/20",
"dark:border-purple-800/30 dark:from-purple-950/30 dark:via-neutral-900/50 dark:to-blue-950/20",
"hover:shadow-md hover:shadow-purple-100/20 dark:hover:shadow-purple-900/10",
isLoading &&
"before:absolute before:inset-0 before:-z-10 before:animate-[pulse_2s_cubic-bezier(0.4,0,0.6,1)_infinite] before:bg-gradient-to-r before:from-purple-100/0 before:via-purple-300/10 before:to-purple-100/0 dark:before:from-purple-900/0 dark:before:via-purple-600/10 dark:before:to-purple-900/0",
@ -81,13 +130,16 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
{/* Animated background gradient */}
<div
className={cn(
"absolute inset-0 -z-10 bg-gradient-to-br opacity-50",
"from-purple-100/20 via-transparent to-blue-100/20",
"dark:from-purple-900/20 dark:to-blue-900/20",
"absolute inset-0 -z-10 bg-gradient-to-br opacity-40",
"from-purple-100/30 via-transparent to-blue-100/30",
"dark:from-purple-900/30 dark:to-blue-900/30",
isLoading && "animate-[glow_4s_ease-in-out_infinite]",
)}
/>
{/* Subtle shine effect on hover */}
<div className="absolute inset-0 -z-10 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100 dark:via-white/5" />
{/* Header */}
<div className="flex items-center justify-between">
{headerContent || (
@ -140,7 +192,7 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
)}
>
<i className="i-mingcute-ai-line text-base" />
<i className="i-mgc-ai-cute-re text-base" />
<span>Ask AI</span>
</MotionButtonBase>
)}
@ -167,12 +219,16 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
loadingComponent || <DefaultLoadingState />
) : hasContent ? (
<Markdown className="prose-sm max-w-none prose-p:m-0">{String(content)}</Markdown>
) : shouldSuggestUpgrade ? (
<DefaultEmptyState
message={t("ai.summary_upgrade_required_title")}
shouldSuggestUpgrade
/>
) : (
emptyComponent || <DefaultEmptyState message={t("ai.summary_not_available")} />
<DefaultEmptyState message={t("ai.summary_not_available")} />
)}
</AutoResizeHeight>
{/* Footer */}
{footerContent}
</div>
)

View File

@ -38,7 +38,7 @@ const glassButtonVariants = cva(
[
// Base styles - perfect 1:1 circle
"pointer-events-auto relative flex items-center justify-center rounded-full",
"transition-all duration-300 ease-out",
"transition-all duration-300 ease-out no-drag-region",
],
{
variants: {

View File

@ -1,12 +1,3 @@
import {
MdiLanguageCss3,
MdiLanguageHtml5,
MdiLanguageJavascript,
MdiLanguageTypescript,
RiMarkdownFill,
UilReact,
} from "@follow/components/icons/Language.jsx"
const LanguageAlias = {
ts: "typescript",
js: "javascript",
@ -17,13 +8,16 @@ const LanguageAlias = {
}
const languageToIconMap = {
javascriptreact: <UilReact />,
typescriptreact: <UilReact />,
javascript: <MdiLanguageJavascript />,
typescript: <MdiLanguageTypescript />,
html: <MdiLanguageHtml5 />,
css: <MdiLanguageCss3 />,
markdown: <RiMarkdownFill />,
javascriptreact: <i className="i-simple-icons-react" />,
typescriptreact: <i className="i-simple-icons-react" />,
javascript: <i className="i-simple-icons-javascript" />,
typescript: <i className="i-simple-icons-typescript" />,
html: <i className="i-simple-icons-html5" />,
css: <i className="i-simple-icons-css3" />,
markdown: <i className="i-simple-icons-markdown" />,
json: <i className="i-simple-icons-json" />,
yaml: <i className="i-simple-icons-yaml" />,
bash: <i className="i-simple-icons-shell" />,
}
export const getLanguageIcon = (language?: string) => {

View File

@ -187,24 +187,59 @@ const ShikiCode: FC<
return (
<div
className={cn(
"my-4 border",
"group relative my-4 overflow-hidden rounded-lg border backdrop-blur-sm",
styles["shiki-wrapper"],
transparent ? styles["transparent"] : null,
className,
)}
style={{
borderColor: "hsl(var(--fo-a) / 0.3)",
backgroundColor: "hsl(var(--fo-background) / 0.6)",
}}
>
<div className="flex items-center justify-between border-b p-2">
{/* Inner subtle glow */}
<div
className="pointer-events-none absolute inset-0 opacity-50"
style={{
background: "radial-gradient(circle at 50% 0%, hsl(var(--fo-a) / 0.03), transparent 50%)",
}}
/>
{/* Compact Header */}
<div
className="relative flex items-center justify-between border-b py-0 pl-3 pr-1"
style={{
borderColor: "hsl(var(--fo-a) / 0.3)",
backgroundColor: "hsl(var(--fo-a) / 0.05)",
}}
>
{language === "plaintext" ? (
<div />
<div className="h-4" />
) : (
<span className="center flex gap-1 text-xs uppercase opacity-80 dark:text-white">
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
<div className="flex items-center gap-1.5 text-xs font-medium uppercase text-accent">
<span className="center [&_svg]:size-3.5">{getLanguageIcon(language)}</span>
<span>{language}</span>
</span>
</div>
)}
<CopyButton variant="outline" value={code} className={showCopy ? "" : "invisible"} />
<CopyButton
variant="outline"
value={code}
className={cn(
"scale-90 !bg-transparent transition-opacity duration-200",
showCopy ? "opacity-100" : "pointer-events-none opacity-0",
)}
/>
</div>
{/* Code content */}
<div className="relative">
<div
dangerouslySetInnerHTML={{ __html: rendered }}
data-language={language}
className="relative"
/>
</div>
<div dangerouslySetInnerHTML={{ __html: rendered }} data-language={language} />
</div>
)
}

View File

@ -1,5 +1,5 @@
.shiki-wrapper {
@apply overflow-hidden rounded-md;
@apply overflow-hidden;
pre {
@apply bg-transparent;
@ -16,7 +16,7 @@
:global {
.shiki {
@apply !m-0 !px-0;
@apply !m-0 !bg-transparent !px-0;
font-family:
"OperatorMonoSSmLig Nerd Font",
@ -36,9 +36,27 @@
}
pre {
@apply !m-0 overflow-auto p-4;
@apply !m-0 overflow-auto px-4 py-3;
font-size: 0.875em;
line-height: 1.6;
/* Custom scrollbar */
&::-webkit-scrollbar {
@apply h-1.5 w-1.5;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-full bg-fill-tertiary transition-colors;
&:hover {
@apply bg-fill-secondary;
}
}
}
pre code {
@ -46,16 +64,21 @@
}
.line {
@apply block px-5;
@apply block px-4 transition-colors duration-100;
& > span:last-child {
@apply mr-5;
@apply mr-4;
}
/* Expand the row without content */
&::after {
content: " ";
}
/* Subtle hover effect on lines */
&:hover {
@apply bg-fill/10;
}
}
.highlighted,
@ -63,13 +86,13 @@
@apply relative break-all;
&::before {
@apply absolute left-0 top-0 h-full w-[2px];
@apply absolute left-0 top-0 h-full w-0.5;
content: "";
}
}
.diff.add {
@apply bg-green-100 dark:bg-green-900;
@apply bg-green/10;
&::before {
@apply bg-green;
@ -77,25 +100,25 @@
&::after {
content: " +";
@apply absolute left-0 text-green;
@apply absolute left-1.5 text-xs font-semibold text-green;
}
}
.diff.remove {
@apply bg-red-100 dark:bg-red-900;
@apply bg-red/10;
&::before {
@apply bg-red-500;
@apply bg-red;
}
&::after {
content: " -";
@apply absolute left-0 text-red;
@apply absolute left-1.5 text-xs font-semibold text-red;
}
}
.highlighted {
@apply bg-accent/20;
@apply bg-accent/10;
&::before {
@apply bg-accent;

View File

@ -9,6 +9,19 @@ import * as React from "react"
import { HotkeyScope } from "~/constants"
const styles = {
content: {
backgroundImage:
"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))",
boxShadow:
"0 6px 20px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px hsl(var(--fo-a) / 0.04), 0 1px 3px rgba(0, 0, 0, 0.03)",
} as React.CSSProperties,
innerGlow: {
background:
"linear-gradient(to bottom right, hsl(var(--fo-a) / 0.01), transparent, hsl(var(--fo-a) / 0.01))",
} as React.CSSProperties,
}
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
const setGlobalFocusableScope = useSetGlobalFocusableScope()
return (
@ -50,7 +63,7 @@ const DropdownMenuSubTrigger = ({
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[state=open]:bg-theme-selection-active data-[state=open]:text-theme-selection-foreground",
"flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none focus:bg-accent/30 data-[state=open]:bg-accent/30",
inset && "pl-8",
"center gap-2",
className,
@ -75,15 +88,26 @@ const DropdownMenuSubContent = ({
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-material-medium text-body text-text backdrop-blur-background",
"text-body text-text",
"min-w-32 overflow-hidden",
"rounded-[6px] border p-1",
"shadow-context-menu",
"rounded-[6px] p-1",
"backdrop-blur-2xl",
"z-[61]",
"relative",
"dark:border dark:border-border/50",
className,
)}
style={styles.content}
{...props}
/>
>
{/* Inner glow layer */}
<div
className="pointer-events-none absolute inset-0 rounded-[6px]"
style={styles.innerGlow}
/>
{/* Content wrapper */}
<div className="relative">{props.children}</div>
</DropdownMenuPrimitive.SubContent>
</RootPortal>
)
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
@ -102,12 +126,24 @@ const DropdownMenuContent = ({
ref={ref}
sideOffset={sideOffset}
className={cn(
"shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border bg-material-medium p-1 text-text backdrop-blur-background",
"z-[60] min-w-32 overflow-hidden rounded-[6px] p-1 text-text",
"backdrop-blur-2xl",
"text-body motion-scale-in-75 motion-duration-150 lg:animate-none",
"relative",
"dark:border dark:border-border/50",
className,
)}
style={styles.content}
{...props}
/>
>
{/* Inner glow layer */}
<div
className="pointer-events-none absolute inset-0 rounded-[6px]"
style={styles.innerGlow}
/>
{/* Content wrapper */}
<div className="relative">{props.children}</div>
</DropdownMenuPrimitive.Content>
</RootPortal>
)
}
@ -119,7 +155,7 @@ const DropdownMenuItem = ({
inset,
icon,
active,
highlightColor = "accent",
shortcut,
checked,
...props
@ -127,19 +163,15 @@ const DropdownMenuItem = ({
inset?: boolean
icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode)
active?: boolean
highlightColor?: "accent" | "gray"
shortcut?: string
checked?: boolean
} & { ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null> }) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus-within:outline-transparent",
highlightColor === "accent"
? "focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[highlighted]:bg-theme-selection-hover data-[highlighted]:text-theme-selection-foreground"
: "focus:bg-theme-item-active data-[highlighted]:bg-theme-item-hover",
"relative flex cursor-menu select-none items-center rounded-[5px] px-2.5 py-1 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus-within:outline-transparent data-[highlighted]:text-accent data-[highlighted]:bg-mix-background/accent-9/1",
"h-[28px]",
inset && "pl-8",
className,
@ -187,7 +219,7 @@ const DropdownMenuCheckboxItem = ({
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-checkbox select-none items-center rounded-[5px] px-8 py-1.5 outline-none focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-checkbox select-none items-center rounded-[5px] px-8 py-1.5 outline-none focus:bg-accent/30 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus-within:outline-transparent",
"h-[28px]",
className,

View File

@ -43,7 +43,7 @@ export const FeedPreviewCard: React.FC<FeedPreviewCardProps> = ({
>
{/* Header */}
<a
className="border-b border-border bg-fill-tertiary p-4"
className="p-4"
href={feed.url ?? "#"}
onClick={(e) => {
e.preventDefault()
@ -52,8 +52,8 @@ export const FeedPreviewCard: React.FC<FeedPreviewCardProps> = ({
target="_blank"
rel="noopener noreferrer"
>
<div className="flex items-start gap-3">
<FeedIcon target={feed} size={40} className="shrink-0" />
<div className="flex items-start gap-3 pl-4">
<FeedIcon target={feed} size={32} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="line-clamp-1 text-sm font-semibold text-text">{feed.title}</h3>

View File

@ -1,98 +0,0 @@
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import type { DotLottie } from "@lottiefiles/dotlottie-react"
import { DotLottieReact } from "@lottiefiles/dotlottie-react"
import { atom, useAtomValue } from "jotai"
import type { FC, ReactNode, RefCallback } from "react"
import { useEffect, useState } from "react"
import { jotaiStore } from "~/lib/jotai"
const portalElementsAtom = atom([] as ReactNode[])
export function LottieRenderContainer() {
const elements = useAtomValue(portalElementsAtom, { store: jotaiStore })
return (
<RootPortal>
<div className="pointer-events-none fixed z-[9999]" data-testid="lottie-render-container">
{elements.map((element) => element)}
</div>
</RootPortal>
)
}
type LottieOptions = {
once?: boolean
x: number
y: number
height?: number
width?: number
className?: string
onComplete?: () => void
speed?: number
}
export const mountLottie = (url: string, options: LottieOptions) => {
const { once = true, height, width, x, y, className, speed } = options
const Lottie: FC = () => {
const [dotLottie, setDotLottie] = useState<DotLottie | null>(null)
useEffect(() => {
function onComplete() {
if (once) {
unmount()
}
options.onComplete?.()
}
if (dotLottie) {
dotLottie.addEventListener("complete", onComplete)
}
return () => {
if (dotLottie) {
dotLottie.removeEventListener("complete", onComplete)
}
}
}, [dotLottie])
const dotLottieRefCallback: RefCallback<DotLottie> = (dotLottie) => {
setDotLottie(dotLottie)
}
return (
<DotLottieReact
speed={speed}
dotLottieRefCallback={dotLottieRefCallback}
src={url}
autoplay
loop={false}
height={height}
width={width}
style={{
height,
width,
position: "fixed",
left: 0,
top: 0,
transform: `translate(${x}px, ${y}px)`,
}}
className={className}
/>
)
}
const element = <Lottie />
const unmount = () => {
jotaiStore.set(portalElementsAtom, (prev) => prev.filter((e) => e !== element))
}
jotaiStore.set(portalElementsAtom, (prev) => [...prev, element])
return unmount
}

View File

@ -1,10 +1,14 @@
import { captureException } from "@sentry/react"
import { tracker } from "@follow/tracker"
import { useEffect } from "react"
export const BlockError = (props: { error: any; message: string }) => {
useEffect(() => {
captureException(props.error)
}, [])
console.error(props.error)
void tracker.manager.captureException(props.error, {
source: "desktop_markdown_block_error",
message: props.message,
})
}, [props.error, props.message])
return (
<div className="center flex min-h-12 flex-col rounded bg-red py-4 text-sm text-white">
{props.message}

View File

@ -7,11 +7,15 @@ import {
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.jsx"
import { useCorrectZIndex } from "@follow/components/ui/z-index/ctx.js"
import { cn, stopPropagation } from "@follow/utils"
import { env } from "@follow/shared/env.desktop"
import { feedSyncServices } from "@follow/store/feed/store"
import { cn, parseSafeUrl, stopPropagation } from "@follow/utils"
import type { MouseEvent } from "react"
import { use, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { copyToClipboard } from "~/lib/clipboard"
import { MarkdownRenderActionContext } from "../context"
@ -21,6 +25,7 @@ export const MarkdownLink: Component<LinkProps> = (props) => {
const { t } = useTranslation()
const populatedFullHref = transformUrl(props.href)
const shareFeedInfo = parseShareFeedInfo(populatedFullHref)
const handleCopyLink = useCallback(async () => {
try {
@ -34,6 +39,25 @@ export const MarkdownLink: Component<LinkProps> = (props) => {
}
}, [populatedFullHref, t])
const handleClickLink = useCallback(
async (event: MouseEvent<HTMLAnchorElement>) => {
stopPropagation(event)
if (!shareFeedInfo) {
return
}
event.preventDefault()
const view = await resolveShareFeedView(shareFeedInfo)
navigateEntry({
feedId: shareFeedInfo.id,
entryId: null,
view,
})
},
[shareFeedInfo],
)
const parseTimeStamp = isAudio(populatedFullHref)
const zIndex = useCorrectZIndex(0)
if (parseTimeStamp) {
@ -58,7 +82,7 @@ export const MarkdownLink: Component<LinkProps> = (props) => {
title={props.title}
target="_blank"
rel="noreferrer"
onClick={stopPropagation}
onClick={handleClickLink}
>
{props.children}
@ -93,3 +117,46 @@ export const MarkdownLink: Component<LinkProps> = (props) => {
</Tooltip>
)
}
const parseShareFeedInfo = (href?: string) => {
if (!href) return null
const baseUrl = parseSafeUrl(env.VITE_WEB_URL)
if (!baseUrl) return null
let parsedUrl: URL
try {
parsedUrl = new URL(href, baseUrl)
} catch {
return null
}
if (parsedUrl.host !== baseUrl.host) return null
const pathParts = parsedUrl.pathname.split("/").filter(Boolean)
if (pathParts.length !== 3 || pathParts[0] !== "share" || pathParts[1] !== "feeds") {
return null
}
const viewParam = parsedUrl.searchParams.get("view")
const view = viewParam ? Number.parseInt(viewParam, 10) : undefined
return {
id: pathParts[2]!,
view: Number.isNaN(view) ? undefined : view,
}
}
const resolveShareFeedView = async (info: { id: string; view?: number }) => {
if (typeof info.view === "number") {
return info.view
}
const data = await feedSyncServices.fetchFeedById({ id: info.id }).catch(() => {})
const analyticsView = data?.analytics?.view
if (typeof analyticsView === "number") {
return analyticsView
}
return 0
}

View File

@ -1,7 +1,5 @@
import { nextFrame } from "@follow/utils/dom"
import { getImageProxyUrl } from "@follow/utils/img-proxy"
import { cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import { useForceUpdate } from "motion/react"
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
import * as React from "react"
@ -9,8 +7,10 @@ import { memo, use, useEffect, useMemo, useRef, useState } from "react"
import { Blurhash, BlurhashCanvas } from "react-blurhash"
import { useEventCallback } from "usehooks-ts"
import { useGetImageProxyUrl } from "~/lib/img-proxy"
import { saveImageDimensionsToDb } from "~/store/image/db"
import { ErrorBoundary } from "../../common/ErrorBoundary"
import { useMediaContainerWidth, usePreviewMedia } from "./hooks"
import { MediaInfoRecordContext } from "./MediaInfoRecordContext"
import type { VideoPlayerRef } from "./VideoPlayer"
@ -77,6 +77,7 @@ const MediaImpl: FC<MediaProps> = ({
videoClassName,
...rest
} = props
const getImageProxyUrl = useGetImageProxyUrl()
const ctxMediaInfo = use(MediaInfoRecordContext)
const ctxHeight = ctxMediaInfo[src!]?.height
@ -121,7 +122,7 @@ const MediaImpl: FC<MediaProps> = ({
}
return sources
}, [src, proxy, preferOrigin])
}, [src, proxy, preferOrigin, getImageProxyUrl])
const [currentSourceIndex, setCurrentSourceIndex] = useState(0)
const [isError, setIsError] = useState(false)
@ -142,7 +143,7 @@ const MediaImpl: FC<MediaProps> = ({
})
}
return previewImageUrl
}, [previewImageUrl, proxy, currentSource?.type])
}, [previewImageUrl, proxy, currentSource?.type, getImageProxyUrl])
// When image source list changes, reset to the first source
const prevImageSources = useRef(imageSources)
@ -305,21 +306,22 @@ const MediaImpl: FC<MediaProps> = ({
}
}
}, [
errorHandle,
handleClick,
handleOnLoad,
imgSrc,
mediaContainerClassName,
type,
rest,
finalHeight,
finalWidth,
mediaLoadState,
popper,
previewImageSrc,
rest,
src,
thumbnail,
type,
errorHandle,
inline,
popper,
mediaLoadState,
mediaContainerClassName,
imgSrc,
handleOnLoad,
handleClick,
src,
previewImageSrc,
thumbnail,
videoClassName,
])
if (!type || !src) return null

View File

@ -18,9 +18,10 @@ import { m } from "~/components/common/Motion"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { COPY_MAP } from "~/constants"
import { ipcServices } from "~/lib/client"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { useReplaceImgUrlIfNeed } from "~/lib/img-proxy"
import { useCurrentModal } from "../modal/stacked/hooks"
import type { VideoPlayerRef } from "./VideoPlayer"
import { VideoPlayer } from "./VideoPlayer"
// Calculate the dynamic scale value and offset
@ -264,6 +265,7 @@ export const PreviewMediaContent: FC<{
children?: React.ReactNode
onZoomChange?: (isZoomed: boolean) => void
}> = ({ media, initialIndex = 0, children, onZoomChange }) => {
const videoRefs = useRef<(VideoPlayerRef | null)[]>([])
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [
WheelGesturesPlugin(),
])
@ -297,6 +299,22 @@ export const PreviewMediaContent: FC<{
return () => $container.removeEventListener("keydown", handleKeyDown)
}, [emblaApi, ref])
const setVideoRef = useCallback((el: VideoPlayerRef | null, index: number) => {
videoRefs.current[index] = el
}, [])
// Pause all videos when slide change
// And play the current video if it's a video
useEffect(() => {
videoRefs.current.forEach((video) => {
video?.controls.pause()
})
const currentVideo = videoRefs.current[currentSlideIndex]
if (currentVideo) {
currentVideo.controls.play()
}
}, [currentSlideIndex])
if (media.length === 0) return null
if (media.length === 1) {
const src = media[0]!.url!
@ -338,12 +356,12 @@ export const PreviewMediaContent: FC<{
{(handleZoomChange) => [
<div key={"left"} className="group size-full overflow-hidden" ref={emblaRef}>
<div className="flex size-full">
{media.map((med) => (
{media.map((med, i) => (
<div className="mr-2 flex w-full flex-none items-center justify-center" key={med.url}>
{med.type === "video" ? (
<VideoPlayer
ref={(el) => setVideoRef(el, i)}
src={med.url}
autoPlay
muted
controls
className="size-full object-contain"
@ -403,6 +421,7 @@ const FallbackableImage: FC<
onZoomChange?: (isZoomed: boolean) => void
}
> = ({ src, fallbackUrl, containerClassName, onZoomChange, loading }) => {
const replaceImgUrlIfNeed = useReplaceImgUrlIfNeed()
const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))
const [isAllError, setIsAllError] = useState(false)

View File

@ -1,8 +1,6 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { use, useCallback } from "react"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { PlainModal } from "../modal/stacked/custom-modal"
import { useModalStack } from "../modal/stacked/hooks"
import { MediaContainerWidthContext } from "./MediaContainerWidthContext"
@ -17,7 +15,7 @@ export const usePreviewMedia = (children?: React.ReactNode) => {
return
}
if (isMobile()) {
window.open(replaceImgUrlIfNeed(media[initialIndex]!.url))
window.open(media[initialIndex]!.url)
return
}
present({

View File

@ -11,6 +11,19 @@ import { useCurrentModal } from "./hooks"
export const PlainModal = ({ children }: PropsWithChildren) => children
export const PlainWithAnimationModal = ({ children }: PropsWithChildren) => {
return (
<m.div
initial={true}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
>
{children}
</m.div>
)
}
export { PlainModal as NoopChildren }
type ModalTemplateType = {

View File

@ -1,7 +1,7 @@
import { cn } from "@follow/utils/utils"
import { AnimatePresence } from "motion/react"
import type { FC, ReactNode } from "react"
import { useId, useMemo } from "react"
import { useCallback, useEffect, useId, useMemo, useState } from "react"
import { jotaiStore } from "~/lib/jotai"
@ -11,6 +11,7 @@ import type { ModalProps } from "./types"
export interface DeclarativeModalProps extends Omit<ModalProps, "content"> {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: ReactNode
@ -20,25 +21,39 @@ export interface DeclarativeModalProps extends Omit<ModalProps, "content"> {
const Noop = () => null
const DeclarativeModalImpl: FC<DeclarativeModalProps> = ({
open,
defaultOpen,
onOpenChange,
children,
...rest
}) => {
const index = useMemo(() => jotaiStore.get(modalStackAtom).length, [])
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false)
const id = useId()
const item = useMemo(
() => ({
...rest,
content: Noop,
id,
open: internalOpen,
}),
[id, rest],
[id, internalOpen, rest],
)
const handleOpenChange = useCallback(
(open: boolean) => {
setInternalOpen(open)
onOpenChange?.(open)
},
[onOpenChange, setInternalOpen],
)
useEffect(() => {
if (open !== undefined && open !== internalOpen) {
setInternalOpen(open)
}
}, [open, internalOpen, setInternalOpen])
return (
<AnimatePresence>
{open && (
<ModalInternal isTop onClose={onOpenChange} index={index} item={item}>
{internalOpen && (
<ModalInternal isTop onClose={handleOpenChange} index={index} item={item}>
{children}
</ModalInternal>
)}

View File

@ -338,15 +338,20 @@ export const ModalInternal = memo(function Modal({
<m.div
ref={modalElementRef}
style={modalStyle}
style={{
...modalStyle,
backgroundImage:
"linear-gradient(to bottom right, rgba(var(--color-background) / 0.98), rgba(var(--color-background) / 0.95))",
boxShadow:
"0 6px 20px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.05), 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 16px hsl(var(--fo-a) / 0.06), 0 2px 8px hsl(var(--fo-a) / 0.04), 0 1px 3px rgba(0, 0, 0, 0.03)",
}}
{...modalMontionConfig}
animate={animateController}
className={cn(
"relative flex flex-col overflow-hidden rounded-xl px-2 pt-1",
"bg-background",
"shadow-modal [transform-style:preserve-3d]",
"backdrop-blur-2xl [transform-style:preserve-3d]",
max ? "h-[90vh] w-[90vw]" : "max-h-[90vh]",
"border border-border",
"dark:border dark:border-border/50",
modalClassName,
)}
tabIndex={-1}
@ -369,9 +374,9 @@ export const ModalInternal = memo(function Modal({
onResizeStart={handleResizeStart}
onResizeStop={handleResizeStop}
defaultSize={resizeDefaultSize}
className="flex grow flex-col"
className="relative z-10 flex grow flex-col"
>
<div className={"relative z-10 flex flex-col bg-background"}>
<div className={"relative flex flex-col"}>
<div className={"flex items-center"}>
<Dialog.Title
className="flex w-0 max-w-full grow items-center gap-2 px-2 pb-1 pt-2 text-base font-medium text-text"

View File

@ -1,7 +1,7 @@
import { usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { Paper } from "~/components/ui/paper"
import { EntryContent as EntryContentLegacy } from "~/modules/entry-content/EntryContent.legacy"
import { EntryContentForPreview } from "~/modules/entry-content/EntryContentForPreview"
export const EntryModalPreview = ({ entryId }: { entryId: string }) => {
const { isPending } = usePrefetchEntryDetail(entryId)
@ -11,7 +11,7 @@ export const EntryModalPreview = ({ entryId }: { entryId: string }) => {
{isPending ? (
<PeekModalSkeleton />
) : (
<EntryContentLegacy
<EntryContentForPreview
className="h-auto [&_#entry-action-header-bar]:!bg-transparent"
entryId={entryId}
/>

View File

@ -1,5 +1,6 @@
import { useEntry } from "@follow/store/entry/hooks"
import type { EntryModel } from "@follow/store/entry/types"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { useRouteParamsSelector } from "./useRouteParams"
@ -7,14 +8,17 @@ const selector = (state: EntryModel) => state.read
export function useEntryIsRead(entryId?: string) {
const entryRead = useEntry(entryId, selector)
const isLoggedIn = useIsLoggedIn()
return useRouteParamsSelector(
(params) => {
if (!isLoggedIn) return true
if (params.isCollection) {
return true
}
if (entryRead === undefined) return false
return entryRead
},
[entryRead],
[entryRead, isLoggedIn],
)
}

View File

@ -2,6 +2,7 @@ import { isMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, getView, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { isOnboardingEntryUrl } from "@follow/store/constants/onboarding"
import { useEntry } from "@follow/store/entry/hooks"
import { entrySyncServices } from "@follow/store/entry/store"
import type { EntryModel } from "@follow/store/entry/types"
@ -11,7 +12,6 @@ import { useUserRole } from "@follow/store/user/hooks"
import { doesTextContainHTML } from "@follow/utils/utils"
import { useMemo } from "react"
import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
import { MENU_ITEM_SEPARATOR, MenuItemSeparator, MenuItemText } from "~/atoms/context-menu"
import {
@ -26,7 +26,8 @@ import { ipcServices } from "~/lib/client"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { getCommand, useRunCommandFn } from "~/modules/command/hooks/use-command"
import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding"
import type { FollowCommandId } from "~/modules/command/types"
import { isMutationCommandId } from "~/modules/command/mutation-command-ids"
import type { FollowCommandId, UnknownCommand } from "~/modules/command/types"
import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks"
import { useRouteParams } from "./useRouteParams"
@ -76,6 +77,7 @@ interface EntryActionMenuItemConfig {
disabled?: boolean
notice?: boolean
entryId: string
requiresLogin?: boolean
}
export class EntryActionMenuItem extends MenuItemText {
@ -83,14 +85,19 @@ export class EntryActionMenuItem extends MenuItemText {
constructor(config: EntryActionMenuItemConfig) {
const cmd = getCommand(config.id) || null
const requiresLogin = config.requiresLogin ?? isMutationCommandId(config.id)
super({
...config,
label: cmd?.label.title || "",
click: () => config.onClick?.(),
hide: !cmd || config.hide,
requiresLogin,
})
this.privateConfig = config
this.privateConfig = {
...config,
requiresLogin,
}
}
public get id() {
@ -123,14 +130,19 @@ export class EntryActionDropdownItem extends MenuItemText {
constructor(config: EntryActionMenuItemConfig & { children?: EntryActionMenuItem[] }) {
const cmd = getCommand(config.id) || null
const requiresLogin = config.requiresLogin ?? isMutationCommandId(config.id)
super({
...config,
label: cmd?.label.title || "",
click: () => config.onClick?.(),
hide: !cmd || config.hide,
requiresLogin,
})
this.privateConfig = config
this.privateConfig = {
...config,
requiresLogin,
}
this.children = config.children || []
}
@ -207,8 +219,12 @@ const entrySelector = (state: EntryModel) => {
}
export const HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU: FollowCommandId[] = [
COMMAND_ID.entry.viewSourceContent,
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.copyTitle,
COMMAND_ID.entry.copyLink,
COMMAND_ID.entry.exportAsPDF,
COMMAND_ID.entry.imageGallery,
COMMAND_ID.entry.toggleAITranslation,
COMMAND_ID.entry.share,
COMMAND_ID.settings.customizeToolbar,
COMMAND_ID.entry.readability,
@ -235,8 +251,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
const isInbox = useIsInbox(entry?.inboxId)
const isShowSourceContent = useShowSourceContent()
const isShowAISummaryAuto = useShowAISummaryAuto(entry?.summary)
const isShowAISummaryOnce = useShowAISummaryOnce()
const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
const isShowAITranslationOnce = useShowAITranslationOnce()
@ -249,6 +264,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
const shortcuts = useCommandShortcuts()
const isCurrentVisitEntry = routeEntryId === entryId
const isOnboardingEntry = isOnboardingEntryUrl(entry?.url)
const actionConfigs: EntryActionItem[] = useMemo(() => {
if (!hasEntry) return []
@ -318,6 +334,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.copyLink],
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -330,6 +347,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
id: COMMAND_ID.entry.imageGallery,
hide: entry.imagesLength <= 5,
onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]),
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -337,6 +355,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
hide: !entry.url,
onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),
shortcut: shortcuts[COMMAND_ID.entry.openInBrowser],
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -346,18 +365,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
]),
hide: isMobile() || !entry.url,
active: isShowSourceContent,
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.toggleAISummary,
onClick: runCmdFn(COMMAND_ID.entry.toggleAISummary, []),
hide:
isShowAISummaryAuto ||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
view,
),
active: isShowAISummaryOnce,
disabled: userRole === UserRole.Free || userRole === UserRole.Trial,
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -373,10 +381,11 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.share,
onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.share],
id: COMMAND_ID.entry.read,
onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),
hide: !!isCollection,
active: !!entry.read,
shortcut: shortcuts[COMMAND_ID.entry.read],
entryId,
}),
new EntryActionMenuItem({
@ -385,20 +394,19 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
hide: !!isCollection,
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.read,
onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),
hide: !!isCollection,
active: !!entry.read,
shortcut: shortcuts[COMMAND_ID.entry.read],
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.readBelow,
onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.publishedAt }]),
hide: !!isCollection,
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.share,
onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.share],
entryId,
}),
MENU_ITEM_SEPARATOR,
new EntryActionMenuItem({
id: COMMAND_ID.entry.delete,
@ -410,7 +418,6 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
new EntryActionMenuItem({
id: COMMAND_ID.entry.tts,
onClick: runCmdFn(COMMAND_ID.entry.tts, [{ entryId }]),
hide: !IN_ELECTRON || !entry.hasContent,
shortcut: shortcuts[COMMAND_ID.entry.tts],
entryId,
}),
@ -420,6 +427,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
hide: !!entry.readability || (view && getView(view)?.wideMode) || !entry.url,
active: isEntryInReadability,
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
disabled: isOnboardingEntry,
entryId,
}),
@ -438,7 +446,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
onClick: runCmdFn(COMMAND_ID.integration.custom, [{ entryId }]),
entryId,
children: enabledIntegrations.map((integration) => {
const virtualId = `integration:custom:${integration.id}` as FollowCommandId
const virtualId = `integration:custom:${integration.id}` as UnknownCommand["id"]
return new EntryActionMenuItem({
id: virtualId,
onClick: () => {
@ -468,11 +476,8 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
entry?.imagesLength,
entry?.publishedAt,
entry?.read,
entry?.hasContent,
entry?.readability,
entry?.doesContentContainsHTMLTags,
feed?.id,
feed?.ownerUserId,
feed?.siteUrl,
isInbox,
shortcuts,
@ -480,8 +485,6 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
isInCollection,
isCurrentVisitEntry,
isShowSourceContent,
isShowAISummaryAuto,
isShowAISummaryOnce,
userRole,
isShowAITranslationAuto,
isShowAITranslationOnce,
@ -489,6 +492,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
isEntryInReadability,
integrationSettings.customIntegration,
integrationSettings.enableCustomIntegration,
isOnboardingEntry,
])
return actionConfigs

View File

@ -12,7 +12,6 @@ import { HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, useEntryActions } from "~/hooks/biz
import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { copyToClipboard } from "~/lib/clipboard"
import { COMMAND_ID } from "~/modules/command/commands/id"
export function useEntryContextMenu({
entryId,
@ -45,12 +44,6 @@ export function useEntryContextMenu({
return item && !item.disabled
}),
MENU_ITEM_SEPARATOR,
// Copy section
...actionConfigs.filter((item) => {
if (item instanceof MenuItemSeparator) return false
// @ts-expect-error id exists
return [COMMAND_ID.entry.copyTitle, COMMAND_ID.entry.copyLink].includes(item.id)
}),
new MenuItemText({
label: `${t("words.copy")}${t("space")}${t("words.entry")} ${t("words.id")}`,
click: () => copyToClipboard(entryId),

View File

@ -35,8 +35,6 @@ import { useCategoryCreationModal } from "~/modules/settings/tabs/lists/hooks"
import { ListCreationModalContent } from "~/modules/settings/tabs/lists/modals"
import { useResetFeed } from "~/queries/feed"
import { useNavigateEntry } from "./useNavigateEntry"
import { getRouteParams } from "./useRouteParams"
import { useBatchUpdateSubscription, useDeleteSubscription } from "./useSubscriptionActions"
export const useFeedActions = ({
@ -73,7 +71,6 @@ export const useFeedActions = ({
const deleteSubscription = useDeleteSubscription({})
const claimFeed = useFeedClaimModal()
const navigateEntry = useNavigateEntry()
const isEntryList = type === "entryList"
const { mutateAsync: addFeedToListMutation } = useAddFeedToFeedList()
@ -102,40 +99,46 @@ export const useFeedActions = ({
disabled: isEntryList,
click: () => unreadSyncService.markFeedAsRead(isMultipleSelection ? feedIds : [feedId]),
supportMultipleSelection: true,
requiresLogin: true,
}),
new MenuItemSeparator(isEntryList),
new MenuItemText({
label: isEntryList ? t("sidebar.feed_actions.edit_feed") : t("sidebar.feed_actions.edit"),
shortcut: "E",
disabled: isInbox,
click: () => {
present({
modalContentClassName: "overflow-visible",
title: t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => <FeedForm id={feedId} onSuccess={dismiss} />,
})
},
requiresLogin: true,
}),
new MenuItemText({
label: isMultipleSelection
? t("sidebar.feed_actions.unfollow_feed_many")
: isEntryList
? t("sidebar.feed_actions.unfollow_feed")
: t("sidebar.feed_actions.unfollow"),
shortcut: "$mod+Backspace",
disabled: isInbox,
supportMultipleSelection: true,
click: () => {
if (isMultipleSelection) {
presentDeleteSubscription(feedIds)
return
}
deleteSubscription.mutate({ subscription })
},
requiresLogin: true,
}),
!related.ownerUserId &&
!!isBizId(related.id) &&
related.type === "feed" &&
new MenuItemText({
label: isEntryList
? t("sidebar.feed_actions.claim_feed")
: t("sidebar.feed_actions.claim"),
shortcut: "C",
click: () => {
claimFeed({ feedId })
},
}),
...(isFeedOwner
? [
MenuItemSeparator.default,
new MenuItemText({
label: t("sidebar.feed_actions.feed_owned_by_you"),
disabled: true,
}),
new MenuItemText({
label: t("sidebar.feed_actions.reset_feed"),
click: () => {
resetFeed(feedId)
},
}),
MenuItemSeparator.default,
]
: []),
new MenuItemSeparator(isEntryList),
new MenuItemText({
label: t("sidebar.feed_column.context_menu.add_feeds_to_list"),
disabled: isInbox,
supportMultipleSelection: true,
requiresLogin: true,
submenu: [
...listByView.map((list) => {
const isIncluded = list.feedIds.includes(feedId)
@ -163,6 +166,7 @@ export const useFeedActions = ({
})
}
},
requiresLogin: true,
})
}),
listByView.length > 0 && new MenuItemSeparator(),
@ -175,6 +179,7 @@ export const useFeedActions = ({
content: () => <ListCreationModalContent />,
})
},
requiresLogin: true,
}),
],
}),
@ -182,6 +187,7 @@ export const useFeedActions = ({
label: t("sidebar.feed_column.context_menu.add_feeds_to_category"),
disabled: isInbox,
supportMultipleSelection: true,
requiresLogin: true,
submenu: [
...Array.from(categories.values()).map((category) => {
const isIncluded = isMultipleSelection
@ -197,6 +203,7 @@ export const useFeedActions = ({
view: view!,
})
},
requiresLogin: true,
})
}),
listByView.length > 0 && MenuItemSeparator.default,
@ -206,47 +213,41 @@ export const useFeedActions = ({
click() {
presentCategoryCreationModal(view!, isMultipleSelection ? feedIds : [feedId])
},
requiresLogin: true,
}),
],
}),
new MenuItemSeparator(isEntryList),
new MenuItemText({
label: isEntryList ? t("sidebar.feed_actions.edit_feed") : t("sidebar.feed_actions.edit"),
shortcut: "E",
disabled: isInbox,
click: () => {
present({
modalContentClassName: "overflow-visible",
title: t("sidebar.feed_actions.edit_feed"),
content: ({ dismiss }) => <FeedForm id={feedId} onSuccess={dismiss} />,
})
},
}),
new MenuItemText({
label: isMultipleSelection
? t("sidebar.feed_actions.unfollow_feed_many")
: isEntryList
? t("sidebar.feed_actions.unfollow_feed")
: t("sidebar.feed_actions.unfollow"),
shortcut: "$mod+Backspace",
disabled: isInbox,
supportMultipleSelection: true,
click: () => {
if (isMultipleSelection) {
presentDeleteSubscription(feedIds)
return
}
deleteSubscription.mutate({ subscription })
},
}),
new MenuItemText({
label: t("sidebar.feed_actions.navigate_to_feed"),
shortcut: "$mod+G",
disabled: getRouteParams().feedId === feedId,
click: () => {
navigateEntry({ feedId })
},
}),
!related.ownerUserId &&
!!isBizId(related.id) &&
related.type === "feed" &&
new MenuItemText({
label: isEntryList
? t("sidebar.feed_actions.claim_feed")
: t("sidebar.feed_actions.claim"),
shortcut: "C",
click: () => {
claimFeed({ feedId })
},
disabled: isEntryList,
requiresLogin: true,
}),
...(isFeedOwner
? [
MenuItemSeparator.default,
new MenuItemText({
label: t("sidebar.feed_actions.feed_owned_by_you"),
disabled: true,
}),
new MenuItemText({
label: t("sidebar.feed_actions.reset_feed"),
click: () => {
resetFeed(feedId)
},
requiresLogin: true,
}),
MenuItemSeparator.default,
]
: []),
new MenuItemSeparator(isEntryList),
new MenuItemText({
label: t("sidebar.feed_actions.open_feed_in_browser", {
@ -269,18 +270,6 @@ export const useFeedActions = ({
}
},
}),
new MenuItemSeparator(isEntryList),
new MenuItemText({
label: t("sidebar.feed_actions.copy_feed_url"),
disabled: isEntryList,
shortcut: "$mod+C",
click: () => {
const { url, siteUrl } = feed || {}
const copied = url || siteUrl
if (!copied) return
copyToClipboard(copied)
},
}),
new MenuItemText({
label: t("sidebar.feed_actions.copy_feed_id"),
shortcut: "$mod+Shift+C",
@ -289,15 +278,6 @@ export const useFeedActions = ({
copyToClipboard(feedId)
},
}),
new MenuItemText({
label: t("sidebar.feed_actions.copy_feed_badge"),
disabled: isEntryList,
click: () => {
copyToClipboard(
`https://badge.folo.is/feed/${feedId}?color=FF5C00&labelColor=black&style=flat-square`,
)
},
}),
]
return items.filter(
@ -322,7 +302,6 @@ export const useFeedActions = ({
isInbox,
isMultipleSelection,
listByView,
navigateEntry,
present,
presentCategoryCreationModal,
presentDeleteSubscription,
@ -347,28 +326,18 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
const { mutateAsync: deleteSubscription } = useDeleteSubscription({})
const shortcuts = useCommandShortcuts()
const navigateEntry = useNavigateEntry()
const items = useMemo(() => {
if (!list) return []
const items: MenuItemInput[] = [
...(list.ownerUserId === whoami()?.id
? [
new MenuItemText({
label: t("sidebar.feed_actions.list_owned_by_you"),
disabled: true,
}),
MenuItemSeparator.default,
]
: []),
new MenuItemText({
label: t("sidebar.feed_actions.mark_all_as_read"),
shortcut: shortcuts[COMMAND_ID.subscription.markAllAsRead],
click: () => {
unreadSyncService.markFeedAsRead(list.feedIds)
},
requiresLogin: true,
}),
MenuItemSeparator.default,
new MenuItemText({
@ -380,21 +349,25 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
content: ({ dismiss }) => <ListForm id={listId} onSuccess={dismiss} />,
})
},
requiresLogin: true,
}),
new MenuItemText({
label: t("sidebar.feed_actions.unfollow"),
shortcut: "$mod+Backspace",
click: () => deleteSubscription({ subscription }),
}),
new MenuItemText({
label: t("sidebar.feed_actions.navigate_to_list"),
shortcut: "$mod+G",
disabled: getRouteParams().feedId === listId,
click: () => {
navigateEntry({ listId })
},
requiresLogin: true,
}),
MenuItemSeparator.default,
...(list.ownerUserId === whoami()?.id
? [
new MenuItemText({
label: t("sidebar.feed_actions.list_owned_by_you"),
disabled: true,
}),
MenuItemSeparator.default,
]
: []),
new MenuItemText({
label: t("sidebar.feed_actions.open_list_in_browser", {
which: t(IN_ELECTRON ? "words.browser" : "words.newTab"),
@ -402,7 +375,6 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
shortcut: shortcuts[COMMAND_ID.subscription.openInBrowser],
click: () => window.open(UrlBuilder.shareList(listId, view), "_blank"),
}),
MenuItemSeparator.default,
new MenuItemText({
label: t("sidebar.feed_actions.copy_list_url"),
shortcut: "$mod+C",
@ -420,7 +392,7 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
]
return items
}, [list, t, shortcuts, listId, present, deleteSubscription, subscription, navigateEntry, view])
}, [list, t, shortcuts, listId, present, deleteSubscription, subscription, view])
return items
}
@ -443,6 +415,7 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
content: () => <InboxForm asWidget id={inboxId} />,
})
},
requiresLogin: true,
}),
MenuItemSeparator.default,
new MenuItemText({

View File

@ -1,63 +1,17 @@
import { UserRole } from "@follow/constants"
import { getFeedByIdOrUrl } from "@follow/store/feed/getter"
import { getSubscriptionByFeedId } from "@follow/store/subscription/getter"
import {
useFeedSubscriptionCount,
useListSubscriptionCount,
} from "@follow/store/subscription/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import { t } from "i18next"
import { useCallback } from "react"
import { useNavigate } from "react-router"
import { withoutTrailingSlash, withTrailingSlash } from "ufo"
import { useEventCallback } from "usehooks-ts"
import { previewBackPath } from "~/atoms/preview"
import { useServerConfigs } from "~/atoms/server-configs"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { CustomSafeError } from "~/errors/CustomSafeError"
import { useActivationModal } from "~/modules/activation"
import type { FeedFormDataValuesType } from "~/modules/discover/FeedForm"
import { FeedForm } from "~/modules/discover/FeedForm"
import type { ListFormDataValuesType } from "~/modules/discover/ListForm"
import { ListForm } from "~/modules/discover/ListForm"
const useCanFollowMoreInboxAndNotify = () => {
const role = useUserRole()
const listCurrentCount = useListSubscriptionCount()
const feedCurrentCount = useFeedSubscriptionCount()
const presentActivationModal = useActivationModal()
const serverConfigs = useServerConfigs()
return useEventCallback((type: "list" | "feed") => {
if (role === UserRole.Free || role === UserRole.Trial) {
const LIMIT =
(type !== "list"
? serverConfigs?.MAX_TRIAL_USER_FEED_SUBSCRIPTION
: serverConfigs?.MAX_TRIAL_USER_LIST_SUBSCRIPTION) || 50
const CURRENT = type === "list" ? listCurrentCount : feedCurrentCount
const can = CURRENT < LIMIT
if (!can) {
presentActivationModal()
throw new CustomSafeError(
`Trial user cannot create more ${type}, limit: ${LIMIT}, current: ${CURRENT}`,
true,
)
}
return can
} else {
// const can = currentInboxCount < MAX_INBOX_COUNT
// if (!can) {
// // TODO
// }
// return can
return true
}
})
}
export interface FollowOptions {
isList: boolean
id?: string
@ -68,17 +22,10 @@ export interface FollowOptions {
}
export const useFollow = () => {
const { present } = useModalStack()
const canFollowMoreInboxAndNotify = useCanFollowMoreInboxAndNotify()
const navigate = useNavigate()
return useCallback(
(options?: FollowOptions) => {
if (options?.isList) {
canFollowMoreInboxAndNotify("list")
} else {
canFollowMoreInboxAndNotify("feed")
}
// Some feeds redirect xxx.com/feed to xxx.com/feed/
// Try to get a valid feed, then we can check isFollowed correctly
const feed =
@ -117,6 +64,6 @@ export const useFollow = () => {
},
})
},
[canFollowMoreInboxAndNotify, present],
[present],
)
}

View File

@ -1,7 +1,7 @@
import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/atoms/route.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { useSheetContext } from "@follow/components/ui/sheet/context.js"
import { FeedViewType } from "@follow/constants"
import type { FeedViewType } from "@follow/constants"
import { getEntry } from "@follow/store/entry/getter"
import { getSubscriptionByFeedId } from "@follow/store/subscription/getter"
import { tracker } from "@follow/tracker"
@ -18,11 +18,9 @@ import {
ROUTE_FEED_IN_INBOX,
ROUTE_FEED_IN_LIST,
ROUTE_FEED_PENDING,
ROUTE_TIMELINE_OF_VIEW,
ROUTE_VIEW_ALL,
} from "~/constants"
import { useRouteParamsSelector } from "./useRouteParams"
import { getTimelineIdByView, useRouteParamsSelector } from "./useRouteParams"
export type NavigateEntryOptions = Partial<{
timelineId: string
@ -86,8 +84,7 @@ const parseNavigateEntryOptions = (options: NavigateEntryOptions): ParsedNavigat
finalFeedId = encodeURIComponent(finalFeedId)
if (finalView !== undefined && !timelineId) {
finalTimelineId =
finalView === FeedViewType.All ? ROUTE_VIEW_ALL : `${ROUTE_TIMELINE_OF_VIEW}${finalView}`
finalTimelineId = getTimelineIdByView(finalView)
}
return {
@ -109,7 +106,7 @@ export function getNavigateEntryPath(options: NavigateEntryOptions | ParsedNavig
/*
* /timeline/:timelineId/:feedId/:entryId
* timelineId: view-1
* timelineId: articles | social-media | view-1 (legacy) | ...
* feedId: xxx, folder-xxx, list-xxx, inbox-xxx
* entryId: xxx
*/

View File

@ -1,4 +1,5 @@
import { useEntry } from "@follow/store/entry/hooks"
import { getSubscriptionById } from "@follow/store/subscription/getter"
import { useCallback } from "react"
import { disableShowAISummaryOnce } from "~/atoms/ai-summary"
@ -10,7 +11,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { EntryModalPreview } from "~/components/ui/peek-modal/EntryModalPreview"
import { EntryMoreActions } from "~/components/ui/peek-modal/EntryMoreActions"
import { EntryToastPreview } from "~/components/ui/peek-modal/EntryToastPreview"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { getRouteParams, getTimelineIdByView } from "~/hooks/biz/useRouteParams"
export const usePeekModal = () => {
const { present } = useModalStack()
@ -39,7 +40,9 @@ export const usePeekModal = () => {
CustomModalComponent: ({ children }) => {
const feedId = useEntry(entryId, (state) => state.feedId)
const subscription = feedId ? getSubscriptionById(feedId) : undefined
const view = subscription?.view ?? getRouteParams().view
const timelineId = getTimelineIdByView(view)
return (
<PeekModal
rightActions={[
@ -49,11 +52,7 @@ export const usePeekModal = () => {
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={
feedId
? `/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`
: undefined
}
to={feedId ? `/timeline/${timelineId}/${feedId}/${entryId}` : undefined}
>
{children}
</PeekModal>

View File

@ -43,14 +43,59 @@ export interface BizRouteParams {
timelineId?: string
}
const VIEW_SLUG_BY_VIEW: Record<FeedViewType, string> = {
[FeedViewType.All]: ROUTE_VIEW_ALL,
[FeedViewType.Articles]: "articles",
[FeedViewType.SocialMedia]: "social-media",
[FeedViewType.Pictures]: "pictures",
[FeedViewType.Videos]: "videos",
[FeedViewType.Audios]: "audios",
[FeedViewType.Notifications]: "notifications",
}
const VIEW_PARAM_ALIAS_MAP: Record<string, FeedViewType> = Object.entries(VIEW_SLUG_BY_VIEW).reduce(
(acc, [view, slug]) => {
if (slug === ROUTE_VIEW_ALL) return acc
const numericView = Number(view)
if (Number.isNaN(numericView)) return acc
acc[slug] = numericView as FeedViewType
return acc
},
{} as Record<string, FeedViewType>,
)
const FEED_VIEW_VALUES = new Set<FeedViewType>(
Object.values(FeedViewType).filter((value): value is FeedViewType => typeof value === "number"),
)
const isFeedViewTypeValue = (value: number): value is FeedViewType =>
Number.isInteger(value) && FEED_VIEW_VALUES.has(value as FeedViewType)
export const getTimelineIdByView = (view: FeedViewType) =>
VIEW_SLUG_BY_VIEW[view] ?? `${ROUTE_TIMELINE_OF_VIEW}${view}`
export function parseView(input: string | undefined): FeedViewType | undefined {
if (input === ROUTE_VIEW_ALL) return FeedViewType.All
if (input?.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
const view = Number.parseInt(input?.slice(ROUTE_TIMELINE_OF_VIEW.length), 10)
if (Object.values(FeedViewType).includes(view)) {
return view as FeedViewType
if (!input) return undefined
const normalizedInput = input.toLowerCase()
if (normalizedInput === ROUTE_VIEW_ALL) return FeedViewType.All
const aliasView = VIEW_PARAM_ALIAS_MAP[normalizedInput]
if (aliasView !== undefined) return aliasView
if (normalizedInput.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
const view = Number.parseInt(normalizedInput.slice(ROUTE_TIMELINE_OF_VIEW.length), 10)
if (isFeedViewTypeValue(view)) {
return view
}
}
const numericView = Number.parseInt(normalizedInput, 10)
if (isFeedViewTypeValue(numericView)) {
return numericView
}
}
const parseRouteParams = (params: Params<any>, _searchParams: URLSearchParams): BizRouteParams => {

View File

@ -1,19 +1,16 @@
import { FeedViewType } from "@follow/constants"
import { getView } from "@follow/constants"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export const useShowEntryDetailsColumn = () => {
const { view, isInEntry } = useRouteParamsSelector((s) => ({
const { view } = useRouteParamsSelector((s) => ({
view: s.view,
isInEntry: s.entryId && !s.isPendingEntry,
}))
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
return (
(view === FeedViewType.All || view === FeedViewType.Articles) &&
(aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible) &&
isInEntry
!getView(view).wideMode && (aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible)
)
}

View File

@ -1,44 +1,111 @@
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { FeedViewType, getViewList } from "@follow/constants"
import type { UISettings } from "@follow/shared/settings/interface"
import { useSubscriptionStore } from "@follow/store/subscription/store"
import { useMemo } from "react"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ROUTE_VIEW_ALL } from "~/constants/app"
import { getTimelineIdByView, parseView } from "./useRouteParams"
const ALL_TIMELINE_IDS = getViewList({ includeAll: true }).map((view) =>
getTimelineIdByView(view.view),
)
const normalizeTimelineId = (id: string) => {
const view = parseView(id)
return view !== undefined ? getTimelineIdByView(view) : id
}
const filterKnownTimelineIds = (ids: string[]) => {
const seen = new Set<string>()
return ids.filter((id) => {
if (!ALL_TIMELINE_IDS.includes(id)) return false
if (seen.has(id)) return false
seen.add(id)
return true
})
}
export const computeTimelineTabLists = ({
timelineTabs,
hasAudiosSubscription,
hasNotificationsSubscription,
}: {
timelineTabs?: UISettings["timelineTabs"]
hasAudiosSubscription: boolean
hasNotificationsSubscription: boolean
}) => {
const savedVisible = filterKnownTimelineIds(
(timelineTabs?.visible ?? []).map(normalizeTimelineId),
)
const savedHidden = filterKnownTimelineIds((timelineTabs?.hidden ?? []).map(normalizeTimelineId))
const extras = ALL_TIMELINE_IDS.filter(
(id) => !savedVisible.includes(id) && !savedHidden.includes(id),
)
const isDefaultHidden = (id: string) => {
if (id === getTimelineIdByView(FeedViewType.Audios)) return !hasAudiosSubscription
if (id === getTimelineIdByView(FeedViewType.Notifications)) return !hasNotificationsSubscription
return false
}
const extraVisible = extras.filter((id) => !isDefaultHidden(id))
const extraHidden = extras.filter((id) => isDefaultHidden(id))
const allConfigured =
savedVisible.includes(ROUTE_VIEW_ALL) || savedHidden.includes(ROUTE_VIEW_ALL)
let nextVisible = [...savedVisible]
if (!allConfigured && extraVisible.includes(ROUTE_VIEW_ALL)) {
nextVisible = [ROUTE_VIEW_ALL, ...nextVisible]
}
nextVisible = [...nextVisible, ...extraVisible.filter((id) => id !== ROUTE_VIEW_ALL)]
const nextHidden = [...savedHidden, ...extraHidden].filter((id) => !nextVisible.includes(id))
return { visible: nextVisible, hidden: nextHidden }
}
export const useTimelineList = (options?: {
ordered?: boolean
visible?: boolean
hidden?: boolean
withAll?: boolean
}) => {
const timelineTabs = useUISettingKey("timelineTabs")
const views = useViewWithSubscription()
const hasAudiosSubscription = useSubscriptionStore(
(state) =>
state.feedIdByView[FeedViewType.Audios].size > 0 ||
state.listIdByView[FeedViewType.Audios].size > 0,
)
const hasNotificationsSubscription = useSubscriptionStore(
(state) =>
state.feedIdByView[FeedViewType.Notifications].size > 0 ||
state.listIdByView[FeedViewType.Notifications].size > 0,
)
const viewsIds = useMemo(() => {
const ids = views.map((view) => `view-${view}`)
if (!options?.ordered) {
return ids
}
const savedVisible = (timelineTabs?.visible ?? []).filter((id) => ids.includes(id))
const savedHidden = (timelineTabs?.hidden ?? []).filter((id) => ids.includes(id))
const extra = ids.filter((id) => !savedVisible.includes(id) && !savedHidden.includes(id))
const visible = [...savedVisible, ...extra].slice(0, 5)
if (options?.visible) return visible
const hidden = [...savedHidden, ...extra].filter((id) => !visible.includes(id))
if (options?.hidden) return hidden
const ordered = [...visible, ...hidden]
return ordered
}, [
options?.hidden,
options?.ordered,
options?.visible,
timelineTabs?.hidden,
timelineTabs?.visible,
views,
])
const { visible, hidden } = useMemo(
() =>
computeTimelineTabLists({
timelineTabs,
hasAudiosSubscription,
hasNotificationsSubscription,
}),
[hasAudiosSubscription, hasNotificationsSubscription, timelineTabs],
)
return useMemo(() => {
return options?.withAll ? [ROUTE_VIEW_ALL, ...viewsIds] : viewsIds
}, [options?.withAll, viewsIds])
let result: string[]
if (options?.visible) result = visible
else if (options?.hidden) result = hidden
else result = [...visible, ...hidden]
if (options?.withAll === false) {
result = result.filter((id) => id !== ROUTE_VIEW_ALL)
}
return result
}, [hidden, options?.hidden, options?.visible, options?.withAll, visible])
}

View File

@ -1,5 +1,8 @@
export * from "./useBizQuery"
export * from "./useContextMenu"
export * from "./useI18n"
export * from "./useLoginModal"
export * from "./usePreventOverscrollBounce"
export * from "./useRecaptchaToken"
export * from "./useRequireLogin"
export * from "./useSyncTheme"

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