release(desktop): Release v1.0.0

release(desktop): Release v1.0.0
This commit is contained in:
DIYgod 2025-11-01 08:28:04 +08:00 committed by GitHub
commit 8187784cc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
142 changed files with 4248 additions and 2154 deletions

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

@ -68,7 +68,7 @@ jobs:
- name: 📤 Upload apk Artifact
if: github.event.inputs.profile != 'production'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: app-android
path: ${{ github.workspace }}/build.apk
@ -76,7 +76,7 @@ jobs:
- name: 📤 Upload aab Artifact
if: github.event.inputs.profile == 'production'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: aab-android
path: ${{ github.workspace }}/build.aab

View File

@ -183,7 +183,7 @@ jobs:
run: pnpm build:render
- name: Upload file (macos-arm64-dmg)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
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@v5
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@v5
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@v5
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@v5
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@v5
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@v5
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

@ -60,7 +60,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -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@v5
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -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@v5
with:
name: app-ios-development-simulator
path: apps/mobile/build-simulator.ipa

View File

@ -67,7 +67,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: app-ios
path: apps/mobile/build.ipa
@ -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@v5
with:
name: app-ios
path: apps/mobile/build.ipa

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

@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@byjohann/toon": "0.4.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@electron-toolkit/preload": "3.0.2",
@ -29,6 +30,7 @@
"@lexical/markdown": "0.33.1",
"@lexical/react": "0.33.1",
"@lottiefiles/dotlottie-react": "0.17.5",
"@number-flow/react": "0.5.10",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-context-menu": "2.2.16",
@ -84,7 +86,6 @@
"nanoid": "5.1.6",
"ofetch": "1.4.1",
"plain-shiki": "0.3.1",
"progressive-blur": "1.0.0",
"re-resizable": "6.11.2",
"react": "19.0.0",
"react-blurhash": "0.3.0",
@ -125,7 +126,7 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@folo-services/ai-tools": "0.2.47",
"@folo-services/ai-tools": "0.2.49",
"@types/node": "24.8.1",
"@vite-pwa/assets-generator": "1.0.2",
"fake-indexeddb": "6.2.4",

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

@ -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 { isNeedUpgradeError } 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 code returned when requesting the summary */
errorCode?: number
}
const DefaultLoadingState = () => (
@ -41,12 +44,24 @@ 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()
return (
<div
className="text-center"
onClick={shouldSuggestUpgrade ? () => settingModalPresent("plan") : undefined}
>
<p className="text-sm text-text-secondary">{message}</p>
</div>
)
}
export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
content,
@ -55,16 +70,20 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
headerContent,
footerContent,
loadingComponent,
emptyComponent,
title = "AI Summary",
showCopyButton = true,
showAskAIButton = false,
onAskAI,
errorCode,
}) => {
const { t } = useTranslation("app")
const aiEnabled = useFeature("ai")
const isPaymentEnabled = useIsPaymentEnabled()
const hasContent = !isLoading && content
const normalizedErrorCode = typeof errorCode === "number" ? errorCode : undefined
const shouldSuggestUpgrade =
normalizedErrorCode !== undefined && isPaymentEnabled && isNeedUpgradeError(normalizedErrorCode)
return (
<div
@ -167,12 +186,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

@ -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

@ -21,6 +21,7 @@ import { ipcServices } from "~/lib/client"
import { replaceImgUrlIfNeed } 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"

View File

@ -357,7 +357,6 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
view,
),
active: isShowAISummaryOnce,
disabled: userRole === UserRole.Free || userRole === UserRole.Trial,
entryId,
}),
new EntryActionMenuItem({

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,10 +1,56 @@
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_TIMELINE_OF_VIEW, ROUTE_VIEW_ALL } from "~/constants/app"
const ALL_TIMELINE_IDS = getViewList({ includeAll: true }).map((view) =>
view.view === FeedViewType.All ? ROUTE_VIEW_ALL : `${ROUTE_TIMELINE_OF_VIEW}${view.view}`,
)
export const computeTimelineTabLists = ({
timelineTabs,
hasAudiosSubscription,
hasNotificationsSubscription,
}: {
timelineTabs?: UISettings["timelineTabs"]
hasAudiosSubscription: boolean
hasNotificationsSubscription: boolean
}) => {
const savedVisible = (timelineTabs?.visible ?? []).filter((id) => ALL_TIMELINE_IDS.includes(id))
const savedHidden = (timelineTabs?.hidden ?? []).filter((id) => ALL_TIMELINE_IDS.includes(id))
const extras = ALL_TIMELINE_IDS.filter(
(id) => !savedVisible.includes(id) && !savedHidden.includes(id),
)
const isDefaultHidden = (id: string) => {
if (id === `${ROUTE_TIMELINE_OF_VIEW}${FeedViewType.Audios}`) return !hasAudiosSubscription
if (id === `${ROUTE_TIMELINE_OF_VIEW}${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?: {
visible?: boolean
hidden?: boolean
@ -22,50 +68,15 @@ export const useTimelineList = (options?: {
state.listIdByView[FeedViewType.Notifications].size > 0,
)
const allTimelineIds = useMemo(() => {
return getViewList({ includeAll: true }).map((view) =>
view.view === FeedViewType.All ? ROUTE_VIEW_ALL : `${ROUTE_TIMELINE_OF_VIEW}${view.view}`,
)
}, [])
const { visible, hidden } = useMemo(() => {
const ids = allTimelineIds
const savedVisible = (timelineTabs?.visible ?? []).filter((id) => ids.includes(id))
const savedHidden = (timelineTabs?.hidden ?? []).filter((id) => ids.includes(id))
const extras = ids.filter((id) => !savedVisible.includes(id) && !savedHidden.includes(id))
let nextVisible = [...savedVisible]
let nextHidden = [...savedHidden]
const isDefaultHidden = (id: string) => {
if (id === `${ROUTE_TIMELINE_OF_VIEW}${FeedViewType.Audios}`) return !hasAudiosSubscription
if (id === `${ROUTE_TIMELINE_OF_VIEW}${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)
if (!allConfigured && extraVisible.includes(ROUTE_VIEW_ALL)) {
nextVisible = [ROUTE_VIEW_ALL, ...nextVisible]
}
nextVisible = [...nextVisible, ...extraVisible.filter((id) => id !== ROUTE_VIEW_ALL)]
nextHidden = [...nextHidden, ...extraHidden].filter((id) => !nextVisible.includes(id))
return { visible: nextVisible, hidden: nextHidden }
}, [
allTimelineIds,
hasAudiosSubscription,
hasNotificationsSubscription,
timelineTabs?.hidden,
timelineTabs?.visible,
])
const { visible, hidden } = useMemo(
() =>
computeTimelineTabLists({
timelineTabs,
hasAudiosSubscription,
hasNotificationsSubscription,
}),
[hasAudiosSubscription, hasNotificationsSubscription, timelineTabs],
)
return useMemo(() => {
let result: string[]

View File

@ -7,6 +7,7 @@ import { repository } from "@pkg"
import { enableMapSet } from "immer"
import { initI18n } from "~/i18n"
import { hydrateSessionsFromLocalDb } from "~/modules/ai-chat-session"
import { settingSyncQueue } from "~/modules/settings/helper/sync-queue"
import { ElectronCloseEvent, ElectronShowEvent } from "~/providers/invalidate-query-provider"
@ -55,6 +56,7 @@ export const initializeApp = async () => {
initializeDayjs()
registerHistoryStack()
hydrateSessionsFromLocalDb()
// Set Environment
document.documentElement.dataset.buildType = ELECTRON_BUILD ? "electron" : "web"

View File

@ -3,12 +3,9 @@ import { userActions } from "@follow/store/user/store"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import { FollowClient } from "@follow-app/client-sdk"
import PKG from "@pkg"
import { createElement } from "react"
import { toast } from "sonner"
import { NetworkStatus, setApiStatus } from "~/atoms/network"
import { setLoginModalShow } from "~/atoms/user"
import { NeedActivationToast } from "~/modules/activation/NeedActivationToast"
import { getClientId, getSessionId } from "./client-session"
@ -78,25 +75,6 @@ followClient.addResponseInterceptor(async ({ response }) => {
if (response.status === 400 && json.code === 1003) {
router.navigate("/invitation")
}
if (json.code.toString().startsWith("11")) {
setTimeout(() => {
const toastId = toast.error(
createElement(NeedActivationToast, {
dimiss: () => {
toast.dismiss(toastId)
},
}),
{
closeButton: true,
duration: 10e4,
classNames: {
content: tw`w-full`,
},
},
)
}, 500)
}
} catch {
// ignore
}

View File

@ -1,16 +1,11 @@
import { DEV } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { userActions } from "@follow/store/user/store"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
import { ofetch } from "ofetch"
import { createElement } from "react"
import { toast } from "sonner"
import { NetworkStatus, setApiStatus } from "~/atoms/network"
import { setLoginModalShow } from "~/atoms/user"
import { NeedActivationToast } from "~/modules/activation/NeedActivationToast"
import { DebugRegistry } from "~/modules/debug/registry"
import { getClientId, getSessionId } from "./client-session"
@ -57,48 +52,8 @@ export const apiFetch = ofetch.create({
if (context.response.status === 400 && json.code === 1003) {
router.navigate("/invitation")
}
if (json.code.toString().startsWith("11")) {
setTimeout(() => {
const toastId = toast.error(
createElement(NeedActivationToast, {
dimiss: () => {
toast.dismiss(toastId)
},
}),
{
closeButton: true,
duration: 10e4,
classNames: {
content: tw`w-full`,
},
},
)
}, 500)
}
} catch {
// ignore
}
},
})
if (DEV) {
DebugRegistry.add("Activation Toast", () => {
setTimeout(() => {
const toastId = toast.error(
createElement(NeedActivationToast, {
dimiss: () => {
toast.dismiss(toastId)
},
}),
{
closeButton: true,
duration: 10e4,
classNames: {
content: tw`w-full`,
},
},
)
}, 500)
})
}

View File

@ -1,11 +1,13 @@
import { DEV } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
import { FollowAPIError, isNeedUpgradeError } from "@follow-app/client-sdk"
import { t } from "i18next"
import { FetchError } from "ofetch"
import { createElement } from "react"
import type { ExternalToast } from "sonner"
import { toast } from "sonner"
import { getIsPaymentEnabled } from "~/atoms/server-configs"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { DebugRegistry } from "~/modules/debug/registry"
@ -32,6 +34,20 @@ export const getFetchErrorInfo = (
}
}
if (error instanceof FollowAPIError && error.code) {
const code = Number(error.code)
try {
const i18nKey = `errors:${code}` as any
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
return {
message: i18nMessage,
code,
}
} catch {
return { message: error.message }
}
}
return { message: error.message }
}
@ -78,6 +94,17 @@ export const toastFetchError = (
}
}
if ("code" in error && error.code) {
code = Number(error.code)
try {
const tValue = t(`errors:${code}` as any)
const i18nMessage = tValue === code?.toString() ? error.message : tValue
message = i18nMessage
} catch {
message = error.message
}
}
// 2fa errors are handled by the form
if (code === 4007 || code === 4008) {
return
@ -94,9 +121,24 @@ export const toastFetchError = (
}
if (!_reason) {
const title = _title || message
toastOptions.description = _title ? message : undefined
return toast.error(title, toastOptions)
const title = _title || message || "Unknown error occurred"
toastOptions.description = _title ? message : ""
const isPaymentEnabled = getIsPaymentEnabled()
const needUpgradeError = code && isPaymentEnabled ? isNeedUpgradeError(code) : false
if (needUpgradeError) {
toastOptions.description = "Please upgrade your plan."
}
return toast.error(title, {
...toastOptions,
action: needUpgradeError
? {
label: "Upgrade",
onClick: () => {
window.router.showSettings({ tab: "plan" })
},
}
: undefined,
})
} else {
return toast.error(message || _title, {
duration: 5000,

View File

@ -32,6 +32,7 @@ import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { getI18n } from "~/i18n"
import { copyToClipboard, readFromClipboard } from "~/lib/clipboard"
import { toastFetchError } from "~/lib/error-parser"
import { downloadJsonFile, selectJsonFile } from "~/lib/export"
import { RuleCard } from "~/modules/action/rule-card"
import {
@ -421,7 +422,7 @@ const ActionButtonGroup = ({ onCreateRule }: { onCreateRule: () => void }) => {
toast(t("actions.saveSuccess"))
},
onError: (error) => {
toast.error(error)
toastFetchError(error)
},
})

View File

@ -1,29 +0,0 @@
import { toastStyles } from "@follow/components/ui/toast/styles.js"
import { stopPropagation } from "@follow/utils/dom"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useSettingModal } from "../settings/modal/use-setting-modal-hack"
export const NeedActivationToast = (props: { dimiss: () => void }) => {
const settingModalPresent = useSettingModal()
const { t } = useTranslation()
return (
<div className="flex justify-between gap-3">
<div>{t("activation.plan.description")}</div>
<button
className={toastStyles.actionButton}
type="button"
onPointerDown={stopPropagation}
onClick={useCallback(() => {
settingModalPresent("plan")
props.dimiss()
}, [settingModalPresent, props])}
>
{t("activation.activate")}
</button>
</div>
)
}

View File

@ -1,2 +1,3 @@
export * from "./query"
export * from "./service"
export * from "./store"

View File

@ -1,10 +1,17 @@
import type { AIChatMessage, AIChatSession, ListSessionsQuery } from "@follow-app/client-sdk"
import type { UIMessagePart } from "ai"
import { followApi } from "../../lib/api-client"
import { queryClient } from "../../lib/query-client"
import { AIPersistService } from "../ai-chat/services"
import type { BizUIMessage, BizUIMetadata } from "../ai-chat/store/types"
import type {
BizUIDataTypes,
BizUIMessage,
BizUIMetadata,
BizUITools,
} from "../ai-chat/store/types"
import { aiChatSessionKeys } from "./query"
import { aiChatSessionStoreActions } from "./store"
// Hard cap on pagination to prevent excessive API calls while keeping initial sync fast.
const MAX_PAGES = 10
@ -19,52 +26,51 @@ class AIChatSessionServiceStatic {
* List sessions from backend and ensure local DB has sessions and unseen messages.
* This does NOT mark sessions as seen on the server (non-destructive sync).
*
* Returns a small summary for observability.
* Returns the list of sessions.
*/
async syncSessionsAndMessagesFromServer(filters?: ListSessionsQuery): Promise<{
sessions: number
messages: number
failures: number
}> {
async syncSessionsAndMessagesFromServer(filters?: ListSessionsQuery): Promise<AIChatSession[]> {
if (this.sync) {
return { sessions: 0, messages: 0, failures: 0 }
return []
}
this.sync = true
const res = await followApi.aiChatSessions.list(filters)
const sessions = res.data
let totalMessages = 0
let failures = 0
aiChatSessionStoreActions.setSyncing(true)
aiChatSessionStoreActions.clearError()
for (const session of sessions) {
const dbSession = await AIPersistService.getChatSession(session.chatId)
const lastUpdatedAt = dbSession ? dbSession.updatedAt : new Date(0)
if (lastUpdatedAt >= new Date(session.updatedAt)) {
// If local session is already up-to-date, skip fetching messages
continue
}
try {
// Ensure session exists locally first
try {
const { data: sessions } = await followApi.aiChatSessions.list(filters)
const summary = { sessions: sessions.length, messages: 0, failures: 0 }
sessions.forEach(async (session) => {
await AIPersistService.ensureSession(session.chatId, {
title: session.title,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
// Use createdAt for updatedAt as we are syncing session instead of messages
updatedAt: new Date(session.createdAt),
})
// Fetch unseen messages (newer than lastSeenAt) without changing read state remotely
const unseenMessages = await this.fetchUnseenRemoteMessages(session.chatId, lastUpdatedAt)
if (unseenMessages.length === 0) continue
const normalized = unseenMessages.map(this.normalizeRemoteMessage)
await AIPersistService.upsertMessages(session.chatId, normalized)
totalMessages += normalized.length
} catch (err) {
// Keep going even if a single session fails
console.error("syncSessionsAndMessagesFromServer: failed for session", session.chatId, err)
failures += 1
}
})
await this.loadSessionsFromDb()
aiChatSessionStoreActions.setStats(summary)
aiChatSessionStoreActions.setLastSyncedAt(new Date())
return sessions
} catch (error) {
aiChatSessionStoreActions.setError(
error instanceof Error ? error.message : "Failed to sync chat sessions",
)
throw error
} finally {
aiChatSessionStoreActions.setSyncing(false)
this.sync = false
}
return { sessions: sessions.length, messages: totalMessages, failures }
}
async loadSessionsFromDb() {
const rows = await AIPersistService.getChatSessions()
aiChatSessionStoreActions.setSessions(rows)
return rows
}
/**
* Fetch messages for a chat session from the remote API and persist (upsert) them locally.
@ -72,10 +78,17 @@ class AIChatSessionServiceStatic {
*
* Defensive in extracting the message array because the SDK response shape may evolve.
*/
async fetchAndPersistMessages(session: AIChatSession): Promise<void> {
async fetchAndPersistMessages(
session: AIChatSession,
options?: {
force?: boolean
},
): Promise<void> {
const dbSession = await AIPersistService.getChatSession(session.chatId)
const lastUpdatedAt = dbSession ? dbSession.updatedAt : new Date(0)
if (lastUpdatedAt >= new Date(session.updatedAt)) {
const hasUpToDateSession = lastUpdatedAt >= new Date(session.updatedAt)
if (!options?.force && hasUpToDateSession) {
// If local session is already up-to-date, skip fetching messages
return
}
@ -88,14 +101,14 @@ class AIChatSessionServiceStatic {
await AIPersistService.ensureSession(session.chatId, {
title: session.title,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
// Use createdAt for updatedAt
// Because we are fetching session data instead of messages
updatedAt: new Date(session.createdAt),
})
await AIPersistService.upsertMessages(session.chatId, normalized)
await followApi.aiChatSessions.markSeen({
chatId: session.chatId,
lastSeenAt: new Date().toISOString(),
})
await this.loadSessionsFromDb()
aiChatSessionStoreActions.setLastSyncedAt(new Date())
// Invalidate related queries so UI updates outside of hook-based mutation flows
Promise.all([
@ -105,6 +118,23 @@ class AIChatSessionServiceStatic {
])
}
async syncSessionMessages(chatId: string) {
try {
const sessionResponse = await followApi.aiChatSessions.get({ chatId })
const session = sessionResponse.data
if (!session) {
return AIPersistService.loadUIMessages(chatId)
}
await this.fetchAndPersistMessages(session)
return AIPersistService.loadUIMessages(chatId)
} catch (error) {
console.error("syncSessionMessages: failed", error)
throw error
}
}
/**
* Fetch remote messages for a chat session that are newer than the provided lastSeenAt timestamp.
* Implements keyset pagination using `before` / `nextBefore` and stops when:
@ -147,9 +177,7 @@ class AIChatSessionServiceStatic {
return {
id: msg.id,
role: msg.role satisfies BizUIMessage["role"],
// Remove this comment once @follow-app/client-sdk updated
// @ts-expect-error TODO fix message part types
parts: msg.messageParts,
parts: msg.messageParts as UIMessagePart<BizUIDataTypes, BizUITools>[],
metadata,
createdAt: new Date(msg.createdAt),
}

View File

@ -0,0 +1,110 @@
import { createWithEqualityFn } from "zustand/traditional"
import { getI18n } from "~/i18n"
import type { ChatSession } from "../ai-chat/types/ChatSession"
import { AIChatSessionService } from "./service"
export interface AIChatSessionSyncStats {
sessions: number
messages: number
failures: number
}
export interface AIChatSessionViewModelState {
sessions: ChatSession[]
isLoading: boolean
isSyncing: boolean
stats: AIChatSessionSyncStats
lastSyncedAt?: Date
error?: string
}
const createInitialStats = (): AIChatSessionSyncStats => ({
sessions: 0,
messages: 0,
failures: 0,
})
export const useAIChatSessionStore = createWithEqualityFn<AIChatSessionViewModelState>(() => ({
sessions: [],
isLoading: false,
isSyncing: false,
stats: createInitialStats(),
lastSyncedAt: undefined,
error: undefined,
}))
const { setState, getState, subscribe } = useAIChatSessionStore
export const aiChatSessionStoreActions = {
setSessions: (sessions: ChatSession[]) =>
setState({
sessions,
}),
setLoading: (isLoading: boolean) =>
setState({
isLoading,
}),
setSyncing: (isSyncing: boolean) =>
setState({
isSyncing,
}),
setStats: (stats: AIChatSessionSyncStats) =>
setState({
stats: { ...stats },
}),
resetStats: () =>
setState({
stats: createInitialStats(),
}),
setLastSyncedAt: (lastSyncedAt?: Date) =>
setState({
lastSyncedAt,
}),
setError: (error?: string) =>
setState({
error,
}),
clearError: () =>
setState({
error: undefined,
}),
// syncing
fetchRemoteSessions: async () => {
try {
const { t } = getI18n()
const sessions = await AIChatSessionService.syncSessionsAndMessagesFromServer()
aiChatSessionStoreActions.setSessions(
sessions.map((session) => ({
chatId: session.chatId,
title: session.title || t("ai:common.new_chat"),
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
})),
)
} catch (error) {
console.error("fetchRemoteSessionsAndMessages: failed", error)
aiChatSessionStoreActions.setError(error instanceof Error ? error.message : "fetch_failed")
}
},
}
export const useAIChatSessionViewModel = useAIChatSessionStore
export const createEmptyAIChatSessionSyncStats = createInitialStats
export const getAIChatSessionState = getState
export const subscribeAIChatSessionStore = subscribe
export const hydrateSessionsFromLocalDb = async () => {
try {
await AIChatSessionService.loadSessionsFromDb()
} catch (error) {
console.error("hydrateSessionsFromLocalDb: failed", error)
aiChatSessionStoreActions.setError(error instanceof Error ? error.message : "hydrate_failed")
}
}
void hydrateSessionsFromLocalDb()

View File

@ -2,8 +2,7 @@ import { clamp, cn } from "@follow/utils"
import Spline from "@splinetool/react-spline"
import { useCallback, useRef } from "react"
// TODO: use folo cdn
const resolvedAIIconUrl = "https://prod.spline.design/n2hjp93nWReC-512/scene.splinecode"
const resolvedAIIconUrl = "https://cdn.follow.is/ai2.splinecode"
export const AISplineLoader = ({ className }: { className?: string }) => {
const containerRef = useRef<HTMLDivElement>(null)

View File

@ -110,7 +110,7 @@ export const AIChainOfThought: React.FC<AIChainOfThoughtProps> = React.memo(
contentClassName="pb-2 pt-1"
>
<div className="relative">
<div aria-hidden className="absolute inset-y-0 left-2 border-l border-fill" />
<div aria-hidden className="absolute inset-y-2 left-2 border-l border-fill" />
{groups.map((part, index) => {
const innerCollapseId = `${collapseId}-${index}`
if (isToolUIPart(part)) {
@ -125,13 +125,7 @@ export const AIChainOfThought: React.FC<AIChainOfThoughtProps> = React.memo(
return (
<div key={innerCollapseId} className="relative pb-3 pl-8 last:pb-0">
<div
aria-hidden
className={cn(
"absolute left-2 top-2 size-2 -translate-x-1/2 rounded-full border",
"border-fill bg-fill-vibrant",
)}
>
<div aria-hidden className={"absolute left-2 top-2 size-2 -translate-x-1/2"}>
<i className="i-mgc-brain-cute-re absolute top-1/2 -translate-x-1/4 -translate-y-1/2" />
</div>

View File

@ -1,10 +1,12 @@
import { cn } from "@follow/utils"
import { memo, useMemo } from "react"
import { Fragment, memo, useMemo } from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
@ -25,17 +27,6 @@ const providerIcons: Record<ProviderType, string> = {
deepseek: "i-mgc-deepseek-original",
}
const AIModelNameMapping = {
auto: "Auto",
"gpt-5": "GPT-5",
"gpt-5-mini": "GPT-5 mini",
"gpt-5-nano": "GPT-5 nano",
"gpt-4.1": "GPT-4.1",
"claude-sonnet-4.5": "Claude Sonnet 4.5",
"gemini-2.5-pro": "Gemini 2.5 Pro",
"deepseek-v3.2-exp-thinking": "DeepSeek V3.2 Exp Thinking",
}
const parseModelString = (modelString: string) => {
if (!modelString || !modelString.includes("/") || modelString === "auto") {
return { provider: "auto" as ProviderType, modelName: modelString || "Unknown" }
@ -52,7 +43,7 @@ const parseModelString = (modelString: string) => {
export const AIModelIndicator = memo(({ className, onModelChange }: AIModelIndicatorProps) => {
const { data, changeModel } = useAIModel()
const { defaultModel, availableModels = [], currentModel } = data || {}
const { defaultModel, availableModels = [], currentModel, availableModelsMenu = [] } = data || {}
const { provider, modelName } = useMemo(() => {
return parseModelString(currentModel || defaultModel || "")
@ -79,7 +70,7 @@ export const AIModelIndicator = memo(({ className, onModelChange }: AIModelIndic
>
<i className={cn("size-3", iconClass)} />
<span className="hidden max-w-20 truncate @md:inline">
{AIModelNameMapping[modelName] || modelName}
{availableModelsMenu.find((item) => item.value === currentModel)?.label || modelName}
</span>
{hasMultipleModels && <i className="i-mingcute-down-line size-3 opacity-60" />}
</div>
@ -93,27 +84,36 @@ export const AIModelIndicator = memo(({ className, onModelChange }: AIModelIndic
<DropdownMenu>
<DropdownMenuTrigger asChild>{modelContent}</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
{availableModels.map((model) => {
const { provider: itemProvider, modelName: itemModelName } = parseModelString(model)
const itemIconClass = providerIcons[itemProvider] || providerIcons.auto
const isSelected = model === (currentModel || defaultModel)
{availableModelsMenu.map(({ label, value }, index) => {
if (value) {
const { provider: itemProvider, modelName: itemModelName } = parseModelString(value)
const itemIconClass = providerIcons[itemProvider] || providerIcons.auto
const isSelected = value === (currentModel || defaultModel)
const handleModelSelect = () => {
changeModel(model)
onModelChange?.(model)
const handleModelSelect = () => {
changeModel(value)
onModelChange?.(value)
}
return (
<DropdownMenuItem
key={value}
className="gap-2"
onClick={handleModelSelect}
checked={isSelected}
>
<i className={cn("size-3", itemIconClass)} />
<span className="truncate">{label || itemModelName}</span>
</DropdownMenuItem>
)
} else {
return (
<Fragment key={label}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{label}</DropdownMenuLabel>
</Fragment>
)
}
return (
<DropdownMenuItem
key={model}
className="gap-2"
onClick={handleModelSelect}
checked={isSelected}
>
<i className={cn("size-3", itemIconClass)} />
<span className="truncate">{AIModelNameMapping[itemModelName] || itemModelName}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>

View File

@ -61,6 +61,7 @@ const ChatHeaderLayout = ({
const { isScrolledBeyondThreshold } = useAIRootState()
const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)
return (
<div
className={cn(
@ -81,7 +82,7 @@ const ChatHeaderLayout = ({
<div className="relative z-10 flex h-full items-center justify-between px-4">
<div className="mr-2 flex min-w-0 items-center">
{hasMessages && (
{(hasMessages || currentTitle) && (
<div onClick={() => settingModalPresent("ai")}>
<AISpline className="no-drag-region -mx-1 -mb-1 mr-1 size-9" />
</div>

View File

@ -38,7 +38,7 @@ export const ChatHistoryDropdown = ({
(open: boolean) => {
if (open) {
loadHistory()
AIPersistService.cleanupEmptySessions()
// AIPersistService.cleanupEmptySessions()
}
},
[loadHistory],
@ -95,7 +95,7 @@ export const ChatHistoryDropdown = ({
{triggerElement || defaultTrigger}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-96 w-72 overflow-y-auto">
{loading ? (
{loading && sessions.length === 0 ? (
<div className="flex items-center justify-center py-8">
<i className="i-mgc-loading-3-cute-re size-5 animate-spin text-text-secondary" />
</div>
@ -107,17 +107,18 @@ export const ChatHistoryDropdown = ({
{sessions.map((session) => (
<DropdownMenuItem
key={session.chatId}
onClick={() => startTransition(() => chatActions.switchToChat(session.chatId))}
className="group flex h-12 cursor-pointer items-center justify-between rounded-md px-2 py-3"
onClick={() =>
startTransition(() => {
chatActions.setTimelineSummaryManualOverride(true)
chatActions.switchToChat(session.chatId)
})
}
className="group flex h-8 cursor-pointer items-center justify-between rounded-md px-2 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{session.title || t("common.new_chat")}
</p>
<p className="mt-0.5 text-xs text-text-secondary group-data-[highlighted]:text-text-secondary-dark">
<span>{session.messageCount}</span>
<span> {session.messageCount === 1 ? "message" : "messages"}</span>
</p>
</div>
<div className="relative flex min-w-0 items-center">
<span className="ml-2 shrink-0 cursor-help text-xs text-text-secondary group-data-[highlighted]:text-text-secondary-dark">

View File

@ -152,7 +152,7 @@ export const ChatInput = memo(
)
return (
<div className={cn(chatInputVariants({ variant }))}>
<div data-testid="chat-input" className={cn(chatInputVariants({ variant }))}>
{/* Input Area */}
<div className="relative z-10 flex items-end" onContextMenu={stopPropagation}>
<ScrollArea rootClassName="mr-14 flex-1 overflow-auto" viewportClassName="px-5 py-3.5">

View File

@ -59,7 +59,7 @@ import { WelcomeScreen } from "./WelcomeScreen"
const SCROLL_BOTTOM_THRESHOLD = 100
const draftMessages = new Map<string, EditorState>()
const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfaceProps) => {
const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const hasMessages = useHasMessages()
const status = useChatStatus()
const chatActions = useChatActions()
@ -117,7 +117,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
previousMinHeightRef.current = 0
}, [currentChatId])
const { isLoading: isLoadingHistory } = useLoadMessages(currentChatId || "", {
const { isLoading: isLoadingHistory, isSyncingRemote } = useLoadMessages(currentChatId || "", {
onLoad: () => {
nextFrame(() => {
const $scrollArea = scrollAreaRef
@ -333,6 +333,8 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
}, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef])
const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory
const shouldShowLoadingOverlay =
Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote)
const { handleScroll } = useAttachScrollBeyond()
@ -343,23 +345,26 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
const rateLimitExtraHeight = hasRateLimitError ? 40 : 0
return (
<div
className="flex size-full flex-col @container"
style={
visualOffsetY
? ({
transform: `translateY(${typeof visualOffsetY === "number" ? `${visualOffsetY}px` : visualOffsetY})`,
} as React.CSSProperties)
: undefined
}
>
<div className="flex size-full flex-col @container">
<GlobalFileDropZone className="flex size-full flex-col @container">
<div className="flex min-h-0 flex-1 flex-col" ref={scrollContainerParentRef}>
<AnimatePresence>
{!hasMessages && !isLoadingHistory ? (
{!hasMessages && !shouldShowLoadingOverlay ? (
<WelcomeScreen centerInputOnEmpty={centerInputOnEmpty} />
) : (
<>
{shouldShowLoadingOverlay ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex -translate-y-24 flex-col items-center space-y-2">
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-text" />
{isSyncingRemote && (
<p className="text-sm text-text-secondary">
Syncing messages from server...
</p>
)}
</div>
</div>
) : null}
<ScrollArea
onScroll={handleScroll}
flex
@ -378,26 +383,20 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
}}
viewportClassName={"pt-12"}
>
{isLoadingHistory ? (
<div className="flex min-h-96 items-center justify-center">
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-text" />
</div>
) : (
<div
className="mx-auto w-full max-w-4xl px-6 py-8"
style={{
minHeight: messageContainerMinHeight
? `${messageContainerMinHeight}px`
: undefined,
}}
>
<Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />
<div
className="mx-auto w-full max-w-4xl px-6 py-8"
style={{
minHeight: messageContainerMinHeight
? `${messageContainerMinHeight}px`
: undefined,
}}
>
<Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />
{(status === "submitted" || status === "streaming") && (
<AIChatWaitingIndicator />
)}
</div>
)}
{(status === "submitted" || status === "streaming") && (
<AIChatWaitingIndicator />
)}
</div>
</ScrollArea>
</>
)}
@ -424,6 +423,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
<div
ref={bottomPanelRef}
data-testid="chat-input-container"
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
hasMessages && "inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
@ -496,7 +496,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfa
interface ChatInterfaceProps {
centerInputOnEmpty?: boolean
visualOffsetY?: string | number
}
export const ChatInterface = (props: ChatInterfaceProps) => (
<ErrorBoundary fallback={AIErrorFallback}>

View File

@ -94,11 +94,10 @@ export const ChatMoreDropdown = ({
: "Switch to Fixed Panel"}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => settingModalPresent("ai")}>
<i className="i-mgc-settings-1-cute-re mr-2 size-4" />
<span>AI Settings</span>

View File

@ -2,6 +2,8 @@ import { cn } from "@follow/utils"
import { m } from "motion/react"
import * as React from "react"
import { useSettingModal } from "~/modules/settings/modal/useSettingModal"
import { parseAIError } from "../../utils/error"
interface RateLimitNoticeProps {
@ -16,6 +18,7 @@ interface RateLimitNoticeProps {
export const RateLimitNotice: React.FC<RateLimitNoticeProps> = ({ error, className }) => {
const parsedError = React.useMemo(() => parseAIError(error), [error])
const { isRateLimitError, errorData } = parsedError
const settingModalPresent = useSettingModal()
// Only render for rate limit errors
if (!isRateLimitError || !errorData) {
@ -57,7 +60,7 @@ export const RateLimitNotice: React.FC<RateLimitNoticeProps> = ({ error, classNa
parts.push(`${remainingTokens.toLocaleString()} tokens left`)
}
} else {
parts.push("AI usage limit reached")
parts.push("Upgrade plan to get more AI credits.")
}
// Reset time
@ -79,9 +82,10 @@ export const RateLimitNotice: React.FC<RateLimitNoticeProps> = ({ error, classNa
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={cn("mb-3", className)}
onClick={() => settingModalPresent("plan")}
>
<div className="flex items-center gap-2 rounded-lg border border-border bg-fill/50 px-3 py-2 backdrop-blur-sm">
<i className="i-mgc-information-cute-re size-4 flex-shrink-0 text-text-tertiary" />
<div className="flex items-center gap-2 rounded-lg border border-border bg-material-ultra-thick px-3 py-2 backdrop-blur-background">
<i className="i-mgc-power size-4 flex-shrink-0 text-text" />
<span className="min-w-0 flex-1 truncate text-xs text-text-secondary">
{buildMessage()}
</span>

View File

@ -21,6 +21,7 @@ import { AIChatSessionService } from "~/modules/ai-chat-session"
import {
useAIChatSessionListQuery,
useDeleteAIChatSessionMutation,
useMarkChatSessionSeenMutation,
} from "~/modules/ai-chat-session/query"
import { AITaskModal, useCanCreateNewAITask } from "~/modules/ai-task"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
@ -40,20 +41,32 @@ interface SessionItemProps {
isLoading?: boolean
}
// Helper to determine if a session has unread messages
const isSessionUnread = (session: AIChatSession): boolean => {
const isTaskSession = (session: AIChatSession): boolean => {
if (!session.lastSeenAt || !session.updatedAt) return false
return new Date(session.updatedAt) > new Date(session.lastSeenAt)
if (!session.chatId.startsWith("ai-task")) return false
return true
}
// Helper to determine if a session has unread messages
const isUnreadSession = (session: AIChatSession): boolean => {
return new Date(session.updatedAt) > new Date(session.lastSeenAt)
}
const SessionItem = ({ session, onClick, onDelete, isLoading }: SessionItemProps) => {
const hasUnreadMessages = isUnreadSession(session)
return (
<DropdownMenuItem
onClick={onClick}
className={`group relative ${onClick ? "cursor-pointer" : "cursor-default"}`}
>
<div className="flex min-w-0 flex-1 justify-between">
<div className="ml-1 flex min-w-0 flex-1 justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{hasUnreadMessages && (
<span
className="absolute left-0 block size-2 shrink-0 rounded-full bg-accent"
aria-label="Unread"
role="status"
/>
)}
<p className="mb-0.5 truncate font-medium">{session.title || "Untitled Chat"}</p>
</div>
<div className="relative flex min-w-0 items-center">
@ -99,15 +112,15 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
const { t } = useTranslation("ai")
const [loadingChatId, setLoadingChatId] = useState<string | null>(null)
const showSettings = useSettingModal()
// Only keep unread sessions for display
const unreadSessions = useMemo(
() => (sessions || []).filter((s) => isSessionUnread(s)),
[sessions],
const markChatSessionSeenMutation = useMarkChatSessionSeenMutation()
// Only keep task sessions for display
const taskSessions = useMemo(() => (sessions || []).filter((s) => isTaskSession(s)), [sessions])
const hasTaskSessions = taskSessions.length > 0
const hasUnreadSessions = useMemo(
() => taskSessions.some((s) => isUnreadSession(s)),
[taskSessions],
)
const hasUnreadSessions = unreadSessions.length > 0
const { present } = useModalStack()
const canCreateNewTask = useCanCreateNewAITask()
const handleScheduleActionClick = () => {
@ -127,7 +140,10 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
const handleSessionSelect = useCallback(
async (session: AIChatSession) => {
if (session.chatId === currentChatId) return
if (session.chatId === currentChatId) {
console.warn("Session already active, no action taken")
return
}
try {
await AIChatSessionService.fetchAndPersistMessages(session)
} catch (e) {
@ -138,8 +154,14 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
chatActions.setTimelineSummaryManualOverride(true)
}
chatActions.switchToChat(session.chatId)
if (isUnreadSession(session)) {
markChatSessionSeenMutation.mutate({
chatId: session.chatId,
lastSeenAt: new Date().toISOString(),
})
}
},
[chatActions, currentChatId],
[chatActions, currentChatId, markChatSessionSeenMutation, shouldDisableTimelineSummary],
)
const handleDeleteSession = useCallback(
@ -211,7 +233,7 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
) : (
<DropdownMenuTrigger className="relative">
{triggerElement || defaultTrigger}
{hasUnreadSessions && triggerElement && (
{hasTaskSessions && triggerElement && (
<span
className="absolute right-1 top-1 block size-2 rounded-full bg-accent shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]"
aria-label="Unread task reports"
@ -221,8 +243,8 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
)}
<DropdownMenuContent align="end" className="w-80">
{unreadSessions.length > 0 ? (
unreadSessions.map((session) => (
{taskSessions.length > 0 ? (
taskSessions.map((session) => (
<SessionItem
key={session.chatId}
session={session}

View File

@ -62,15 +62,17 @@ const DefaultWelcomeHeader = ({ description }: { description: string }) => (
animate={{ opacity: 1, y: 0 }}
className="space-y-6 text-center"
>
<div className="center">
<AISpline />
</div>
<div className="flex flex-col gap-2">
<h1 className="flex items-center justify-center gap-2 text-2xl font-semibold text-text">
<Folo className="size-11" /> AI
</h1>
<div data-testid="welcome-screen-header">
<div className="center">
<AISpline />
</div>
<div className="flex flex-col gap-2">
<h1 className="flex items-center justify-center gap-2 text-2xl font-semibold text-text">
<Folo className="size-11" /> AI
</h1>
<p className="text-balance text-sm text-text-secondary">{description}</p>
<p className="text-balance text-sm text-text-secondary">{description}</p>
</div>
</div>
</m.div>
)

View File

@ -44,7 +44,7 @@ export const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(
const parts = [] as (ChainReasoningPart[] | TextUIPart | ToolUIPart<BizUITools>)[]
const shouldBypass = (name: string) =>
name.startsWith("tool-") || name.startsWith("tool-save_user_memory")
name.startsWith("tool-save_user_memory") || name.startsWith("tool-display")
let chainReasoningParts: ChainReasoningPart[] | null = null
for (const part of message.parts) {

View File

@ -41,7 +41,7 @@ export const TokenUsagePill: React.FC<TokenUsagePillProps> = ({ metadata, childr
</div>
</div>
<div className="space-y-2 text-xs">
<div className="font-medium text-text">Token Usage</div>
<div className="font-medium text-text">AI Credits Usage</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{metadata.totalTokens != null && (
<div className="flex justify-between gap-2">

View File

@ -4,6 +4,7 @@ import type { ToolUIPart } from "ai"
import { getToolName } from "ai"
import clsx from "clsx"
import * as React from "react"
import { titleCase } from "title-case"
interface ToolInvocationComponentProps {
part: ToolUIPart
@ -13,7 +14,8 @@ interface ToolInvocationComponentProps {
export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = React.memo(
({ part, variant }) => {
const toolName = getToolName(part)
const toolName = titleCase(getToolName(part).replaceAll("_", " "))
const hasError = "errorText" in part && part.errorText
const hasResult = "output" in part && part.output
const hasArgs = "input" in part && part.input
@ -23,11 +25,22 @@ export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = R
// Generate a unique value for this accordion item
const accordionValue = `tool-${"toolCallId" in part ? part.toolCallId : Math.random()}`
const result = React.useMemo(() => {
if (hasResult) {
const string = JSON.stringify(part.output, null, 2)
if (string.length > 1000) {
return `${string.slice(0, 1000)}\n...`
} else {
return string
}
}
}, [hasResult, part])
return (
<div className={clsx("relative pl-8 last:pb-0", variant === "tight" ? "pb-0" : "pb-3")}>
<div
aria-hidden
className={`absolute left-2 top-2 size-2 -translate-x-1/2 rounded-full border border-fill bg-fill-vibrant ${hasError ? "text-red" : ""}`}
className={`absolute left-2 top-2 size-2 -translate-x-1/2 ${hasError ? "text-red" : ""}`}
>
<i className={`i-mgc-tool-cute-re absolute top-1/2 -translate-x-1/4 -translate-y-1/2`} />
</div>
@ -72,7 +85,7 @@ export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = R
<div className="mb-1 font-medium text-text-secondary">Result:</div>
<JsonHighlighter
className="overflow-x-auto rounded bg-fill-secondary p-2 text-[11px] text-text-tertiary"
json={JSON.stringify(part.output, null, 2)}
json={result!}
/>
</div>
) : null}

View File

@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
import { useEntryIsInReadabilitySuccess } from "~/atoms/readability"
import { useActionLanguage } from "~/atoms/settings/general"
import { AISummaryCardBase } from "~/components/ui/ai-summary-card"
import { getFetchErrorInfo } from "~/lib/error-parser"
interface EntrySummaryCardProps {
entryId: string
@ -22,6 +23,8 @@ export const EntrySummaryCard: React.FC<EntrySummaryCardProps> = ({ entryId, cla
actionLanguage,
enabled: true,
})
const summaryErrorCode =
summary.error instanceof Error ? getFetchErrorInfo(summary.error).code : undefined
return (
<m.div
@ -35,6 +38,7 @@ export const EntrySummaryCard: React.FC<EntrySummaryCardProps> = ({ entryId, cla
isLoading={summary.isLoading}
className={className}
title={t("ai_summary")}
errorCode={summaryErrorCode}
/>
</m.div>
)

View File

@ -52,6 +52,7 @@ export const useAIModel = () => {
data: {
defaultModel: configuration?.defaultModel,
availableModels: configuration?.availableModels,
availableModelsMenu: configuration?.availableModelsMenu,
currentModel,
},
isLoading,

View File

@ -21,7 +21,7 @@ import { BlockSliceAction } from "../store/slices/block.slice"
import type { AIChatContextBlock, SendingUIMessage } from "../store/types"
import { isTimelineSummaryAutoContext } from "./useTimelineSummaryAutoContext"
const ONE_HOUR = 60 * 60 * 1000
const INTERVAL = 5 * 60 * 60 * 1000
const buildSummaryMessage = (
editor: LexicalEditor,
@ -100,7 +100,7 @@ export const useAutoTimelineSummaryShortcut = () => {
})
const previousContextKeyRef = useRef<string | null>(null)
const isAllTimeline = isTimelineSummaryAutoContext({ view, entryId })
const isAllTimeline = isTimelineSummaryAutoContext({ view, feedId, entryId })
const defaultShortcut = useMemo(() => {
const shortcuts = aiSettings.shortcuts ?? []
@ -227,7 +227,7 @@ export const useAutoTimelineSummaryShortcut = () => {
if (existingSession) {
const lastUpdatedAt = existingSession.updatedAt?.getTime?.() ?? existingSession.updatedAt
if (typeof lastUpdatedAt === "number" && now - lastUpdatedAt < ONE_HOUR) {
if (typeof lastUpdatedAt === "number" && now - lastUpdatedAt < INTERVAL) {
if (currentChatId !== existingSession.chatId) {
await chatActions.switchToChat(existingSession.chatId)
}

View File

@ -1,41 +1,40 @@
import { useCallback, useState } from "react"
import { useMemo } from "react"
import { useEventCallback } from "usehooks-ts"
import { getI18n } from "~/i18n"
import { AIPersistService } from "~/modules/ai-chat/services/index"
import { AIChatSessionService } from "~/modules/ai-chat-session/service"
import type { ChatSession } from "../types/ChatSession"
import { aiChatSessionStoreActions, useAIChatSessionStore } from "~/modules/ai-chat-session/store"
export const useChatHistory = () => {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [loading, setLoading] = useState(false)
const state = useAIChatSessionStore()
const loadHistory = useCallback(async () => {
setLoading(true)
const { sessions } = state
const loading = state.isLoading || state.isSyncing
const loadHistory = useEventCallback(async () => {
if (loading) return
aiChatSessionStoreActions.setLoading(true)
aiChatSessionStoreActions.clearError()
const { t } = getI18n()
try {
await AIChatSessionService.syncSessionsAndMessagesFromServer()
const result = await AIPersistService.getChatSessions()
const sessions: ChatSession[] = result.map((row) => ({
chatId: row.chatId,
title: row.title || t("ai:common.new_chat"),
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
messageCount: row.messageCount,
}))
setSessions(sessions)
await aiChatSessionStoreActions.fetchRemoteSessions()
} catch (error) {
console.error("Failed to load chat history:", error)
aiChatSessionStoreActions.setError(error instanceof Error ? error.message : "Unknown error")
} finally {
setLoading(false)
aiChatSessionStoreActions.setSyncing(false)
aiChatSessionStoreActions.setLoading(false)
}
}, [])
})
return {
sessions,
loading,
loadHistory,
}
return useMemo(
() => ({
sessions,
loading,
loadHistory,
stats: state.stats,
lastSyncedAt: state.lastSyncedAt,
error: state.error,
}),
[sessions, loading, loadHistory, state.stats, state.lastSyncedAt, state.error],
)
}

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { AIPersistService } from "../services"
import { AIChatSessionService } from "~/modules/ai-chat-session/service"
import { useChatActions } from "../store/hooks"
import type { BizUIMessage, BizUIMetadata } from "../store/types"
import type { BizUIMessage } from "../store/types"
export const useLoadMessages = (
chatId: string,
@ -12,6 +13,7 @@ export const useLoadMessages = (
const chatActions = useChatActions()
const [isLoading, setIsLoading] = useState(true)
const [isSyncingRemote, setIsSyncingRemote] = useState(false)
const onLoadEventCallback = useEventCallback((messages: BizUIMessage[]) => {
options?.onLoad?.(messages)
@ -20,25 +22,15 @@ export const useLoadMessages = (
useEffect(() => {
let mounted = true
setIsLoading(true)
AIPersistService.loadMessages(chatId)
.then((messages) => {
if (mounted) {
const messagesToSet: BizUIMessage[] = messages.map((message) => ({
id: message.id,
parts: message.messageParts as any[],
role: message.role,
metadata: message.metadata as BizUIMetadata,
createdAt: message.createdAt,
}))
const existingMessages = chatActions.getMessages()
if (messagesToSet.length === 0 && existingMessages.length > 0) {
onLoadEventCallback(existingMessages)
} else {
chatActions.setMessages(messagesToSet)
onLoadEventCallback(messagesToSet)
}
setIsSyncingRemote(false)
AIChatSessionService.syncSessionMessages(chatId)
.then(async (messages) => {
if (!mounted) {
return []
}
chatActions.setMessages(messages)
onLoadEventCallback(messages)
return messages
})
.catch((error) => {
console.error(error)
@ -46,11 +38,12 @@ export const useLoadMessages = (
.finally(() => {
if (mounted) {
setIsLoading(false)
setIsSyncingRemote(false)
}
})
return () => {
mounted = false
}
}, [chatId, onLoadEventCallback, chatActions])
return { isLoading }
return { isLoading, isSyncingRemote }
}

View File

@ -1,14 +1,24 @@
import { FeedViewType } from "@follow/constants"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants"
import type { BizRouteParams } from "~/hooks/biz/useRouteParams"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export type TimelineSummaryContextParams = Pick<BizRouteParams, "view" | "entryId">
export type TimelineSummaryContextParams = Pick<BizRouteParams, "view" | "entryId" | "feedId">
export const isTimelineSummaryAutoContext = ({ view, entryId }: TimelineSummaryContextParams) => {
return view === FeedViewType.All && (!entryId || entryId === ROUTE_ENTRY_PENDING)
export const isTimelineSummaryAutoContext = ({
view,
feedId,
entryId,
}: TimelineSummaryContextParams) => {
return (
view === FeedViewType.All &&
(!entryId || entryId === ROUTE_ENTRY_PENDING) &&
(!feedId || feedId === ROUTE_FEED_PENDING)
)
}
export const useTimelineSummaryAutoContext = () =>
useRouteParamsSelector(({ view, entryId }) => isTimelineSummaryAutoContext({ view, entryId }))
useRouteParamsSelector(({ view, feedId, entryId }) =>
isTimelineSummaryAutoContext({ view, feedId, entryId }),
)

View File

@ -1,8 +1,7 @@
import type { AsyncDb } from "@follow/database/db"
import { db } from "@follow/database/db"
import type { AiChatMessagesModel } from "@follow/database/schemas/index"
import { aiChatMessagesTable, aiChatTable } from "@follow/database/schemas/index"
import { asc, count, eq, inArray, sql } from "drizzle-orm"
import { asc, eq, inArray, sql } from "drizzle-orm"
import { getI18n } from "~/i18n"
import { followClient } from "~/lib/api-client"
@ -130,6 +129,10 @@ class AIPersistServiceStatic {
const cleanParts = [] as typeof message.parts
for (const part of message.parts) {
// Skip streaming messages
if ("state" in part && part.state === "streaming") {
return acc
}
if (isDataBlockPart(part)) {
const nextPart = structuredClone(part)
for (const block of nextPart.data) {
@ -169,8 +172,24 @@ class AIPersistServiceStatic {
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
status: sql`excluded.status`,
createdAt: sql`excluded.created_at`,
},
})
const date = results.reduce<Date | null>((latest, msg) => {
const date = msg.createdAt ? new Date(msg.createdAt) : null
if (date === null) {
return latest
}
if (!latest || date > latest) {
return date
}
return latest
}, null)
if (date) {
// Update session time after successfully saving messages
await AIPersistService.updateSessionTime(chatId, date)
}
}
/**
@ -371,18 +390,6 @@ class AIPersistServiceStatic {
return []
}
const chatIds = chats.map((chat) => chat.chatId)
const messageCounts = await (db as AsyncDb)
.select({
chatId: aiChatMessagesTable.chatId,
messageCount: count(aiChatMessagesTable.id),
})
.from(aiChatMessagesTable)
.where(inArray(aiChatMessagesTable.chatId, chatIds))
.groupBy(aiChatMessagesTable.chatId)
const messageCountMap = new Map(messageCounts.map((item) => [item.chatId, item.messageCount]))
const normalizedChats = await Promise.all(
chats.map(async (chat) => {
const resolvedTitle = this.resolveSessionTitle(chat.chatId, chat.title, {
@ -401,15 +408,12 @@ class AIPersistServiceStatic {
}),
)
return normalizedChats
.map((chat) => ({
chatId: chat.chatId,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
messageCount: messageCountMap.get(chat.chatId) || 0,
}))
.filter((chat) => chat.messageCount > 0)
return normalizedChats.map((chat) => ({
chatId: chat.chatId,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
}))
}
async deleteSession(chatId: string) {
@ -432,11 +436,11 @@ class AIPersistServiceStatic {
.where(eq(aiChatTable.chatId, chatId))
}
async updateSessionTime(chatId: string) {
async updateSessionTime(chatId: string, date: Date = new Date()) {
await db
.update(aiChatTable)
.set({
updatedAt: new Date(Date.now()),
updatedAt: date,
})
.where(eq(aiChatTable.chatId, chatId))
}
@ -459,6 +463,11 @@ class AIPersistServiceStatic {
// Clear deleted sessions from cache
chatIdsToDelete.forEach((chatId) => this.clearSessionCache(chatId))
for (const chatId of chatIdsToDelete) {
await followClient.api.aiChatSessions.delete({ chatId }).catch((error) => {
console.error("Failed to delete remote chat sessions:", error)
})
}
}
}
}

View File

@ -36,6 +36,7 @@ export class ChatSliceActions {
private params: Parameters<StateCreator<ChatSlice, [], [], ChatSlice>>,
private chatInstance: ZustandChat,
) {
this.chatInstance.resumeStream()
return autoBindThis(this)
}
@ -280,6 +281,7 @@ export class ChatSliceActions {
chatInstance: newChatInstance,
}))
newChatInstance.resumeStream()
// Update the reference
this.chatInstance = newChatInstance
} catch (error) {

View File

@ -147,14 +147,12 @@ export class ZustandChatState implements ChatState<BizUIMessage> {
await AIPersistService.ensureSession(this.chatId)
// Save messages using incremental updates
await AIPersistService.replaceAllMessages(this.chatId, this.#messages)
// Update session time after successfully saving messages
await AIPersistService.updateSessionTime(this.chatId)
} catch (error) {
console.error("Failed to persist messages:", error)
}
},
100,
{ leading: true, trailing: true },
{ leading: false, trailing: true },
)
#fillMessageCreatedAt(message: SendingUIMessage | BizUIMessage): BizUIMessage {

View File

@ -5,7 +5,6 @@ export interface ChatSession {
title?: string
createdAt: Date
updatedAt: Date
messageCount: number
}
export type RichTextPart = {

View File

@ -42,6 +42,7 @@ export const useAIMemoryListQuery = (params: MemoryListQuery = {}) => {
const response = await followApi.ai.memory.list({
...params,
limit,
// @ts-expect-error TODO: fix type
before: pageParam ?? undefined,
})

View File

@ -8,6 +8,7 @@ import { toast } from "sonner"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { followApi } from "~/lib/api-client"
import { ChatSliceActions } from "~/modules/ai-chat/store/chat-core/chat-actions"
import { AIChatSessionService } from "~/modules/ai-chat-session"
import { useAIChatSessionListQuery } from "~/modules/ai-chat-session/query"
@ -122,7 +123,7 @@ export const TaskItem = memo(({ task }: { task: AITask }) => {
const statusColorClass = getStatusColor(status)
const taskSession = useMemo(
() => sessions?.find((s) => s.chatId === task.id),
() => sessions?.find((s) => s.chatId.startsWith(`ai-task-${task.id}`)),
[sessions, task.id],
)
@ -175,10 +176,23 @@ export const TaskItem = memo(({ task }: { task: AITask }) => {
onClick: async () => {
const loadingId = toast.loading(t("tasks.toast.test_start"))
try {
await testRunMutation.mutateAsync({ id: task.id })
const testRunResult = await testRunMutation.mutateAsync({ id: task.id })
if (testRunResult.data.error) {
throw new Error(testRunResult.data.error)
}
toast.success(t("tasks.toast.test_success"), {
id: loadingId,
})
// @ts-ignore -- Remove once SDK types are updated
const { sessionId } = testRunResult.data
if (sessionId) {
const session = (
await followApi.aiChatSessions.get({
chatId: sessionId,
})
).data
await AIChatSessionService.fetchAndPersistMessages(session)
}
} catch (error) {
console.error("Failed to run test:", error)
toast.error(t("tasks.toast.test_failed"), {

View File

@ -69,6 +69,7 @@ const AIEnhancedTimelineLayoutImpl = () => {
const layoutContainerRef = useRef<HTMLDivElement>(null)
const hasMountedRef = useRef(false)
// Delay the details column until the timeline width animation finishes
const [shouldRenderDetailsColumn, setShouldRenderDetailsColumn] = useState(showEntryDetailsColumn)
const feedColumnWidth = useUISettingKey("feedColWidth")
@ -158,7 +159,7 @@ const AIEnhancedTimelineLayoutImpl = () => {
setAiPanelWidth(width)
// Trigger layout update
window.dispatchEvent(new Event("resize"))
}, [resolvePreferredWidth])
}, [resolvePreferredWidth, setAiPanelWidth])
const handleTimelineTransitionEnd = useCallback(
(event: TransitionEvent<HTMLDivElement>) => {
@ -174,15 +175,18 @@ const AIEnhancedTimelineLayoutImpl = () => {
[showEntryDetailsColumn],
)
useEffect(() => {
if (!showEntryDetailsColumn) {
setShouldRenderDetailsColumn(false)
}
}, [showEntryDetailsColumn])
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true
setShouldRenderDetailsColumn(showEntryDetailsColumn)
return
}
// Defer rendering until the width transition signals completion
setShouldRenderDetailsColumn(false)
}, [showEntryDetailsColumn])
return (

View File

@ -1,13 +1,13 @@
import { getReadonlyRoute } from "@follow/components/atoms/route.js"
import { useGlobalFocusableHasScope } from "@follow/components/common/Focusable/hooks.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { LinearBlur } from "@follow/components/ui/progressive-blur/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Routes } from "@follow/constants"
import { ELECTRON_BUILD } from "@follow/shared/constants"
import { springScrollTo } from "@follow/utils/scroller"
import { clsx, cn, getOS } from "@follow/utils/utils"
import { m } from "framer-motion"
import { LinearBlur } from "progressive-blur"
import { isValidElement, useCallback, useEffect, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
@ -161,7 +161,7 @@ function SubviewLayoutInner() {
{/* Enhanced Header with smooth transitions */}
<div
className={cn(
"absolute inset-x-0 top-0 z-10 transition-all duration-300 ease-out",
"absolute inset-x-0 top-0 z-10 overflow-hidden transition-all duration-300 ease-out",
isHeaderElevated && isElectronWindows && "-top-5",
)}
>

View File

@ -399,10 +399,6 @@ export const useRegisterEntryCommands = () => {
icon: <i className="i-mgc-ai-cute-re" />,
category,
run: () => {
if (role === UserRole.Free || role === UserRole.Trial) {
presentActivationModal()
return
}
toggleShowAISummaryOnce()
},
},

View File

@ -1,48 +1,17 @@
import { Button } from "@follow/components/ui/button/index.js"
import { UserRole } from "@follow/constants"
import { useUserRole } from "@follow/store/user/hooks"
import { repository } from "@pkg"
import { useTranslation } from "react-i18next"
import { useEventCallback } from "usehooks-ts"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { CustomSafeError } from "~/errors/CustomSafeError"
import { useActivationModal } from "../activation"
import { InboxTable } from "./Inbox"
import { InboxForm } from "./InboxForm"
const useCanCreateMoreInboxAndNotify = () => {
const role = useUserRole()
const presentActivationModal = useActivationModal()
return useEventCallback(() => {
if (role === UserRole.Free || role === UserRole.Trial) {
const can = false
if (!can) {
presentActivationModal()
throw new CustomSafeError(`Trial user cannot create more inboxes`, true)
}
return can
} else {
// const can = currentInboxCount < MAX_INBOX_COUNT
// if (!can) {
// // TODO
// }
// return can
return true
}
})
}
export function DiscoverInboxList() {
const { t } = useTranslation()
const { present } = useModalStack()
const preCheck = useCanCreateMoreInboxAndNotify()
return (
<div className="mx-auto w-full max-w-[540px] rounded-lg border bg-material-ultra-thin p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm text-zinc-500">
@ -63,7 +32,6 @@ export function DiscoverInboxList() {
<Button
textClassName="flex items-center gap-2"
onClick={() =>
preCheck() &&
present({
title: t("sidebar.feed_actions.new_inbox"),
content: () => <InboxForm asWidget />,

View File

@ -13,12 +13,19 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import { FeedViewType } from "@follow/constants"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { FeedViewType, UserRole } from "@follow/constants"
import { useFeedByIdOrUrl } from "@follow/store/feed/hooks"
import type { FeedModel } from "@follow/store/feed/types"
import { useCategories, useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
import { subscriptionSyncService } from "@follow/store/subscription/store"
import { whoami } from "@follow/store/user/getters"
import { useUserRole } from "@follow/store/user/hooks"
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import type { FeedAnalyticsModel, ParsedEntry } from "@follow-app/client-sdk"
@ -30,11 +37,13 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import { useIsPaymentEnabled } from "~/atoms/server-configs"
import { Autocomplete } from "~/components/ui/auto-completion"
import { useCurrentModal, useIsInModal } from "~/components/ui/modal/stacked/hooks"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useI18n } from "~/hooks/common"
import { toastFetchError } from "~/lib/error-parser"
import { useSettingModal } from "~/modules/settings/modal/useSettingModal"
import { feed as feedQuery, useFeedQuery } from "~/queries/feed"
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"
@ -49,6 +58,35 @@ const formSchema = z.object({
})
export type FeedFormDataValuesType = z.infer<typeof formSchema>
export const PaidBadge = () => {
const { t } = useTranslation("settings")
const settingModalPresent = useSettingModal()
const isPaymentEnabled = useIsPaymentEnabled()
const handleClick = useCallback(
(e) => {
e.preventDefault()
settingModalPresent("plan")
},
[settingModalPresent],
)
if (!isPaymentEnabled) {
return null
}
return (
<Tooltip>
<TooltipTrigger asChild>
<i className="i-mgc-power block text-accent" onClick={handleClick} />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("control.paid_badge.plus_or_higher")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)
}
export const FeedForm: Component<{
url?: string
id?: string
@ -297,6 +335,10 @@ const FeedInnerForm = ({
form.setValue("title", feed.title || "")
}, [feed.title, form])
const role = useUserRole()
const isPaymentEnabled = useIsPaymentEnabled()
const disabledForRole = role === UserRole.Free && isPaymentEnabled
return (
<div className="flex flex-1 flex-col gap-y-4">
<FeedSummary isLoading={isLoading} feed={feed} analytics={analytics} showAnalytics />
@ -367,7 +409,10 @@ const FeedInnerForm = ({
<FormItem>
<div className="flex items-center justify-between">
<div>
<FormLabel>{t("feed_form.private_follow")}</FormLabel>
<FormLabel className="flex items-center gap-1">
<span>{t("feed_form.private_follow")}</span>
<PaidBadge />
</FormLabel>
<FormDescription>{t("feed_form.private_follow_description")}</FormDescription>
</div>
<FormControl>
@ -375,6 +420,7 @@ const FeedInnerForm = ({
className="shrink-0"
checked={field.value}
onCheckedChange={field.onChange}
disabled={disabledForRole}
/>
</FormControl>
</div>
@ -388,7 +434,10 @@ const FeedInnerForm = ({
<FormItem>
<div className="flex items-center justify-between">
<div>
<FormLabel>{t("feed_form.hide_from_timeline")}</FormLabel>
<FormLabel className="flex items-center gap-1">
<span>{t("feed_form.hide_from_timeline")}</span>
<PaidBadge />
</FormLabel>
<FormDescription>
{t("feed_form.hide_from_timeline_description")}
</FormDescription>
@ -398,6 +447,7 @@ const FeedInnerForm = ({
className="shrink-0"
checked={field.value}
onCheckedChange={field.onChange}
disabled={disabledForRole}
/>
</FormControl>
</div>

View File

@ -14,11 +14,13 @@ interface EntryItemProps {
entryId: string
view: FeedViewType
currentFeedTitle?: string
isFirstItem?: boolean
}
const EntryItemImpl = memo(function EntryItemImpl({
entryId,
view,
currentFeedTitle,
isFirstItem,
}: EntryItemProps) {
const enableTranslation = useGeneralSettingKey("translation")
const actionLanguage = useActionLanguage()
@ -37,7 +39,12 @@ const EntryItemImpl = memo(function EntryItemImpl({
const Item: EntryListItemFC = getItemComponentByView(view)
return (
<EntryItemWrapper itemClassName={Item.wrapperClassName} entryId={entryId} view={view}>
<EntryItemWrapper
itemClassName={Item.wrapperClassName}
entryId={entryId}
view={view}
isFirstItem={isFirstItem}
>
<Item entryId={entryId} translation={translation} currentFeedTitle={currentFeedTitle} />
</EntryItemWrapper>
)
@ -65,9 +72,16 @@ export const EntryVirtualListItem = ({
if (!hasEntry) return <div ref={ref} {...props} style={undefined} />
const isFirstItem = props["data-index"] === 0
return (
<div className="absolute left-0 top-0 w-full will-change-transform" ref={ref} {...props}>
<EntryItemImpl entryId={entryId} view={view} currentFeedTitle={currentFeedTitle} />
<EntryItemImpl
entryId={entryId}
view={view}
currentFeedTitle={currentFeedTitle}
isFirstItem={isFirstItem}
/>
</div>
)
}

View File

@ -32,10 +32,11 @@ export const EntryItemWrapper: FC<
{
entryId: string
view: FeedViewType
isFirstItem?: boolean
itemClassName?: string
style?: React.CSSProperties
} & PropsWithChildren
> = ({ entryId, view, children, itemClassName, style }) => {
> = ({ entryId, view, children, itemClassName, style, isFirstItem }) => {
const entry = useEntry(entryId, (state) => {
const { feedId, inboxHandle } = state
const { id, url } = state
@ -173,6 +174,7 @@ export const EntryItemWrapper: FC<
void openContextMenuAt(x, y)
}}
entryId={entryId}
isFirstItem={!!isFirstItem}
/>
)}
</AnimatePresence>
@ -184,9 +186,11 @@ export const EntryItemWrapper: FC<
const ActionBar = ({
entryId,
openContextMenu,
isFirstItem,
}: {
entryId: string
openContextMenu: () => void
isFirstItem: boolean
}) => {
const { view } = useRouteParams()
@ -196,6 +200,7 @@ const ActionBar = ({
<div
className={cn(
"absolute -right-2 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900",
isFirstItem && "-right-2 top-4",
view === FeedViewType.All && "right-1 top-1/2",
)}
onClick={(e) => {

View File

@ -92,7 +92,7 @@ export const EntryListHeader: FC<{
"flex h-top-header-with-border-b w-full flex-col pr-2.5 pt-2 @[700px]:pr-3 @[1024px]:pr-4",
!feedColumnShow && "macos:mt-4 macos:pt-margin-macos-traffic-light-y",
titleStyleBasedView[view],
isPreview && "px-2.5 @[700px]:px-3 @[1024px]:px-4",
isPreview && "h-top-header-in-preview-with-border-b px-2.5 @[700px]:px-3 @[1024px]:px-4",
view === FeedViewType.All &&
"border-b border-transparent data-[scrolled-beyond-threshold=true]:border-b-border",
)}

View File

@ -1,5 +1,4 @@
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { FeedViewType } from "@follow/constants"
@ -21,6 +20,7 @@ import { memo, useEffect, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { Focusable } from "~/components/common/Focusable"
import { m } from "~/components/common/Motion"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { HotkeyScope } from "~/constants"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
@ -156,26 +156,27 @@ const EntryContentImpl: Component<EntryContentProps> = ({
{isInHasTimelineView && (
<>
<div className="absolute inset-y-0 left-0 z-[9] flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100 group-hover:opacity-40">
<MotionButtonBase
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
<GlassButton
size="sm"
className="!-translate-y-12 !bg-material-opaque !opacity-100 hover:!bg-material-opaque"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
>
<i className="i-mgc-left-small-sharp size-16 text-text-secondary" />
</MotionButtonBase>
<i className="i-mgc-left-small-sharp size-6" />
</GlassButton>
</div>
<div className="absolute inset-y-0 right-0 z-[9] flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100 group-hover:opacity-40">
<MotionButtonBase
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
<GlassButton
size="sm"
className="!-translate-y-12 !bg-material-opaque !opacity-100 hover:!bg-material-opaque"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}
>
<i className="i-mgc-right-small-sharp size-16 text-text-secondary" />
</MotionButtonBase>
<i className="i-mgc-right-small-sharp size-6" />
</GlassButton>
</div>
</>
)}

View File

@ -12,6 +12,7 @@ import {
} from "~/atoms/settings/ai"
import { useActionLanguage } from "~/atoms/settings/general"
import { AISummaryCardBase } from "~/components/ui/ai-summary-card"
import { getFetchErrorInfo } from "~/lib/error-parser"
export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
@ -30,6 +31,8 @@ export function AISummary({ entryId }: { entryId: string }) {
target: isInReadabilitySuccess ? "readabilityContent" : "content",
enabled: showAISummary,
})
const summaryErrorCode =
summary.error instanceof Error ? getFetchErrorInfo(summary.error).code : undefined
// Show Ask AI button when:
// 1. Panel style is floating AND panel is not visible
@ -54,6 +57,7 @@ export function AISummary({ entryId }: { entryId: string }) {
title={t("entry_content.ai_summary")}
showAskAIButton={shouldShowAskAI}
onAskAI={handleAskAI}
errorCode={summaryErrorCode}
/>
)
}

View File

@ -17,7 +17,7 @@ interface EntryContentFallbackProps {
* 3. Error boundary for entry not found cases
*/
export const EntryContentFallback = memo(({ entryId, children }: EntryContentFallbackProps) => {
const { data: realEntry, isLoading: loadingRemoteEntry } = usePrefetchEntryDetail(entryId)
const { data: realEntry, isPending: loadingRemoteEntry } = usePrefetchEntryDetail(entryId)
if (!loadingRemoteEntry && !realEntry) {
// 404

View File

@ -26,7 +26,7 @@ function EntryHeaderImpl({ entryId, className, compact }: EntryHeaderProps) {
return (
<EntryHeaderRoot entryId={entryId} className={className} compact={compact}>
<nav
className="relative z-10 flex h-top-header w-full items-center justify-between gap-3 bg-background px-4 @container"
className="group/header relative z-10 flex h-top-header w-full items-center justify-between gap-3 bg-background px-4 @container"
data-at-top={isAtTop}
data-hide-in-print
ref={headerRef}

View File

@ -232,7 +232,7 @@ export function EntryHeaderBreadcrumb() {
const showEntryDetailsColumn = useShowEntryDetailsColumn()
if (showEntryDetailsColumn && meta?.entryTitle) {
return (
<EllipsisHorizontalTextWithTooltip className="min-w-0 truncate px-1.5 py-0.5 text-sm font-medium text-text">
<EllipsisHorizontalTextWithTooltip className="min-w-0 truncate px-1.5 py-0.5 text-lg font-bold leading-tight text-text opacity-0 transition-opacity duration-200 group-data-[at-top=false]/header:opacity-100">
{meta.entryTitle}
</EllipsisHorizontalTextWithTooltip>
)

View File

@ -190,16 +190,17 @@ export const ArticleAudioPlayer: React.FC<AudioPlayerProps> = ({ entryId, classN
{/* Progress Bar Container */}
<div className="flex-1">
<div
className="group relative h-2 w-full cursor-pointer overflow-hidden rounded-full bg-border"
className="group h-2 w-full cursor-pointer rounded-full bg-border"
onClick={handleProgressClick}
>
<div
className="h-full rounded-full bg-accent transition-all duration-200"
className="relative h-full rounded-full bg-accent transition-all duration-200"
style={{ width: `${progressPercent}%` }}
/>
{/* Hover indicator */}
<div className="absolute inset-y-0 right-0 w-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="size-full rounded-full bg-accent/50" />
>
{/* Hover indicator */}
<div className="absolute right-0 top-1/2 size-4 -translate-y-1/2 translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100">
<div className="size-full rounded-full bg-white" />
</div>
</div>
</div>
</div>

View File

@ -17,6 +17,7 @@ import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { getDefaultLanguage } from "~/lib/language"
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
@ -54,6 +55,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return { attachments, duration, firstMedia, url, media }
})
const lang = getDefaultLanguage()
const [miniIframeSrc, iframeSrc] = useMemo(
() => [
transformVideoUrl({
@ -61,11 +63,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
lang,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
lang,
}),
],
[entry?.attachments, entry?.url],

View File

@ -1,3 +1,4 @@
import { decode } from "@byjohann/toon"
import {
Card,
CardContent,
@ -42,14 +43,14 @@ export function FeedsSelectionList() {
function FeedSelectionOperationScreen() {
const chatMessages = useMessages()
const feedsToSelect: FeedToSelect[] = useMemo(
() =>
// find the last message that has the tool
chatMessages
.findLast((m) => m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds"))
?.parts?.findLast((p) => p.type === "tool-onboardingGetTrendingFeeds")?.output ?? [],
[chatMessages],
)
const feedsToSelect: FeedToSelect[] = useMemo(() => {
// find the last message that has the tool
const output = chatMessages
.findLast((m) => m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds"))
?.parts?.findLast((p) => p.type === "tool-onboardingGetTrendingFeeds")?.output
return typeof output === "string" ? (decode(output) as any[]) : []
}, [chatMessages])
const store = useStore()
const atomList = useAtomValue(feedSelectionAtomsAtom)

View File

@ -1,4 +1,4 @@
import { Button } from "@follow/components/ui/button/index.js"
import { styledButtonVariant } from "@follow/components/ui/button/variants.js"
import { authProvidersConfig } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
@ -32,9 +32,12 @@ function AuthProviderButton({ provider }: { provider: string }) {
if (!account && IN_ELECTRON) return null
return (
<Button
<button
type="button"
key={provider}
variant={"outline"}
className={styledButtonVariant({
variant: "outline",
})}
onClick={() => {
if (!account) {
linkSocial({
@ -44,7 +47,7 @@ function AuthProviderButton({ provider }: { provider: string }) {
}
unlinkAccountMutation.mutate()
}}
isLoading={unlinkAccountMutation.isPending}
disabled={unlinkAccountMutation.isPending}
>
<div className="flex items-center gap-2">
<i className={cn("text-xl", authProvidersConfig[provider]?.iconClassName)} />
@ -55,7 +58,7 @@ function AuthProviderButton({ provider }: { provider: string }) {
</span>
{account && <i className="i-mgc-delete-2-cute-re" />}
</div>
</Button>
</button>
)
}

View File

@ -36,6 +36,8 @@ const updatePasswordFormSchema = z
const UpdateExistingPasswordForm = () => {
const { t } = useTranslation("settings")
const { t: tApp } = useTranslation("app")
const form = useForm<z.infer<typeof updatePasswordFormSchema>>({
resolver: zodResolver(updatePasswordFormSchema),
defaultValues: {
@ -119,7 +121,14 @@ const UpdateExistingPasswordForm = () => {
</FormItem>
)}
/>
<div className="text-right">
<div className="flex items-center justify-between pl-2">
<a
href={`${env.VITE_WEB_URL}/forget-password`}
className="text-sm duration-200 hover:text-accent hover:underline"
target="_blank"
>
{tApp("login.forget_password.note")}
</a>
<Button type="submit" isLoading={updateMutation.isPending}>
{t("profile.submit")}
</Button>

View File

@ -4,11 +4,57 @@ import { Input, TextArea } from "@follow/components/ui/input/index.js"
import { Label } from "@follow/components/ui/label/index.jsx"
import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.jsx"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import type { ChangeEventHandler, ReactNode } from "react"
import { useId, useState } from "react"
import { useCallback, useId, useState } from "react"
import { useTranslation } from "react-i18next"
import { titleCase } from "title-case"
import { useIsPaymentEnabled } from "~/atoms/server-configs"
import { SettingPaidLevels } from "./helper/setting-builder"
import { useSetSettingTab } from "./modal/context"
export const PaidBadge: Component<{
paidLevel: SettingPaidLevels
}> = ({ paidLevel }) => {
const { t } = useTranslation("settings")
const setTab = useSetSettingTab()
const isPaymentEnabled = useIsPaymentEnabled()
const handleClick = useCallback(
(e) => {
e.preventDefault()
setTab("plan")
},
[setTab],
)
if (!isPaymentEnabled) {
return null
}
return (
<Tooltip>
<TooltipTrigger asChild>
<i className="i-mgc-power block text-accent" onClick={handleClick} />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{paidLevel === SettingPaidLevels.FreeLimited && t("control.paid_badge.free_limited")}
{paidLevel === SettingPaidLevels.Plus && t("control.paid_badge.plus_or_higher")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)
}
export const SettingCheckbox: Component<{
label: string
checked: boolean
@ -32,15 +78,20 @@ export const SettingSwitch: Component<{
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}> = ({ checked, label, onCheckedChange, className }) => {
disabled?: boolean
paidLevel?: SettingPaidLevels
}> = ({ checked, label, onCheckedChange, className, disabled, paidLevel }) => {
const id = useId()
const handleCheckedChange = (checked: boolean) => {
onCheckedChange(checked)
}
return (
<div className={cn("mb-3 flex items-center justify-between gap-4", className)}>
<Label htmlFor={id}>{titleCase(label)}</Label>
<Switch id={id} checked={checked} onCheckedChange={handleCheckedChange} />
<Label htmlFor={id} className="flex items-center gap-1">
<span>{titleCase(label)}</span>
{!!paidLevel && <PaidBadge paidLevel={paidLevel} />}
</Label>
<Switch id={id} checked={checked} onCheckedChange={handleCheckedChange} disabled={disabled} />
</div>
)
}

View File

@ -1,10 +1,16 @@
import type { SettingNamespace } from "@follow/shared/settings/constants"
import { getSettingPaidLevel } from "@follow/shared/settings/constants"
import type { JSX } from "react/jsx-runtime"
import type { SettingItem } from "./setting-builder"
import { createSettingBuilder } from "./setting-builder"
export const createDefineSettingItem =
<T>(_getSetting: () => T, setSetting: (key: any, value: Partial<T>) => void) =>
<T>(
settingNamespace: SettingNamespace,
_getSetting: () => T,
setSetting: (key: any, value: Partial<T>) => void,
) =>
<K extends keyof T>(
key: K,
options: {
@ -13,9 +19,10 @@ export const createDefineSettingItem =
onChange?: (value: T[K]) => void
onAfterChange?: (value: T[K]) => void
hide?: boolean
} & Omit<SettingItem<any>, "onChange" | "description" | "label" | "hide" | "key">,
} & Omit<SettingItem<any>, "onChange" | "description" | "label" | "hide" | "key" | "paidLevel">,
): any => {
const { label, description, onChange, hide, onAfterChange, ...rest } = options
const paidLevel = getSettingPaidLevel(settingNamespace, String(key))
if (hide) return null
return {
@ -31,16 +38,18 @@ export const createDefineSettingItem =
}
},
disabled: hide,
paidLevel,
...rest,
} as SettingItem<any>
}
export const createSetting = <T extends object>(
settingNamespace: SettingNamespace,
useSetting: () => T,
setSetting: (key: any, value: Partial<T>) => void,
) => {
const SettingBuilder = createSettingBuilder(useSetting)
const defineSettingItem = createDefineSettingItem(useSetting, setSetting)
const defineSettingItem = createDefineSettingItem(settingNamespace, useSetting, setSetting)
return {
SettingBuilder,
defineSettingItem,

View File

@ -1,4 +1,7 @@
/* eslint-disable @eslint-react/no-array-index-key */
import { UserRole } from "@follow/constants"
import { SettingPaidLevels } from "@follow/shared/settings/constants"
import { useUserRole } from "@follow/store/user/hooks"
import type { FC, ReactNode } from "react"
import * as React from "react"
import { isValidElement } from "react"
@ -6,6 +9,8 @@ import { isValidElement } from "react"
import { SettingActionItem, SettingDescription, SettingInput, SettingSwitch } from "../control"
import { SettingItemGroup, SettingSectionTitle } from "../section"
export { SettingPaidLevels } from "@follow/shared/settings/constants"
type SharedSettingItem = {
disabled?: boolean
}
@ -25,6 +30,7 @@ export type SettingItem<T, K extends keyof T = keyof T> = {
className?: string
[key: string]: any
}
paidLevel?: SettingPaidLevels
} & SharedSettingItem
type SectionSettingItem = {
@ -54,6 +60,7 @@ export const createSettingBuilder =
}) => {
const { settings } = props
const settingObject = useSetting()
const role = useUserRole()
const filteredSettings = settings.filter((i) => !!i)
return filteredSettings.map((setting, index) => {
@ -64,7 +71,7 @@ export const createSettingBuilder =
const assertSetting = setting as SettingItem<T> | SectionSettingItem | ActionSettingItem
if (!assertSetting) return null
if (assertSetting.disabled) return null
// if (assertSetting.disabled) return null
const nextItem = filteredSettings[index + 1]
// If has no next item or next item is also a title, then it is an empty section
@ -90,6 +97,12 @@ export const createSettingBuilder =
if ("type" in assertSetting && assertSetting.type === "title") {
return null
}
const disabledForRole =
role === UserRole.Free &&
"paidLevel" in assertSetting &&
assertSetting.paidLevel !== undefined &&
assertSetting.paidLevel !== SettingPaidLevels.Free &&
assertSetting.paidLevel !== SettingPaidLevels.FreeLimited
let ControlElement: React.ReactNode
@ -110,6 +123,8 @@ export const createSettingBuilder =
assertSetting.onChange(checked as T[keyof T])
}}
label={assertSetting.label}
disabled={assertSetting.disabled || disabledForRole}
paidLevel={assertSetting.paidLevel}
/>
)
break

View File

@ -1,5 +1,5 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { cn } from "@follow/utils"
import { clsx, cn } from "@follow/utils"
import { repository } from "@pkg"
import type { FC } from "react"
import {
@ -8,7 +8,6 @@ import {
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
@ -18,10 +17,11 @@ import { useLoaderData } from "react-router"
import { ModalClose } from "~/components/ui/modal/stacked/components"
import { SettingsTitle } from "~/modules/settings/title"
import { SettingSectionHighlightContext } from "../section"
import { useAvailableSettings } from "../hooks/use-setting-ctx"
import { SettingSectionHighlightIdContext } from "../section"
import { getSettingPages } from "../settings-glob"
import type { SettingPageConfig } from "../utils"
import { useSettingTab } from "./context"
import { SettingTabProvider, useSettingTab } from "./context"
import { SettingModalLayout } from "./layout"
export const SettingModalContent: FC<{
@ -29,102 +29,95 @@ export const SettingModalContent: FC<{
initialSection?: string
}> = ({ initialTab, initialSection }) => {
const pages = getSettingPages()
const resolvedInitialTab = initialTab && initialTab in pages ? initialTab : undefined
const availableSettings = useAvailableSettings()
const resolvedInitialTab =
initialTab && initialTab in pages ? initialTab : availableSettings[0]!.path
return (
<SettingModalLayout initialTab={resolvedInitialTab}>
<Content initialTab={resolvedInitialTab} initialSection={initialSection} />
</SettingModalLayout>
<SettingTabProvider initialTab={resolvedInitialTab}>
<SettingModalLayout>
<Content initialSection={initialSection} />
</SettingModalLayout>
</SettingTabProvider>
)
}
const Content: FC<{
initialTab?: string
initialSection?: string
}> = ({ initialTab, initialSection }) => {
initialSection?: string | null
}> = ({ initialSection }) => {
const key = useDeferredValue(useSettingTab() || "general")
const pages = getSettingPages()
const { Component, loader } = pages[key]
const [scrollerAtTop, setScrollerAtTop] = useState(true)
const [scroller, setScroller] = useState<HTMLDivElement | null>(null)
const sectionRefs = useRef(new Map<string, HTMLElement>())
const pendingSectionRef = useRef<string | null>(initialSection ?? null)
const hasAppliedInitialSectionRef = useRef(false)
const [highlightedSectionId, setHighlightedSectionId] = useState<string | undefined>()
useEffect(() => {
pendingSectionRef.current = initialSection ?? null
hasAppliedInitialSectionRef.current = false
setHighlightedSectionId(undefined)
}, [initialSection])
useLayoutEffect(() => {
if (scroller) {
scroller.scrollTop = 0
}
}, [key])
}, [key, scroller])
const scrollToSection = useCallback((sectionId: string) => {
if (!sectionId) return false
const element = sectionRefs.current.get(sectionId)
if (!element) return false
useEffect(() => {
if (!scroller) return
const handler = () => {
setScrollerAtTop(scroller.scrollTop < 20)
}
scroller.addEventListener("scroll", handler)
return () => {
scroller.removeEventListener("scroll", handler)
}
}, [scroller])
element.scrollIntoView({
behavior: "smooth",
block: "start",
})
const scrollToSection = useCallback(
(sectionId: string) => {
if (!sectionId) return false
if (!scroller) return false
const element = scroller.querySelector(`[data-setting-section="${sectionId}"]`) as HTMLElement
if (!element) return false
setHighlightedSectionId(sectionId)
const elementTop = element.offsetTop
const scrollerTop = scroller.scrollTop
const delta = elementTop - scrollerTop
return true
}, [])
scroller.scrollTo({
top: delta,
behavior: "smooth",
})
const registerSection = useCallback(
(sectionId: string, element: HTMLElement | null) => {
if (!sectionId) return
if (element) {
sectionRefs.current.set(sectionId, element)
if (pendingSectionRef.current === sectionId) {
const handled = scrollToSection(sectionId)
if (handled) {
pendingSectionRef.current = null
hasAppliedInitialSectionRef.current = true
}
}
} else {
sectionRefs.current.delete(sectionId)
}
return true
},
[scrollToSection],
[scroller],
)
useEffect(() => {
if (!initialSection || hasAppliedInitialSectionRef.current) return
if (initialTab && key !== initialTab) return
const handled = scrollToSection(initialSection)
if (handled) {
hasAppliedInitialSectionRef.current = true
pendingSectionRef.current = null
} else {
pendingSectionRef.current = initialSection
if (initialSection) {
scrollToSection(initialSection)
}
}, [initialSection, initialTab, key, scrollToSection])
const highlightContextValue = useMemo(
() => ({
highlightedSectionId: highlightedSectionId ?? undefined,
registerSection,
}),
[highlightedSectionId, registerSection],
)
}, [initialSection, scrollToSection])
const config = (useLoaderData() || loader || {}) as SettingPageConfig
if (!Component) return null
return (
<Suspense>
<SettingsTitle loader={loader} className="relative mb-0 px-8" />
<SettingsTitle
loader={loader}
className={clsx(
"relative mb-0 border-b border-transparent px-8 transition-colors duration-200",
!scrollerAtTop ? "border-border" : "",
)}
/>
<ModalClose />
<ScrollArea.ScrollArea
mask={false}
@ -135,9 +128,9 @@ const Content: FC<{
config.viewportClassName,
)}
>
<SettingSectionHighlightContext value={highlightContextValue}>
<SettingSectionHighlightIdContext value={initialSection!}>
<Component />
</SettingSectionHighlightContext>
</SettingSectionHighlightIdContext>
<div className="h-16" />
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">

View File

@ -1,12 +1,37 @@
import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
import { createContext } from "react"
import { createContext, use, useMemo } from "react"
import { createAtomHooks } from "~/lib/jotai"
export const [, , useSettingTab, useSetSettingTab, getSettingTab, setSettingTab] = createAtomHooks(
atom(""),
)
const SettingTabContext = createContext<ReturnType<typeof createAtomHooks<string>>>(null!)
export const SettingTabProvider = ({
children,
initialTab,
}: {
children: React.ReactNode
initialTab?: string
}) => {
const ctxValue = useMemo(() => {
return createAtomHooks(atom(initialTab ?? ""))
}, [])
return <SettingTabContext value={ctxValue}>{children}</SettingTabContext>
}
const useSettingTabContext = () => {
const ctx = use(SettingTabContext)
if (!ctx) {
throw new Error("SettingTabContext not found")
}
return ctx
}
export const useSettingTab = () => {
return useSettingTabContext()[2]()
}
export const useSetSettingTab = () => {
return useSettingTabContext()[3]()
}
export const SettingModalContentPortalableContext = createContext<PrimitiveAtom<HTMLElement>>(
atom(null as any),

View File

@ -9,7 +9,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai"
import type { BoundingBox } from "motion/react"
import { Resizable } from "re-resizable"
import type { PropsWithChildren } from "react"
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef } from "react"
import { memo, Suspense, use, useCallback, useMemo, useRef } from "react"
import { createPortal } from "react-dom"
import { useUISettingSelector } from "~/atoms/settings/ui"
@ -29,14 +29,9 @@ import { DisableWhy } from "../utils"
import { SettingModalContentPortalableContext, useSetSettingTab, useSettingTab } from "./context"
import { defaultCtx, SettingContext } from "./hooks"
export function SettingModalLayout(
props: PropsWithChildren<{
initialTab?: string
}>,
) {
const { children, initialTab } = props
const setTab = useSetSettingTab()
const tab = useSettingTab()
export function SettingModalLayout(props: PropsWithChildren) {
const { children } = props
const elementRef = useRef<HTMLDivElement>(null)
const edgeElementRef = useRef<HTMLDivElement>(null)
const {
@ -53,17 +48,6 @@ export function SettingModalLayout(
draggable: true,
})
const availableSettings = useAvailableSettings()
useEffect(() => {
if (!tab) {
if (initialTab) {
setTab(initialTab)
} else {
setTab(availableSettings[0]!.path)
}
}
}, [availableSettings])
const { draggable, overlay } = useUISettingSelector((state) => ({
draggable: state.modalDraggable,
overlay: state.modalOverlay,

View File

@ -9,13 +9,7 @@ import { titleCase } from "title-case"
import { SettingActionItem, SettingDescription, SettingSwitch } from "./control"
type SettingSectionHighlightContextValue = {
highlightedSectionId?: string
registerSection: (sectionId: string, element: HTMLElement | null) => void
}
export const SettingSectionHighlightContext =
createContext<SettingSectionHighlightContextValue | null>(null)
export const SettingSectionHighlightIdContext = createContext<string | null>(null)
export const SettingSectionTitle: FC<{
title: string | ReactNode
@ -23,32 +17,59 @@ export const SettingSectionTitle: FC<{
margin?: "compact" | "normal"
sectionId?: string
}> = ({ title, margin, className, sectionId }) => {
const highlightCtx = use(SettingSectionHighlightContext)
const highlightedSectionId = use(SettingSectionHighlightIdContext)
const elementRef = useRef<HTMLDivElement | null>(null)
const isHighlighted = !!sectionId && highlightedSectionId === sectionId && !!elementRef.current
useEffect(() => {
if (!sectionId || !highlightCtx) return
highlightCtx.registerSection(sectionId, elementRef.current)
return () => {
highlightCtx.registerSection(sectionId, null)
if (!isHighlighted) {
return
}
}, [highlightCtx, sectionId])
const isHighlighted =
!!sectionId && highlightCtx?.highlightedSectionId === sectionId && !!elementRef.current
let rollingAnimation: Animation | null = null
const timer = setTimeout(() => {
const highlightedElement = elementRef.current?.querySelector(
"[data-highlighted-element]",
) as HTMLElement
if (!highlightedElement) {
clearTimeout(timer)
return
}
const keyframeEffect = new KeyframeEffect(
highlightedElement,
[
{
backgroundColor: "color-mix(in srgb, hsl(var(--fo-a)) 33%, hsl(var(--background)) 67%)",
},
{ backgroundColor: "transparent" },
],
{
duration: 1000,
easing: "ease-in-out",
},
)
rollingAnimation = new Animation(keyframeEffect, document.timeline)
rollingAnimation.play()
}, 500)
return () => {
rollingAnimation?.cancel()
clearTimeout(timer)
}
}, [isHighlighted])
return (
<div
ref={elementRef}
data-setting-section={sectionId}
data-highlighted={isHighlighted ? "true" : undefined}
className={cn(
"shrink-0 text-headline font-bold text-text opacity-50 transition-colors duration-300 first:mt-0",
"relative shrink-0 text-headline font-bold text-text/80 first:mt-0",
margin === "compact" ? "mb-2 mt-8" : "mb-4 mt-10",
isHighlighted && "-ml-3 rounded-lg border border-folo px-3 py-1.5 opacity-100",
className,
)}
>
{isHighlighted && <div className="absolute -inset-4 rounded-lg" data-highlighted-element />}
{typeof title === "string" ? titleCase(title) : title}
</div>
)

View File

@ -14,7 +14,7 @@ import { TaskSchedulingSection } from "./ai/tasks"
import { UsageAnalysisSection } from "./ai/usage"
const SettingBuilder = createSettingBuilder(useAISettingValue)
const defineSettingItem = createDefineSettingItem(useAISettingValue, setAISetting)
const defineSettingItem = createDefineSettingItem("ai", useAISettingValue, setAISetting)
export const AI_SETTING_SECTION_IDS = {
shortcuts: "settings-ai-shortcuts",

View File

@ -34,6 +34,7 @@ export const TaskSchedulingSection = () => {
size={"sm"}
variant={"outline"}
onClick={handleCreateTask}
buttonClassName="!-translate-y-7"
>
<i className="i-mgc-add-cute-re mr-2 size-4" />
{t("tasks.actions.new_task")}

View File

@ -5,7 +5,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAIConfiguration } from "~/modules/ai-chat/hooks/useAIConfiguration"
import { DetailedUsageModal, UsageProgressRing, UsageWarningBanner } from "./components"
import { formatTimeRemaining, formatTokenCountString } from "./utils"
import { formatTokenCountString } from "./utils"
export const UsageAnalysisSection = () => {
const { t } = useTranslation("ai")
@ -48,11 +48,6 @@ export const UsageAnalysisSection = () => {
<div className="text-xs text-text-tertiary">
{formatTokenCountString(usage.used)} / {formatTokenCountString(usage.total)} used
</div>
<div className="text-xs text-text-secondary">
{t("usage_analysis.resets_in")}{" "}
{formatTimeRemaining(rateLimit.windowResetTime - Date.now())}
</div>
</div>
</div>
<button

View File

@ -88,7 +88,7 @@ export const DetailedUsageModal = () => {
<div className="space-y-6 px-4">
<p className="text-sm text-text-secondary">
{t("usage_analysis.detailed_description", {
defaultValue: "Track your AI token usage, patterns, and efficiency.",
defaultValue: "Track your AI credits usage, patterns, and efficiency.",
})}
</p>

View File

@ -48,7 +48,7 @@ import { SettingItemGroup } from "../section"
import { ContentFontSelector, UIFontSelector } from "../sections/fonts"
const SettingBuilder = createSettingBuilder(useUISettingValue)
const _defineItem = createDefineSettingItem(useUISettingValue, setUISetting)
const _defineItem = createDefineSettingItem("ui", useUISettingValue, setUISetting)
export const SettingAppearance = () => {
const { t } = useTranslation("settings")

View File

@ -33,7 +33,7 @@ import { SettingActionItem, SettingDescription } from "../control"
import { createSetting } from "../helper/builder"
import { SettingItemGroup } from "../section"
const { SettingBuilder } = createSetting(useGeneralSettingValue, setGeneralSetting)
const { SettingBuilder } = createSetting("general", useGeneralSettingValue, setGeneralSetting)
export const SettingDataControl = () => {
const { t } = useTranslation("settings")

View File

@ -1,7 +1,9 @@
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import { UserRole } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { ACTION_LANGUAGE_MAP } from "@follow/shared"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useUserRole } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import { useQuery } from "@tanstack/react-query"
import dayjs from "dayjs"
@ -12,6 +14,7 @@ import { useTranslation } from "react-i18next"
import { currentSupportedLanguages } from "~/@types/constants"
import { defaultResources } from "~/@types/default-resource"
import { langLoadingLockMapAtom } from "~/atoms/lang"
import { useIsPaymentEnabled } from "~/atoms/server-configs"
import {
DEFAULT_ACTION_LANGUAGE,
setGeneralSetting,
@ -26,8 +29,9 @@ import { fallbackLanguage } from "~/i18n"
import { ipcServices } from "~/lib/client"
import { setTranslationCache } from "~/modules/entry-content/atoms"
import { SettingDescription, SettingInput, SettingSwitch } from "../control"
import { PaidBadge, SettingDescription, SettingInput, SettingSwitch } from "../control"
import { createSetting } from "../helper/builder"
import { SettingPaidLevels } from "../helper/setting-builder"
import {
useWrapEnhancedSettingItem,
WrapEnhancedSettingTab,
@ -35,6 +39,7 @@ import {
import { SettingItemGroup } from "../section"
const { defineSettingItem: _defineSettingItem, SettingBuilder } = createSetting(
"general",
useGeneralSettingValue,
setGeneralSetting,
)
@ -78,7 +83,7 @@ export const SettingGeneral = () => {
defineSettingItem("appLaunchOnStartup", {
label: t("general.launch_at_login"),
disabled: !ipcServices,
hide: !ipcServices,
onChange(value) {
saveLoginSetting(value)
},
@ -290,11 +295,17 @@ export const LanguageSelector = ({
const TranslationModeSelector = () => {
const { t } = useTranslation("settings")
const translationMode = useGeneralSettingKey("translationMode")
const role = useUserRole()
const isPaymentEnabled = useIsPaymentEnabled()
const disabledForRole = role === UserRole.Free && isPaymentEnabled
return (
<>
<div className="mt-4 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">{t("general.translation_mode.label")}</span>
<span className="flex shrink-0 items-center gap-1 text-sm font-medium">
<span>{t("general.translation_mode.label")}</span>
<PaidBadge paidLevel={SettingPaidLevels.Plus} />
</span>
<ResponsiveSelect
size="sm"
triggerClassName="w-48"
@ -307,6 +318,7 @@ const TranslationModeSelector = () => {
{ label: t("general.translation_mode.bilingual"), value: "bilingual" },
{ label: t("general.translation_mode.translation-only"), value: "translation-only" },
]}
disabled={disabledForRole}
/>
</div>
<SettingDescription>{t("general.translation_mode.description")}</SettingDescription>

View File

@ -13,6 +13,7 @@ import { SettingSectionTitle } from "../../section"
import { CustomIntegrationModalContent } from "./CustomIntegrationModal"
const { defineSettingItem, SettingBuilder } = createSetting(
"integration",
useIntegrationSettingValue,
setIntegrationSetting,
)

View File

@ -31,6 +31,7 @@ import { SettingSectionTitle } from "../../section"
import { CustomIntegrationSection } from "./CustomIntegrationSection"
const { defineSettingItem, SettingBuilder } = createSetting(
"integration",
useIntegrationSettingValue,
setIntegrationSetting,
)

View File

@ -1,71 +1,54 @@
import { Button } from "@follow/components/ui/button/index.js"
import { UserRole, UserRoleName } from "@follow/constants"
import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.jsx"
import { UserRole } from "@follow/constants"
import { DEEPLINK_SCHEME, IN_ELECTRON } from "@follow/shared"
import { env } from "@follow/shared/env.desktop"
import { useRoleEndAt, useUserRole, useWhoami } from "@follow/store/user/hooks"
import { useUserRole, useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import NumberFlow from "@number-flow/react"
import { useMutation, useQuery } from "@tanstack/react-query"
import { useState } from "react"
import { Trans } from "react-i18next"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
import { subscription } from "~/lib/auth"
// Plan configuration types
interface Plan {
id: string
title: string
monthlyPrice: number
yearlyPrice: number
features: string[]
isPopular?: boolean
role: UserRole
isComingSoon?: boolean
tier: number // Add tier for hierarchy comparison
const formatFeatureValue = (
key: keyof PaymentFeature,
value: number | boolean | null | undefined,
): string => {
if (value == null || value === undefined) {
return "—"
}
if (typeof value === "boolean") {
return value ? "✓" : "—"
}
if (key === "PRIORITY_SUPPORT" && typeof value === "number") {
return "⭐️".repeat(value)
}
if (value === Number.MAX_SAFE_INTEGER) {
return "Unlimited"
}
return new Intl.NumberFormat("en", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(value)
}
// Plan hierarchy: Free (1) < Pro Preview (2) < Pro (3)
const PLAN_TIER_MAP: Record<UserRole, number> = {
[UserRole.Admin]: 4, // Admin has highest tier
[UserRole.Free]: 1,
[UserRole.Trial]: 1, // Same as Free (deprecated)
[UserRole.PreProTrial]: 2,
[UserRole.Pro]: 3,
}
// Plan configurations
const PLAN_CONFIGS: Plan[] = [
{
id: "free",
title: UserRoleName[UserRole.Free],
monthlyPrice: 0,
yearlyPrice: 0,
features: ["50 feeds", "10 lists"],
isPopular: false,
role: UserRole.Free,
tier: PLAN_TIER_MAP[UserRole.Free],
},
{
id: "folo pro",
title: UserRoleName[UserRole.Pro],
monthlyPrice: 20,
yearlyPrice: 180,
features: [
"1000 feeds and lists",
"10 inboxes",
"10 actions",
"100 webhooks",
"Advanced AI features",
],
isPopular: true,
role: UserRole.Pro,
tier: PLAN_TIER_MAP[UserRole.Pro],
},
]
const useUpgradePlan = ({ plan, annual }: { plan: string; annual: boolean }) => {
const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {
return useMutation({
mutationFn: async () => {
if (!plan) {
return
}
const res = await subscription.upgrade({
plan,
annual,
@ -86,22 +69,20 @@ const useActiveSubscription = () => {
queryKey: ["activeSubscription"],
queryFn: async () => {
const { data } = await subscription.list()
return data
return data?.find((sub) => sub.status === "active" || sub.status === "trialing")
},
enabled: !!userId,
})
}
const useCancelPlan = () => {
const activeSubscription = useActiveSubscription()
const latestSubscription = activeSubscription.data?.at(-1)
const { data: latestSubscription } = useActiveSubscription()
const subscriptionId = latestSubscription?.id
const cancelAtPeriodEnd = latestSubscription?.cancelAtPeriodEnd
const cancelMutation = useMutation({
mutationFn: async () => {
if (!subscriptionId) {
toast.error("No active subscription found.")
return
}
@ -117,7 +98,7 @@ const useCancelPlan = () => {
},
})
if (cancelAtPeriodEnd) {
if (cancelAtPeriodEnd || !subscriptionId) {
return null
} else {
return cancelMutation
@ -125,162 +106,175 @@ const useCancelPlan = () => {
}
export function SettingPlan() {
const isPaymentEnabled = useIsPaymentEnabled()
const role = useUserRole()
const roleEndDate = useRoleEndAt()
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("yearly")
const daysLeft = roleEndDate
? Math.ceil((roleEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
const serverConfig = useServerConfigs()
const plans = serverConfig?.PAYMENT_PLAN_LIST || []
const currentPlan = plans.find((plan) => plan.role === role)
const currentTier = currentPlan?.tier || 0
// Calculate average savings percentage across all paid plans
const averageSavings = Math.round(
plans
.filter((plan) => plan.priceInDollars > 0 && plan.priceInDollarsAnnual > 0)
.reduce((acc, plan) => {
const monthlyTotal = plan.priceInDollars * 12
const yearlyTotal = plan.priceInDollarsAnnual
const savings = ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100
return acc + savings
}, 0) / plans.filter((plan) => plan.priceInDollars > 0).length,
)
if (!isPaymentEnabled) {
return null
}
return (
<section className="mt-4 space-y-8">
{/* Description Section */}
<p className="mb-4 space-y-2 text-sm">
<Trans ns="settings" i18nKey="plan.description" />
</p>
{/* Billing Period Toggle */}
<div className="flex justify-center">
<div className="inline-flex rounded-lg bg-fill-secondary p-1">
<button
type="button"
onClick={() => setBillingPeriod("monthly")}
className={cn(
"rounded-md px-4 py-2 text-sm font-medium transition-all",
billingPeriod === "monthly"
? "bg-background text-text shadow-sm"
: "text-text-secondary hover:text-text",
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setBillingPeriod("yearly")}
className={cn(
"rounded-md px-4 py-2 text-sm font-medium transition-all",
billingPeriod === "yearly"
? "bg-background text-text shadow-sm"
: "text-text-secondary hover:text-text",
)}
>
<span>Yearly</span>
<span className="ml-2 text-xs font-medium text-green">Save 25%</span>
</button>
</div>
<SegmentGroup
value={billingPeriod}
onValueChanged={(value) => setBillingPeriod(value as "monthly" | "yearly")}
>
<SegmentItem value="monthly" label="Monthly" />
<SegmentItem
value="yearly"
label={
<span className="flex items-center gap-2">
<span>Yearly</span>
{averageSavings > 0 && (
<span className="text-xs font-semibold text-green">Save {averageSavings}%</span>
)}
</span>
}
/>
</SegmentGroup>
</div>
{/* Plans Grid */}
<div className="@container">
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @xl:grid-cols-3">
{PLAN_CONFIGS.map((plan) => (
{plans.map((plan) => (
<PlanCard
key={plan.id}
key={plan.name}
plan={plan}
billingPeriod={billingPeriod}
currentUserRole={role || null}
daysLeft={daysLeft}
isCurrentPlan={role === plan.role}
currentTier={currentTier}
/>
))}
</div>
</div>
{/* Comparison Table */}
<PlanComparisonTable plans={plans} />
</section>
)
}
// Reusable PlanCard Component
interface PlanCardProps {
plan: Plan
plan: PaymentPlan
billingPeriod: "monthly" | "yearly"
currentUserRole: UserRole | null
isCurrentPlan: boolean
daysLeft: number | null
currentTier: number
}
const PlanCard = ({
plan,
billingPeriod,
currentUserRole,
isCurrentPlan,
daysLeft,
}: PlanCardProps) => {
const getPlanActionType = (): "current" | "upgrade" | "coming-soon" | "in-trial" | null => {
const PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardProps) => {
const { t } = useTranslation("settings")
const getPlanActionType = ():
| "current"
| "upgrade"
| "coming-soon"
| "in-trial"
| "switch"
| null => {
if (plan.isComingSoon) return "coming-soon"
if (!currentUserRole) {
return plan.tier > PLAN_TIER_MAP[UserRole.Free] ? "upgrade" : "current"
}
const currentTier = PLAN_TIER_MAP[currentUserRole]
const targetTier = plan.tier
if (currentTier === targetTier) {
// if (currentUserRole === UserRole.PreProTrial) {
// return "in-trial"
// } else {
// return "current"
switch (true) {
case isCurrentPlan: {
return "current"
}
case plan.tier > currentTier && !!plan.planID: {
return "upgrade"
}
// case plan.tier < currentTier && !!plan.planID: {
// return "switch"
// }
return "current"
default: {
return null
}
}
if (targetTier > currentTier) return "upgrade"
return null
}
const actionType = getPlanActionType()
const upgradePlanMutation = useUpgradePlan({
plan: "folo pro",
plan: plan.planID,
annual: billingPeriod === "yearly",
})
const cancelPlanMutation = useCancelPlan()
// Calculate price and period based on billing period
const price = billingPeriod === "yearly" ? plan.yearlyPrice : plan.monthlyPrice
const formattedPrice = price === 0 ? "$0" : `$${price}`
const period = plan.role === UserRole.Free ? "" : billingPeriod === "yearly" ? "year" : "month"
const regularPrice =
billingPeriod === "yearly" ? plan.priceInDollarsAnnual / 12 : plan.priceInDollars
const discountPrice =
billingPeriod === "yearly"
? (plan.priceInDollarsInDiscountAnnual || 0) / 12
: plan.priceInDollarsInDiscount
const period = plan.role === UserRole.Free ? "" : "month"
// Calculate savings for yearly plan
const yearlyTotalPrice = plan.yearlyPrice
const monthlyTotalPrice = plan.monthlyPrice * 12
const savingsPercentage =
plan.monthlyPrice > 0 && plan.yearlyPrice > 0
? Math.round(((monthlyTotalPrice - yearlyTotalPrice) / monthlyTotalPrice) * 100)
: 0
// Calculate discount percentage from prices
const hasDiscount =
discountPrice &&
discountPrice > 0 &&
discountPrice < regularPrice &&
discountPrice !== regularPrice
// Use discount price if available, otherwise use regular price
const finalPrice = hasDiscount ? discountPrice : regularPrice
const regularPriceForStrike = hasDiscount && regularPrice > 0 ? regularPrice : undefined
// Calculate discount percentage
const discountPercentage = hasDiscount
? Math.round(((regularPrice - discountPrice) / regularPrice) * 100)
: 0
// Create discount description
const discountDescription =
hasDiscount && discountPercentage > 0
? `Early bird discount, ${discountPercentage}% off`
: undefined
// Get plan description from i18n
const planDescriptionKey = `plan.descriptions.${plan.role}` as const
const planDescription = t(planDescriptionKey, { defaultValue: "" })
return (
<div
className={cn(
"group relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-200",
plan.isPopular
? "border-accent"
actionType === "upgrade"
? "border-accent/40 bg-background shadow-sm hover:border-accent/60 hover:shadow-md"
: "border-fill-tertiary bg-background hover:border-fill-secondary",
isCurrentPlan &&
"bg-gradient-to-b from-accent/5 to-transparent shadow-lg shadow-accent/10 ring-2 ring-accent ring-offset-2 ring-offset-background",
plan.isComingSoon && "opacity-75",
isCurrentPlan && "border-blue/30",
)}
>
<PlanBadges isPopular={plan.isPopular || false} />
<div className="flex h-full flex-col p-4 @md:p-5">
<div className="flex-1 space-y-3 @md:space-y-4">
<PlanHeader
title={plan.title}
price={formattedPrice}
period={period}
billingPeriod={billingPeriod}
monthlyPrice={plan.monthlyPrice}
yearlyPrice={plan.yearlyPrice}
savingsPercentage={savingsPercentage}
/>
<PlanFeatures features={plan.features} />
<div />
</div>
<div className="flex h-full flex-col justify-between gap-4 p-4 @md:p-5">
<PlanHeader
title={plan.name}
price={finalPrice}
regularPrice={regularPriceForStrike}
period={period}
description={planDescription}
discountDescription={discountDescription}
/>
<PlanAction
isPopular={plan.isPopular || false}
actionType={actionType}
daysLeft={daysLeft}
isLoading={upgradePlanMutation.isPending || cancelPlanMutation?.isPending}
onSelect={
!plan.isComingSoon && !isCurrentPlan
@ -290,7 +284,7 @@ const PlanCard = ({
: undefined
}
onCancel={
isCurrentPlan && plan.role !== UserRole.Free && cancelPlanMutation
isCurrentPlan && cancelPlanMutation
? () => {
cancelPlanMutation.mutate()
}
@ -299,8 +293,10 @@ const PlanCard = ({
/>
</div>
{/* Subtle gradient line at bottom */}
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-fill-tertiary to-transparent" />
{/* Subtle bottom accent line */}
{actionType === "upgrade" && (
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent" />
)}
</div>
)
}
@ -310,7 +306,7 @@ const PlanBadges = ({ isPopular }: { isPopular: boolean }) => (
<>
{isPopular && (
<div className="absolute -top-px right-4 z-10">
<div className="rounded-b-lg bg-gradient-to-r from-accent to-accent/80 px-1.5 py-1 text-caption font-medium text-white shadow-sm">
<div className="rounded-b-lg bg-gradient-to-r from-accent to-accent/90 px-2.5 py-1 text-caption font-medium text-white shadow-sm">
Most Popular
</div>
</div>
@ -321,108 +317,107 @@ const PlanBadges = ({ isPopular }: { isPopular: boolean }) => (
const PlanHeader = ({
title,
price,
regularPrice,
period,
billingPeriod,
monthlyPrice,
yearlyPrice,
savingsPercentage,
description,
discountDescription,
}: {
title: string
price: string
price: number
regularPrice?: number
period: string
billingPeriod: "monthly" | "yearly"
monthlyPrice: number
yearlyPrice: number
savingsPercentage: number
description?: string
discountDescription?: string
}) => (
<div className="space-y-1">
<h3 className="text-base font-semibold @md:text-lg">{title}</h3>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold">{price}</span>
{period && <span className="text-xs text-text-secondary @md:text-sm">/{period}</span>}
</div>
<div className="h-7 space-y-0.5 @md:h-8">
{billingPeriod === "yearly" &&
monthlyPrice > 0 &&
yearlyPrice > 0 &&
savingsPercentage > 0 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-tertiary line-through">
${(monthlyPrice * 12).toFixed(0)}/year
</span>
<span className="font-medium text-green">Save {savingsPercentage}%</span>
</div>
<div className="space-y-2.5">
<h3 className="text-lg font-semibold">{title}</h3>
<div className="space-y-1.5">
<div className="flex items-baseline gap-1.5">
<NumberFlow
className="text-2xl font-bold"
value={price}
locales="en-US"
format={{ style: "currency", currency: "USD", trailingZeroDisplay: "stripIfInteger" }}
/>
{period && <span className="text-xs text-text-secondary @md:text-sm">/{period}</span>}
{typeof regularPrice === "number" && regularPrice > 0 && (
<span className="relative inline-block text-sm text-text-tertiary after:absolute after:inset-x-0 after:top-1/2 after:h-px after:w-full after:translate-y-1/2 after:bg-text-tertiary after:content-['']">
<NumberFlow
value={regularPrice}
locales="en-US"
format={{ style: "currency", currency: "USD", trailingZeroDisplay: "stripIfInteger" }}
/>
</span>
)}
{billingPeriod === "yearly" && monthlyPrice > 0 && yearlyPrice > 0 && (
<div className="text-xs text-text-secondary">
${(yearlyPrice / 12).toFixed(1)}/month billed annually
</div>
{typeof regularPrice === "number" && regularPrice > 0 && (
<div className="flex items-center gap-2">
{discountDescription && (
<span className="inline-flex items-center gap-1 rounded-md bg-green/10 px-1.5 py-0.5 text-xs font-medium text-green">
{discountDescription}
</span>
)}
</div>
)}
</div>
</div>
)
const PlanFeatures = ({ features }: { features: string[] }) => (
<div className="space-y-1.5 @md:space-y-2">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-2.5 @md:gap-3">
<div className="mt-0.5 flex size-3.5 items-center justify-center rounded-full bg-green/10 @md:size-4">
<i className="i-mgc-check-cute-re text-[10px] text-green @md:text-xs" />
</div>
<span className="text-xs leading-relaxed @md:text-sm">{feature}</span>
</div>
))}
{description && <p className="text-xs leading-relaxed text-text-secondary">{description}</p>}
</div>
)
const PlanAction = ({
isPopular,
actionType,
onSelect,
onCancel,
isLoading,
daysLeft,
}: {
isPopular: boolean
actionType: "current" | "upgrade" | "coming-soon" | "in-trial" | null
actionType: "current" | "upgrade" | "coming-soon" | "in-trial" | "switch" | null
onSelect?: () => void
onCancel?: () => void
isLoading?: boolean
daysLeft: number | null
}) => {
const getButtonConfig = () => {
switch (actionType) {
case "coming-soon": {
return {
text: "Coming Soon",
icon: "i-mgc-time-cute-re",
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm",
disabled: true,
}
}
case "current": {
return {
text: typeof daysLeft === "number" ? `${daysLeft} days left` : "Current Plan",
text: `Current Plan${onCancel ? " | Cancel" : ""}`,
icon: undefined,
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm text-text-secondary",
disabled: true,
className: onCancel ? "" : "text-text-secondary",
disabled: onCancel ? false : true,
}
}
case "in-trial": {
return {
text: `In Trial (${daysLeft} days left)`,
text: "In Trial",
icon: "i-mgc-stopwatch-cute-re",
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm",
disabled: false,
}
}
case "upgrade": {
return {
text: "Upgrade",
variant: isPopular ? undefined : ("outline" as const),
className: isPopular
? "w-full h-9 @md:h-10 text-xs @md:text-sm bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70"
: "w-full h-9 @md:h-10 text-xs @md:text-sm",
icon: "i-mgc-arrow-up-cute-re",
className:
"bg-gradient-to-r from-accent to-accent/90 text-white hover:from-accent/95 hover:to-accent/85",
disabled: false,
}
}
case "switch": {
return {
text: "Switch Plan",
icon: "i-mgc-transfer-cute-re",
className:
"bg-gradient-to-r from-accent to-accent/90 text-white font-semibold hover:from-accent/95 hover:to-accent/85",
disabled: false,
}
}
@ -439,27 +434,98 @@ const PlanAction = ({
}
return (
<div className="space-y-2">
<Button
variant={buttonConfig.variant}
buttonClassName={buttonConfig.className}
disabled={buttonConfig.disabled}
onClick={buttonConfig.disabled ? undefined : onSelect}
isLoading={isLoading}
>
{buttonConfig.text}
</Button>
{actionType === "current" && typeof daysLeft === "number" && onCancel && (
<Button
variant="ghost"
buttonClassName="w-full h-8 text-xs text-text-tertiary hover:text-red"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant={buttonConfig.variant}
buttonClassName={cn(
"w-full h-9 text-sm font-medium transition-all duration-200",
buttonConfig.className,
)}
disabled={buttonConfig.disabled}
onClick={buttonConfig.disabled ? undefined : (onCancel ?? onSelect)}
isLoading={isLoading}
>
<span className="flex items-center justify-center gap-1.5">
{buttonConfig.icon && <i className={cn(buttonConfig.icon, "text-sm")} />}
<span>{buttonConfig.text}</span>
</span>
</Button>
)
}
const PlanComparisonTable = ({ plans }: { plans: PaymentPlan[] }) => {
const { t } = useTranslation("settings")
// Get all unique feature keys
const allFeatureKeys = Array.from(
new Set(plans.flatMap((plan) => Object.keys(plan.limit))),
) as (keyof PaymentFeature)[]
// Filter out features that are all false/0/null
const visibleFeatureKeys = allFeatureKeys.filter((key) =>
plans.some((plan) => {
const value = plan.limit[key]
if (value == null) return false
if (typeof value === "boolean" && !value) return false
if (typeof value === "number" && value === 0) return false
return true
}),
)
return (
<div className="overflow-hidden rounded-xl border border-fill-tertiary bg-background">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-fill-tertiary bg-fill-secondary/50">
<th className="sticky left-0 z-10 bg-fill-secondary/50 px-4 py-3 text-left text-sm font-semibold">
Features
</th>
{plans.map((plan) => (
<th key={plan.name} className="px-4 py-3 text-center text-sm font-semibold">
{plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{visibleFeatureKeys.map((featureKey, index) => (
<tr
key={featureKey}
className={cn(
"border-b border-fill-tertiary transition-colors hover:bg-fill-secondary/30",
index % 2 === 0 ? "bg-background" : "bg-fill-secondary/20",
)}
>
<td className="sticky left-0 z-10 bg-inherit px-4 py-3 text-sm font-medium">
{t(`plan.features.${featureKey}` as any)}
</td>
{plans.map((plan) => {
const value = plan.limit[featureKey]
const formattedValue = formatFeatureValue(featureKey, value)
return (
<td
key={`${plan.name}-${featureKey}`}
className="px-4 py-3 text-center text-sm"
>
<span
className={cn(
"font-medium",
formattedValue === "—" && "text-text-tertiary",
formattedValue === "✓" && "text-green",
formattedValue === "Unlimited" && "text-accent",
)}
>
{formattedValue}
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -42,7 +42,7 @@ export const ViewSelectorRadioGroup = ({
return (
<Card className={rest.disabled ? "pointer-events-none" : void 0}>
<CardHeader className={cn("grid grid-cols-6 space-y-0 px-2 py-3", className)}>
<CardHeader className={cn("flex flex-row justify-around space-y-0 px-2 py-3", className)}>
{configuredViews.map((view) => (
<div key={view.name}>
<input

View File

@ -2,7 +2,6 @@ import { ActionButton } from "@follow/components/ui/button/index.js"
import { RSSHubLogo } from "@follow/components/ui/platform-icon/icons.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
import { UserRole } from "@follow/constants"
import { useMeasure } from "@follow/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
@ -28,7 +27,6 @@ import { usePresentUserProfileModal } from "~/modules/profile/hooks"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { signOut, useSession } from "~/queries/auth"
import { useActivationModal } from "../activation"
import type { LoginProps } from "./LoginButton"
import { LoginButton } from "./LoginButton"
import { UserAvatar } from "./UserAvatar"
@ -53,7 +51,6 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
const navigate = useNavigate()
const role = useUserRole()
const presentActivationModal = useActivationModal()
const isInMASReview = useIsInMASReview()
if (status === "unauthenticated") {
@ -83,7 +80,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<EllipsisHorizontalTextWithTooltip className="mx-auto max-w-[20ch] truncate text-lg">
{user?.name}
</EllipsisHorizontalTextWithTooltip>
{serverConfig?.REFERRAL_ENABLED ? (
{!isInMASReview && serverConfig?.PAYMENT_ENABLED ? (
<UserProBadge
role={role}
withText
@ -121,26 +118,22 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuItem
className="pl-3"
onClick={() => {
if (role !== UserRole.Trial && role !== UserRole.Free) {
presentAchievement()
} else {
presentActivationModal()
}
presentAchievement()
}}
icon={<i className="i-mgc-trophy-cute-re" />}
>
{t("user_button.achievement")}
</DropdownMenuItem>
{!isInMASReview && (
{!isInMASReview && serverConfig?.PAYMENT_ENABLED && (
<DropdownMenuItem
className="pl-3"
onClick={() => {
navigate("/power")
settingModalPresent("plan")
}}
icon={<i className="i-mgc-power-outline" />}
>
{t("user_button.power")}
{t("activation.plan.title")}
</DropdownMenuItem>
)}

View File

@ -82,7 +82,7 @@ export const UserAvatar = ({
{renderUserData?.name?.[0]}
</AvatarFallback>
</Avatar>
{serverConfig?.REFERRAL_ENABLED &&
{serverConfig?.PAYMENT_ENABLED &&
!userId &&
role !== UserRole.Free &&
role !== UserRole.Trial && (

View File

@ -27,7 +27,7 @@ export const UserProBadge = ({
)}
onClick={onClick}
>
<i className={cn("i-mgc-power block text-folo", iconClassName)} />
<i className={cn("i-mgc-power block", iconClassName)} />
{withText && <span className="text-xs">{UserRoleName[role]}</span>}
</div>
)

View File

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

View File

@ -2,16 +2,12 @@ import { Divider } from "@follow/components/ui/divider/Divider.js"
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
import { UserRole } from "@follow/constants"
import { useUserRole } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import { createElement } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router"
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
import { ErrorComponentType } from "~/components/errors/enum"
import { useActivationModal } from "~/modules/activation"
import { useSubViewTitle } from "~/modules/app-layout/subview/hooks"
import { DiscoverForm } from "~/modules/discover/DiscoverForm"
import { DiscoverImport } from "~/modules/discover/DiscoverImport"
@ -24,7 +20,6 @@ import { Trending } from "~/modules/trending"
const tabs: {
name: I18nKeys
value: string
disableForTrial?: boolean
}[] = [
{
name: "words.search",
@ -41,7 +36,6 @@ const tabs: {
{
name: "words.inbox",
value: "inbox",
disableForTrial: true,
},
{
name: "words.user",
@ -62,18 +56,8 @@ export function Component() {
const { t } = useTranslation()
useSubViewTitle("words.discover")
const presentActivationModal = useActivationModal()
const role = useUserRole()
const { onUpdateMaxScroll } = useScrollElementUpdate()
const currentTabs = tabs.map((tab) => {
const disabled = tab.disableForTrial && (role === UserRole.Free || role === UserRole.Trial)
return {
...tab,
disabled,
}
})
return (
<div className="flex size-full flex-col px-6 py-8">
{/* Simple Header */}
@ -100,17 +84,12 @@ export function Component() {
<div className="mb-8">
<ScrollArea.ScrollArea flex orientation="horizontal" rootClassName="w-full">
<TabsList className="relative flex w-full">
{currentTabs.map((tab) => (
{tabs.map((tab) => (
<TabsTrigger
key={tab.name}
value={tab.value}
className={cn(tab.disabled && "cursor-not-allowed opacity-50")}
onClick={() => {
if (tab.disabled) {
presentActivationModal()
} else {
onUpdateMaxScroll?.()
}
onUpdateMaxScroll?.()
}}
>
{t(tab.name)}
@ -122,7 +101,7 @@ export function Component() {
{/* Tab Content */}
<div className="space-y-8">
{currentTabs.map((tab) => (
{tabs.map((tab) => (
<TabsContent key={tab.name} value={tab.value} className="mt-0">
<div className={tab.value === "inbox" ? "" : "flex flex-col items-center"}>
{createElement(TabComponent[tab.value]! || TabComponent.default, {

View File

@ -1,11 +1,34 @@
import { FeedViewType } from "@follow/constants"
import { useSubscriptionStore } from "@follow/store/subscription/store"
import { redirect } from "react-router"
import { getUISettings } from "~/atoms/settings/ui"
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING, ROUTE_VIEW_ALL } from "~/constants"
import { computeTimelineTabLists } from "~/hooks/biz/useTimelineList"
export function Component() {
return null
}
export const loader = () => {
return redirect(`/timeline/${ROUTE_VIEW_ALL}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`)
const uiSettings = getUISettings()
const subscriptionState = useSubscriptionStore.getState()
const hasAudiosSubscription =
subscriptionState.feedIdByView[FeedViewType.Audios].size > 0 ||
subscriptionState.listIdByView[FeedViewType.Audios].size > 0
const hasNotificationsSubscription =
subscriptionState.feedIdByView[FeedViewType.Notifications].size > 0 ||
subscriptionState.listIdByView[FeedViewType.Notifications].size > 0
const { visible } = computeTimelineTabLists({
timelineTabs: uiSettings.timelineTabs,
hasAudiosSubscription,
hasNotificationsSubscription,
})
const firstTimeline = visible[0] ?? ROUTE_VIEW_ALL
return redirect(`/timeline/${firstTimeline}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`)
}

View File

@ -1,8 +1,6 @@
import { UserRole } from "@follow/constants"
import { SettingLists } from "~/modules/settings/tabs/lists"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData, DisableWhy } from "~/modules/settings/utils"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-rada-cute-re"
const priority = (1000 << 2) + 10
@ -11,10 +9,6 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.lists",
priority,
disableIf: (ctx) => [
ctx.role === UserRole.Free || ctx.role === UserRole.Trial,
DisableWhy.NotActivation,
],
hideIf: (ctx) => ctx.isInMASReview,
})

View File

@ -1,8 +1,6 @@
import { UserRole } from "@follow/constants"
import { SettingNotifications } from "~/modules/settings/tabs/notifications"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData, DisableWhy } from "~/modules/settings/utils"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-notification-cute-re"
const priority = (1000 << 1) + 50
@ -11,10 +9,6 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.notifications",
priority,
disableIf: (ctx) => [
ctx.role === UserRole.Free || ctx.role === UserRole.Trial,
DisableWhy.NotActivation,
],
})
export function Component() {

View File

@ -3,14 +3,14 @@ import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-power-outline"
const priority = (1000 << 2) + 30
const priority = (1000 << 1) + 17
export const loader = defineSettingPageData({
icon: iconName,
name: "titles.plan.short",
title: "titles.plan.long",
priority,
hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.REFERRAL_ENABLED,
hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.PAYMENT_ENABLED,
})
export function Component() {

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