release(desktop): Release v1.0.0
release(desktop): Release v1.0.0
This commit is contained in:
commit
8187784cc8
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./query"
|
||||
export * from "./service"
|
||||
export * from "./store"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const useAIModel = () => {
|
|||
data: {
|
||||
defaultModel: configuration?.defaultModel,
|
||||
availableModels: configuration?.availableModels,
|
||||
availableModelsMenu: configuration?.availableModelsMenu,
|
||||
currentModel,
|
||||
},
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export interface ChatSession {
|
|||
title?: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export type RichTextPart = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { SettingSectionTitle } from "../../section"
|
|||
import { CustomIntegrationModalContent } from "./CustomIntegrationModal"
|
||||
|
||||
const { defineSettingItem, SettingBuilder } = createSetting(
|
||||
"integration",
|
||||
useIntegrationSettingValue,
|
||||
setIntegrationSetting,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { SettingSectionTitle } from "../../section"
|
|||
import { CustomIntegrationSection } from "./CustomIntegrationSection"
|
||||
|
||||
const { defineSettingItem, SettingBuilder } = createSetting(
|
||||
"integration",
|
||||
useIntegrationSettingValue,
|
||||
setIntegrationSetting,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue