release(desktop): Release v0.6.0
release(desktop): Release v0.6.0
This commit is contained in:
commit
121ff0035a
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
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)
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
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,6 +1,6 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
globs: apps/desktop/**/*,packages/internal/components/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
# UIKit Colors for Tailwind CSS
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
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,6 +1,6 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
globs: apps/desktop/layer/renderer/src/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
You need to find the available UI components in the project.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
|
||||
- name: Commit and push changes
|
||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
with:
|
||||
commit_message: "chore: auto-fix linting and formatting issues"
|
||||
commit_options: "--no-verify"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ on:
|
|||
- "**"
|
||||
paths:
|
||||
- "apps/desktop/**"
|
||||
- "packages/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/build-desktop.yml"
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ jobs:
|
|||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build web and SSR server
|
||||
run: |
|
||||
npm exec turbo run Folo#build:web @follow/ssr#build
|
||||
- name: Format, Lint and Typecheck
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=16384"
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@ vite.config.*.mjs
|
|||
|
||||
apps/desktop/src/renderer/dev-dist
|
||||
tsconfig.tsbuildinfo
|
||||
buildServer.json
|
||||
buildServer.json
|
||||
|
||||
**/**/generated-routes.ts
|
||||
|
|
@ -9,3 +9,4 @@ apps/mobile/android
|
|||
apps/mobile/ios
|
||||
apps/mobile/native/example
|
||||
|
||||
generated-routes.ts
|
||||
|
|
|
|||
|
|
@ -87,14 +87,20 @@ To develop in the mobile app, follow these steps:
|
|||
cd apps/mobile
|
||||
```
|
||||
|
||||
2. Build and install Folo(dev) app from source: (This step will take a while and only need to be done once)
|
||||
2. Copy the example environment variables file:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Build and install Folo(dev) app from source: (This step will take a while and only need to be done once)
|
||||
|
||||
```sh
|
||||
pnpm expo prebuild --clean # Optional
|
||||
pnpm run ios
|
||||
```
|
||||
|
||||
3. Run the development server:
|
||||
4. Run the development server:
|
||||
|
||||
```sh
|
||||
pnpm run dev
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<img src="https://github.com/user-attachments/assets/6997a236-3df3-49d5-98a4-514f6d1a02c4" height="60" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/RSSNext/Folo/graphs/contributors"><img src="https://img.shields.io/github/stars/RSSNext/Follow?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/RSSNext/Folo/stargazers"><img src="https://img.shields.io/github/stars/RSSNext/Follow?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/RSSNext/Folo/graphs/contributors"><img src="https://img.shields.io/github/contributors/RSSNext/Folo?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
|
||||
<a href="https://status.follow.is/" target="_blank"><img src="https://status.follow.is/api/badge/18/uptime?color=%2344CC10&labelColor=black&style=flat-square"/></a>
|
||||
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/downloads/RSSNext/Folo/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
|
||||
|
|
|
|||
|
|
@ -8,3 +8,6 @@ VITE_OPENPANEL_CLIENT_ID=
|
|||
VITE_OPENPANEL_API_URL=
|
||||
|
||||
VITE_EDITOR=cursor
|
||||
|
||||
VITE_PUBLIC_POSTHOG_KEY=
|
||||
VITE_PUBLIC_POSTHOG_HOST=
|
||||
|
|
|
|||
|
|
@ -27,3 +27,7 @@
|
|||
- “Mark as read” button is now perfectly centred (#3836)
|
||||
- Entry list no longer displays stale cached items (0a167ac)
|
||||
- Squashed numerous shortcut-key bugs
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to external contributors @ericyzhu @kovsu @yeeway0609 @cscnk52 for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# What's New in v0.6.0
|
||||
|
||||
## Shiny New Things
|
||||
|
||||
- Import and export your Actions (394d00f)
|
||||
- Add a bio, website, and social links to your profile (507a525)
|
||||
- Upload a profile picture
|
||||
- Use video duration as an Action condition
|
||||
|
||||
## Improvements
|
||||
|
||||
- A snazzy new look for your personal profile
|
||||
- Redesigned the Actions page (1ace5ea)
|
||||
- Redesigned the RSSHub page (f9aca60)
|
||||
- Added length limits to certain profile fields
|
||||
- Simplified default commands in the entry tool (85122fb)
|
||||
- Enhanced UI labels and descriptions for clarity (2ed9f70)
|
||||
- Gradually rolling out an experimental unified local database for mobile and desktop (#3897 #3902)
|
||||
- Polished image-preview styling (cf72753)
|
||||
- Refined toast notifications (73f8011)
|
||||
|
||||
## No Longer Broken
|
||||
|
||||
- More reliable automatic recovery after database-migration failures (c2e0c3d)
|
||||
- Fixed unread counts not clearing in the macOS Docker build (70255af)
|
||||
- Fixed old entries showing during initial load (24ae065)
|
||||
- Fixed handling of links starting with `.` (de8eac8)
|
||||
- Fixed text-to-speech not working (82952b0)
|
||||
- Fixed star/unstar status not syncing across devices (fbd0b3)
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to volunteer contributors @kovsu @huanfe1 @cscnk52 @Olexandr88 @0-o0 @kingsword09 @ericyzhu for their valuable contributions
|
||||
|
|
@ -8,4 +8,4 @@
|
|||
|
||||
## Thanks
|
||||
|
||||
Special thanks to external contributors @ for their valuable contributions
|
||||
Special thanks to volunteer contributors @ for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@
|
|||
|
||||
## Thanks
|
||||
|
||||
Special thanks to external contributors @ for their valuable contributions
|
||||
Special thanks to volunteer contributors @ for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -1,26 +1,55 @@
|
|||
import { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import type { UserConfig } from "vite"
|
||||
|
||||
import { cleanupUnnecessaryFilesPlugin } from "../plugins/vite/cleanup"
|
||||
import { createPlatformSpecificImportPlugin } from "../plugins/vite/specific-import"
|
||||
import { viteRenderBaseConfig } from "./vite.render.config"
|
||||
|
||||
const routeBuilderPluginV2 = await tsImport(
|
||||
"@follow-app/vite-plugin-route-builder",
|
||||
import.meta.url,
|
||||
).then((m) => m.default)
|
||||
|
||||
const root = resolve(fileURLToPath(dirname(import.meta.url)), "..")
|
||||
|
||||
const VITE_ROOT = resolve(root, "layer/renderer")
|
||||
export default {
|
||||
...viteRenderBaseConfig,
|
||||
|
||||
plugins: [...viteRenderBaseConfig.plugins, createPlatformSpecificImportPlugin("electron")],
|
||||
plugins: [
|
||||
...viteRenderBaseConfig.plugins,
|
||||
createPlatformSpecificImportPlugin("electron"),
|
||||
routeBuilderPluginV2({
|
||||
pagePattern: `${resolve(VITE_ROOT, "./src/pages")}/**/*.tsx`,
|
||||
outputPath: `${resolve(VITE_ROOT, "./src/generated-routes.ts")}`,
|
||||
enableInDev: true,
|
||||
}),
|
||||
cleanupUnnecessaryFilesPlugin([
|
||||
"og-image.png",
|
||||
"icon-512x512.png",
|
||||
"opengraph-image.png",
|
||||
"favicon.ico",
|
||||
"icon-192x192.png",
|
||||
"favicon-dev.ico",
|
||||
"apple-touch-icon-180x180.png",
|
||||
"maskable-icon-512x512.png",
|
||||
"pwa-64x64.png",
|
||||
"pwa-192x192.png",
|
||||
"pwa-512x512.png",
|
||||
]),
|
||||
],
|
||||
|
||||
root: resolve(root, "layer/renderer"),
|
||||
root: VITE_ROOT,
|
||||
build: {
|
||||
outDir: resolve(root, "dist/renderer"),
|
||||
sourcemap: !!process.env.CI,
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(root, "layer/renderer/index.html"),
|
||||
main: resolve(VITE_ROOT, "index.html"),
|
||||
},
|
||||
},
|
||||
minify: true,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { getGitHash } from "../../../scripts/lib"
|
|||
import { astPlugin } from "../plugins/vite/ast"
|
||||
import { circularImportRefreshPlugin } from "../plugins/vite/hmr"
|
||||
import { customI18nHmrPlugin } from "../plugins/vite/i18n-hmr"
|
||||
import { localesPlugin } from "../plugins/vite/locales"
|
||||
import { localesJsonPlugin } from "../plugins/vite/locales-json"
|
||||
import i18nCompleteness from "../plugins/vite/utils/i18n-completeness"
|
||||
|
||||
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
|
@ -33,6 +33,12 @@ const getChangelogFileContent = () => {
|
|||
|
||||
const changelogFile = getChangelogFileContent()
|
||||
export const viteRenderBaseConfig = {
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["sqlocal"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": resolve("layer/renderer/src"),
|
||||
|
|
@ -44,6 +50,21 @@ export const viteRenderBaseConfig = {
|
|||
base: "/",
|
||||
|
||||
plugins: [
|
||||
{
|
||||
name: "import-sql",
|
||||
transform(code, id) {
|
||||
if (id.endsWith(".sql")) {
|
||||
const json = JSON.stringify(code)
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029")
|
||||
|
||||
return {
|
||||
code: `export default ${json}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
localesJsonPlugin(),
|
||||
react({
|
||||
// jsxImportSource: "@welldone-software/why-did-you-render", // <-----
|
||||
}),
|
||||
|
|
@ -82,7 +103,6 @@ export const viteRenderBaseConfig = {
|
|||
},
|
||||
}),
|
||||
|
||||
localesPlugin(),
|
||||
astPlugin,
|
||||
customI18nHmrPlugin(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { resolve } from "node:path"
|
|||
import { defineConfig } from "electron-vite"
|
||||
|
||||
import { getGitHash } from "../../scripts/lib"
|
||||
import { viteRenderBaseConfig } from "./configs/vite.render.config"
|
||||
import { cleanupUnnecessaryFilesPlugin } from "./plugins/vite/cleanup"
|
||||
import { createPlatformSpecificImportPlugin } from "./plugins/vite/specific-import"
|
||||
import rendererConfig from "./configs/vite.electron-render.config"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
|
|
@ -42,43 +40,5 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
...viteRenderBaseConfig,
|
||||
|
||||
root: "layer/renderer",
|
||||
build: {
|
||||
outDir: "dist/renderer",
|
||||
sourcemap: !!process.env.CI,
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve("./layer/renderer/index.html"),
|
||||
},
|
||||
},
|
||||
minify: true,
|
||||
},
|
||||
|
||||
plugins: [
|
||||
...viteRenderBaseConfig.plugins,
|
||||
createPlatformSpecificImportPlugin("electron"),
|
||||
cleanupUnnecessaryFilesPlugin([
|
||||
"og-image.png",
|
||||
"icon-512x512.png",
|
||||
"opengraph-image.png",
|
||||
"favicon.ico",
|
||||
"icon-192x192.png",
|
||||
"favicon-dev.ico",
|
||||
"apple-touch-icon-180x180.png",
|
||||
"maskable-icon-512x512.png",
|
||||
"pwa-64x64.png",
|
||||
"pwa-192x192.png",
|
||||
"pwa-512x512.png",
|
||||
]),
|
||||
],
|
||||
|
||||
define: {
|
||||
...viteRenderBaseConfig.define,
|
||||
ELECTRON: "true",
|
||||
},
|
||||
},
|
||||
renderer: rendererConfig,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Export types for renderer to use
|
||||
export type { IpcServices } from "./src/ipc"
|
||||
export type { RendererHandlers } from "./src/renderer-handlers"
|
||||
|
||||
// Export services for potential main process use
|
||||
export { services } from "./src/ipc"
|
||||
|
|
|
|||
|
|
@ -27,20 +27,20 @@
|
|||
"@follow/shared": "workspace:*",
|
||||
"@follow/utils": "workspace:*",
|
||||
"@openpanel/web": "1.0.1",
|
||||
"@sentry/electron": "6.6.0",
|
||||
"@sentry/electron": "6.8.0",
|
||||
"builder-util-runtime": "9.3.1",
|
||||
"cookie-es": "2.0.0",
|
||||
"electron-context-menu": "4.0.5",
|
||||
"electron-log": "5.4.0",
|
||||
"electron-log": "5.4.1",
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"electron-store": "10.0.1",
|
||||
"electron-store": "10.1.0",
|
||||
"electron-updater": "6.6.2",
|
||||
"es-toolkit": "1.38.0",
|
||||
"es-toolkit": "1.39.3",
|
||||
"fast-folder-size": "2.4.0",
|
||||
"font-list": "1.5.1",
|
||||
"i18next": "25.2.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"linkedom": "0.18.10",
|
||||
"linkedom": "0.18.11",
|
||||
"lowdb": "7.0.1",
|
||||
"msedge-tts": "2.0.0",
|
||||
"node-machine-id": "1.1.12",
|
||||
|
|
@ -53,10 +53,10 @@
|
|||
"@follow/models": "workspace:*",
|
||||
"@follow/types": "workspace:*",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "22.15.23",
|
||||
"@types/node": "24.0.3",
|
||||
"electron": "35.1.5",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"hono": "4.7.10",
|
||||
"hono": "4.8.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ declare global {
|
|||
electron?: ElectronAPI
|
||||
api?: { canWindowBlur: boolean }
|
||||
platform: NodeJS.Platform
|
||||
mas: boolean
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ if (process.contextIsolated) {
|
|||
contextBridge.exposeInMainWorld("electron", electronAPI)
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
contextBridge.exposeInMainWorld("platform", process.platform)
|
||||
contextBridge.exposeInMainWorld("mas", process.mas)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
|
@ -49,6 +50,8 @@ if (process.contextIsolated) {
|
|||
window.api = api
|
||||
// @ts-ignore (define in dts)
|
||||
window.platform = process.platform
|
||||
// @ts-ignore (define in dts)
|
||||
window.mas = process.mas
|
||||
|
||||
Object.defineProperty(window.navigator, "clipboard", {
|
||||
get: () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import path from "node:path"
|
||||
|
||||
import { app, protocol } from "electron"
|
||||
|
||||
import { initializeSentry } from "./sentry"
|
||||
|
||||
if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: "sentry-ipc",
|
||||
privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
|
||||
},
|
||||
{
|
||||
scheme: "app",
|
||||
privileges: {
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
supportFetchAPI: true,
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
// Solve Sentry SDK should be initialized before the Electron app 'ready' event is fired
|
||||
initializeSentry()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { app } from "electron"
|
||||
import squirrelStartup from "electron-squirrel-startup"
|
||||
|
||||
import { DEVICE_ID } from "./constants/system"
|
||||
import { BootstrapManager } from "./manager/bootstrap"
|
||||
|
||||
console.info("[main] device id:", DEVICE_ID)
|
||||
if (squirrelStartup) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
BootstrapManager.start()
|
||||
|
|
@ -1,227 +1,2 @@
|
|||
import "./side-effects"
|
||||
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { createBuildSafeHeaders } from "@follow/utils/headers"
|
||||
import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
|
||||
import { parse } from "cookie-es"
|
||||
import { app, BrowserWindow, net, protocol, session } from "electron"
|
||||
import squirrelStartup from "electron-squirrel-startup"
|
||||
|
||||
import { DEVICE_ID } from "./constants/system"
|
||||
import { isMacOS } from "./env"
|
||||
import { initializeAppStage0, initializeAppStage1 } from "./init"
|
||||
import { updateProxy } from "./lib/proxy"
|
||||
import { handleUrlRouting } from "./lib/router"
|
||||
import { store } from "./lib/store"
|
||||
import { registerAppTray } from "./lib/tray"
|
||||
import { updateNotificationsToken } from "./lib/user"
|
||||
import { logger } from "./logger"
|
||||
import { registerUpdater } from "./updater"
|
||||
import { cleanupOldRender } from "./updater/hot-updater"
|
||||
import {
|
||||
createMainWindow,
|
||||
getMainWindow,
|
||||
getMainWindowOrCreate,
|
||||
windowStateStoreKey,
|
||||
} from "./window"
|
||||
|
||||
if (DEV) console.info("[main] env loaded:", env)
|
||||
|
||||
const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL
|
||||
|
||||
console.info("[main] device id:", DEVICE_ID)
|
||||
if (squirrelStartup) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [
|
||||
env.VITE_OPENPANEL_API_URL || "",
|
||||
IMAGE_PROXY_URL,
|
||||
env.VITE_API_URL,
|
||||
// Fix unexpected CORS error when modify the origin header to request domain in the preflight request
|
||||
// Learn more https://github.com/RSSNext/Folo/issues/3312
|
||||
"https://readwise.io",
|
||||
])
|
||||
|
||||
function bootstrap() {
|
||||
initializeAppStage0()
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow
|
||||
|
||||
initializeAppStage1()
|
||||
|
||||
app.on("second-instance", (_, commandLine) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
}
|
||||
|
||||
const url = commandLine.pop()
|
||||
if (url) {
|
||||
handleOpen(url)
|
||||
}
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
mainWindow = getMainWindowOrCreate()
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle("app", (request) => {
|
||||
try {
|
||||
const urlObj = new URL(request.url)
|
||||
return net.fetch(`file://${urlObj.pathname}`)
|
||||
} catch {
|
||||
logger.error("app protocol error", request.url)
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(`re.${LEGACY_APP_PROTOCOL}`)
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
details.requestHeaders = buildSafeHeaders({
|
||||
url: details.url,
|
||||
headers: details.requestHeaders,
|
||||
})
|
||||
|
||||
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||
})
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
|
||||
updateProxy()
|
||||
registerUpdater()
|
||||
registerAppTray()
|
||||
updateNotificationsToken()
|
||||
|
||||
app.on("open-url", (_, url) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow = createMainWindow()
|
||||
}
|
||||
url && handleOpen(url)
|
||||
})
|
||||
|
||||
// for dev debug
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
import("electron-devtools-installer").then(
|
||||
({ default: installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS }) => {
|
||||
;[REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS].forEach((extension) => {
|
||||
installExtension(extension, {
|
||||
loadExtensionOptions: { allowFileAccess: true },
|
||||
})
|
||||
.then((name) => console.info(`Added Extension: ${name}`))
|
||||
.catch((err) => console.info("An error occurred:", err))
|
||||
})
|
||||
|
||||
session.defaultSession.getAllExtensions().forEach((e) => {
|
||||
session.defaultSession.loadExtension(e.path)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
app.on("before-quit", async () => {
|
||||
// store window pos when before app quit
|
||||
const window = getMainWindow()
|
||||
if (!window || window.isDestroyed()) return
|
||||
const bounds = window.getBounds()
|
||||
|
||||
store.set(windowStateStoreKey, {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
})
|
||||
await session.defaultSession.cookies.flushStore()
|
||||
|
||||
await cleanupOldRender()
|
||||
})
|
||||
|
||||
const handleOpen = async (url: string) => {
|
||||
const isValid = URL.canParse(url)
|
||||
if (!isValid) return
|
||||
const urlObj = new URL(url)
|
||||
|
||||
if (urlObj.hostname === "auth" || urlObj.pathname === "//auth") {
|
||||
const token = urlObj.searchParams.get("token")
|
||||
|
||||
if (token) {
|
||||
await callWindowExpose(mainWindow).applyOneTimeToken(token)
|
||||
} else {
|
||||
// compatible with old version of ssr, should be removed in 0.4.4
|
||||
const ck = urlObj.searchParams.get("ck")
|
||||
const userId = urlObj.searchParams.get("userId")
|
||||
|
||||
if (ck && apiURL) {
|
||||
const cookie = parse(atob(ck), { decode: (value) => value })
|
||||
Object.keys(cookie).forEach(async (name) => {
|
||||
const value = cookie[name]
|
||||
await mainWindow.webContents.session.cookies.set({
|
||||
url: apiURL,
|
||||
name,
|
||||
value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
domain: new URL(apiURL).hostname,
|
||||
sameSite: "no_restriction",
|
||||
expirationDate: new Date().setDate(new Date().getDate() + 30),
|
||||
})
|
||||
})
|
||||
|
||||
userId && (await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId))
|
||||
mainWindow.reload()
|
||||
|
||||
updateNotificationsToken()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handleUrlRouting(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on("window-all-closed", () => {
|
||||
if (!isMacOS) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
windows.forEach((window) => window.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
import "./before-bootstrap"
|
||||
import "./bootstrap"
|
||||
|
|
|
|||
|
|
@ -1,214 +0,0 @@
|
|||
import path from "node:path"
|
||||
|
||||
import { PushReceiver } from "@eneris/push-receiver"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { APP_PROTOCOL, DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import type { MessagingData } from "@follow/shared/hono"
|
||||
import { app, nativeTheme, Notification, protocol, shell } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import { getIconPath } from "./helper"
|
||||
import { initializeIpcServices } from "./ipc"
|
||||
import { checkAndCleanCodeCache, clearCacheCronJob } from "./lib/cleaner"
|
||||
import { t } from "./lib/i18n"
|
||||
import { store } from "./lib/store"
|
||||
import { updateNotificationsToken } from "./lib/user"
|
||||
import { logger } from "./logger"
|
||||
import { registerAppMenu } from "./menu"
|
||||
import { initializeSentry } from "./sentry"
|
||||
import { getMainWindowOrCreate } from "./window"
|
||||
|
||||
if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) {
|
||||
process.env.NODE_ENV = "development"
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory and fast initializers for the app
|
||||
*/
|
||||
export function initializeAppStage0() {
|
||||
initializeSentry()
|
||||
initializeIpcServices()
|
||||
}
|
||||
export const initializeAppStage1 = () => {
|
||||
const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL]
|
||||
|
||||
for (const protocol of protocols) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1]!)])
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(protocol)
|
||||
}
|
||||
}
|
||||
|
||||
if (app.dock) {
|
||||
app.dock.setIcon(getIconPath())
|
||||
}
|
||||
|
||||
// store.set("appearance", input);
|
||||
const appearance = store.get("appearance")
|
||||
if (appearance && ["light", "dark", "system"].includes(appearance)) {
|
||||
nativeTheme.themeSource = appearance
|
||||
}
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
registerMenuAndContextMenu()
|
||||
registerPushNotifications()
|
||||
clearCacheCronJob()
|
||||
checkAndCleanCodeCache()
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: "app",
|
||||
privileges: {
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
let contextMenuDisposer: () => void
|
||||
export const registerMenuAndContextMenu = () => {
|
||||
registerAppMenu()
|
||||
if (contextMenuDisposer) {
|
||||
contextMenuDisposer()
|
||||
}
|
||||
|
||||
contextMenuDisposer = contextMenu({
|
||||
showSaveImageAs: true,
|
||||
showCopyLink: true,
|
||||
showCopyImageAddress: true,
|
||||
showCopyImage: true,
|
||||
showInspectElement: DEV,
|
||||
showSelectAll: true,
|
||||
showCopyVideoAddress: true,
|
||||
showSaveVideoAs: true,
|
||||
|
||||
labels: {
|
||||
saveImageAs: t("contextMenu.saveImageAs"),
|
||||
copyLink: t("contextMenu.copyLink"),
|
||||
copyImageAddress: t("contextMenu.copyImageAddress"),
|
||||
copyImage: t("contextMenu.copyImage"),
|
||||
copyVideoAddress: t("contextMenu.copyVideoAddress"),
|
||||
saveVideoAs: t("contextMenu.saveVideoAs"),
|
||||
inspect: t("contextMenu.inspect"),
|
||||
copy: t("contextMenu.copy"),
|
||||
cut: t("contextMenu.cut"),
|
||||
paste: t("contextMenu.paste"),
|
||||
saveImage: t("contextMenu.saveImage"),
|
||||
saveVideo: t("contextMenu.saveVideo"),
|
||||
selectAll: t("contextMenu.selectAll"),
|
||||
services: t("contextMenu.services"),
|
||||
searchWithGoogle: t("contextMenu.searchWithGoogle"),
|
||||
learnSpelling: t("contextMenu.learnSpelling"),
|
||||
lookUpSelection: t("contextMenu.lookUpSelection"),
|
||||
saveLinkAs: t("contextMenu.saveLinkAs"),
|
||||
},
|
||||
|
||||
prepend: (_defaultActions, params) => {
|
||||
return [
|
||||
{
|
||||
label: t("contextMenu.openImageInBrowser"),
|
||||
visible: params.mediaType === "image",
|
||||
click: () => {
|
||||
shell.openExternal(params.srcURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("contextMenu.openLinkInBrowser"),
|
||||
visible: params.linkURL !== "",
|
||||
click: () => {
|
||||
shell.openExternal(params.linkURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "undo",
|
||||
label: t("menu.undo"),
|
||||
accelerator: "CmdOrCtrl+Z",
|
||||
visible: params.isEditable,
|
||||
},
|
||||
{
|
||||
role: "redo",
|
||||
label: t("menu.redo"),
|
||||
accelerator: "CmdOrCtrl+Shift+Z",
|
||||
visible: params.isEditable,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const registerPushNotifications = async () => {
|
||||
if (!env.VITE_FIREBASE_CONFIG) {
|
||||
return
|
||||
}
|
||||
|
||||
const credentialsKey = "notifications-credentials"
|
||||
const persistentIdsKey = "notifications-persistent-ids"
|
||||
const credentials = store.get(credentialsKey)
|
||||
const persistentIds = store.get(persistentIdsKey)
|
||||
|
||||
const instance = new PushReceiver({
|
||||
debug: true,
|
||||
firebase: JSON.parse(env.VITE_FIREBASE_CONFIG),
|
||||
persistentIds: persistentIds || [],
|
||||
credentials: credentials || undefined,
|
||||
bundleId: "is.follow",
|
||||
chromeId: "is.follow",
|
||||
})
|
||||
logger.info(
|
||||
`PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${env.VITE_FIREBASE_CONFIG}`,
|
||||
)
|
||||
|
||||
instance.onReady(() => {
|
||||
logger.info("PushReceiver ready")
|
||||
})
|
||||
|
||||
instance.onCredentialsChanged(({ newCredentials }) => {
|
||||
logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`)
|
||||
updateNotificationsToken(newCredentials)
|
||||
})
|
||||
|
||||
instance.onNotification((notification) => {
|
||||
logger.info(`PushReceiver received notification: ${JSON.stringify(notification.message.data)}`)
|
||||
const data = notification.message.data as MessagingData
|
||||
switch (data.type) {
|
||||
case "new-entry": {
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.description,
|
||||
})
|
||||
notification.on("click", () => {
|
||||
const mainWindow = getMainWindowOrCreate()
|
||||
mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
const handlers = callWindowExpose(mainWindow)
|
||||
handlers.navigateEntry({
|
||||
feedId: data.feedId,
|
||||
entryId: data.entryId,
|
||||
view: Number.parseInt(data.view),
|
||||
})
|
||||
})
|
||||
notification.show()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
store.set(persistentIdsKey, instance.persistentIds)
|
||||
})
|
||||
|
||||
try {
|
||||
await instance.connect()
|
||||
} catch (error) {
|
||||
logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`)
|
||||
}
|
||||
|
||||
logger.info("PushReceiver connected")
|
||||
}
|
||||
|
|
@ -5,15 +5,15 @@ import { callWindowExpose } from "@follow/shared/bridge"
|
|||
import { DEV } from "@follow/shared/constants"
|
||||
import { app, BrowserWindow, clipboard, dialog } from "electron"
|
||||
|
||||
import { registerMenuAndContextMenu } from "~/init"
|
||||
import { i18n } from "~/lib/i18n"
|
||||
import { registerAppTray } from "~/lib/tray"
|
||||
import { logger } from "~/logger"
|
||||
import { AppManager } from "~/manager/app"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater"
|
||||
|
||||
import { downloadFile } from "../../lib/download"
|
||||
import { checkForAppUpdates, quitAndInstall } from "../../updater"
|
||||
import { getMainWindow } from "../../window"
|
||||
import type { IpcContext } from "../base"
|
||||
import { IpcMethod, IpcService } from "../base"
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ export class AppService extends IpcService {
|
|||
@IpcMethod()
|
||||
switchAppLocale(context: IpcContext, input: string): void {
|
||||
i18n.changeLanguage(input)
|
||||
registerMenuAndContextMenu()
|
||||
AppManager.registerMenuAndContextMenu()
|
||||
registerAppTray()
|
||||
|
||||
app.commandLine.appendSwitch("lang", input)
|
||||
|
|
@ -62,7 +62,7 @@ export class AppService extends IpcService {
|
|||
|
||||
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
|
||||
logger.info("appLoadEntry", appLoadEntry)
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
|
||||
for (const window of allWindows) {
|
||||
if (window === mainWindow) {
|
||||
|
|
|
|||
|
|
@ -1,36 +1,78 @@
|
|||
import { UNREAD_BACKGROUND_POLLING_INTERVAL } from "../../constants/app"
|
||||
import { apiClient } from "../../lib/api-client"
|
||||
import { setDockCount } from "../../lib/dock"
|
||||
import { sleep } from "../../lib/utils"
|
||||
import type { IpcContext } from "../base"
|
||||
import { IpcMethod, IpcService } from "../base"
|
||||
|
||||
const pollingMap = {
|
||||
unread: false,
|
||||
class PollingManager {
|
||||
private abortController: AbortController | null = null
|
||||
private isPolling = false
|
||||
|
||||
async startPolling(pollingFn: () => Promise<void>, interval: number): Promise<void> {
|
||||
if (this.isPolling) {
|
||||
return // Already polling, prevent duplicate instances
|
||||
}
|
||||
|
||||
this.isPolling = true
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
while (!this.abortController.signal.aborted) {
|
||||
await pollingFn()
|
||||
|
||||
// Use AbortSignal with sleep for proper cancellation
|
||||
await this.sleepWithAbortSignal(interval, this.abortController.signal)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== "AbortError") {
|
||||
console.error("Polling error:", error)
|
||||
}
|
||||
} finally {
|
||||
this.isPolling = false
|
||||
this.abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.isPolling
|
||||
}
|
||||
|
||||
private async sleepWithAbortSignal(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(resolve, ms)
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class DockService extends IpcService {
|
||||
private unreadPollingManager = new PollingManager()
|
||||
|
||||
constructor() {
|
||||
super("dock")
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async pollingUpdateUnreadCount(): Promise<void> {
|
||||
if (pollingMap.unread) {
|
||||
return
|
||||
}
|
||||
|
||||
pollingMap.unread = true
|
||||
while (pollingMap.unread) {
|
||||
await sleep(UNREAD_BACKGROUND_POLLING_INTERVAL)
|
||||
if (pollingMap.unread) {
|
||||
await this.updateUnreadCount()
|
||||
}
|
||||
}
|
||||
await this.unreadPollingManager.startPolling(
|
||||
() => this.updateUnreadCount(),
|
||||
UNREAD_BACKGROUND_POLLING_INTERVAL,
|
||||
)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async cancelPollingUpdateUnreadCount(): Promise<void> {
|
||||
pollingMap.unread = false
|
||||
this.unreadPollingManager.stopPolling()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
|
|
@ -38,4 +80,9 @@ export class DockService extends IpcService {
|
|||
const res = await apiClient.reads["total-count"].$get()
|
||||
setDockCount(res.data.count)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setDockBadge(_context: IpcContext, count: number): void {
|
||||
setDockCount(count)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export class ReaderService extends IpcService {
|
|||
if (!window) return null
|
||||
|
||||
try {
|
||||
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3)
|
||||
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, {})
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to set voice", error)
|
||||
if (error instanceof Error) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { createRequire } from "node:module"
|
|||
|
||||
import { app, nativeTheme } from "electron"
|
||||
|
||||
import { setDockCount } from "../../lib/dock"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { setProxyConfig, updateProxy } from "../../lib/proxy"
|
||||
import { store } from "../../lib/store"
|
||||
import { getTrayConfig, setTrayConfig } from "../../lib/tray"
|
||||
import { showSetting } from "../../window"
|
||||
import type { IpcContext } from "../base"
|
||||
import { IpcMethod, IpcService } from "../base"
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ export class SettingService extends IpcService {
|
|||
|
||||
@IpcMethod()
|
||||
openSettingWindow(_context: IpcContext): void {
|
||||
showSetting()
|
||||
WindowManager.showSetting()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
|
|
@ -65,11 +65,6 @@ export class SettingService extends IpcService {
|
|||
setTrayConfig(minimize)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setDockBadge(_context: IpcContext, count: number): void {
|
||||
setDockCount(count)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getProxyConfig(_context: IpcContext) {
|
||||
const proxy = store.get("proxy")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { hc } from "hono/client"
|
|||
import { ofetch } from "ofetch"
|
||||
|
||||
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
|
||||
import { getMainWindow } from "~/window"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { logger } from "../logger"
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ export const apiFetch = ofetch.create({
|
|||
export const apiClient = hc<AppType>("", {
|
||||
fetch: async (input, options = {}) => apiFetch(input.toString(), options),
|
||||
async headers() {
|
||||
const window = getMainWindow()
|
||||
const window = WindowManager.getMainWindow()
|
||||
const cookies = await window?.webContents.session.cookies.get({
|
||||
domain: new URL(env.VITE_API_URL).hostname,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { app, dialog } from "electron"
|
|||
|
||||
import { getIconPath } from "~/helper"
|
||||
import { logger } from "~/logger"
|
||||
import { getMainWindow } from "~/window"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { t } from "./i18n"
|
||||
import { store, StoreKey } from "./store"
|
||||
|
|
@ -23,7 +23,7 @@ const fastFolderSize = esModuleInterop(
|
|||
) as typeof import("fast-folder-size").default
|
||||
|
||||
export const clearAllDataAndConfirm = async () => {
|
||||
const win = getMainWindow()
|
||||
const win = WindowManager.getMainWindow()
|
||||
if (!win) return
|
||||
|
||||
// Dialog to confirm
|
||||
|
|
@ -42,7 +42,7 @@ export const clearAllDataAndConfirm = async () => {
|
|||
}
|
||||
|
||||
export const clearAllData = async () => {
|
||||
const win = getMainWindow()
|
||||
const win = WindowManager.getMainWindow()
|
||||
if (!win) return
|
||||
const ses = win.webContents.session
|
||||
const caller = callWindowExpose(win)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { extractElectronWindowOptions } from "@follow/shared/electron"
|
|||
import type { BrowserWindow } from "electron/main"
|
||||
|
||||
import { logger } from "~/logger"
|
||||
import { createMainWindow, createWindow, getMainWindow } from "~/window"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
export const handleUrlRouting = (url: string) => {
|
||||
const options = extractElectronWindowOptions(url)
|
||||
|
|
@ -84,7 +84,7 @@ export const handleUrlRouting = (url: string) => {
|
|||
|
||||
default: {
|
||||
const { height, resizable = true, width } = options || {}
|
||||
createWindow({
|
||||
WindowManager.createWindow({
|
||||
extraPath: `#${uri}`,
|
||||
width: width ?? 800,
|
||||
height: height ?? 700,
|
||||
|
|
@ -101,9 +101,9 @@ export const handleUrlRouting = (url: string) => {
|
|||
}
|
||||
|
||||
const callMainWindow = (url: string, fn: (mainWindow: BrowserWindow) => any) => {
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) {
|
||||
createMainWindow()
|
||||
WindowManager.createMainWindow()
|
||||
|
||||
return handleUrlRouting(url)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { app, Menu, nativeImage, Tray } from "electron"
|
|||
import { isMacOS, isMAS, isWindows } from "~/env"
|
||||
import { getTrayIconPath } from "~/helper"
|
||||
import { logger, revealLogFile } from "~/logger"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
import { checkForAppUpdates } from "~/updater"
|
||||
|
||||
import { getMainWindowOrCreate } from "../window"
|
||||
import { getDockCount } from "./dock"
|
||||
import { t } from "./i18n"
|
||||
import { store } from "./store"
|
||||
|
|
@ -36,14 +36,14 @@ const getTrayContextMenu = () => {
|
|||
{
|
||||
label: t("menu.reload"),
|
||||
click: () => {
|
||||
const mainWindow = getMainWindowOrCreate()
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
mainWindow.webContents.reload()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("menu.toggleDevTools"),
|
||||
click: () => {
|
||||
const mainWindow = getMainWindowOrCreate()
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
mainWindow.webContents.toggleDevTools()
|
||||
},
|
||||
},
|
||||
|
|
@ -98,7 +98,7 @@ export const registerAppTray = () => {
|
|||
}
|
||||
|
||||
const showWindow = () => {
|
||||
const mainWindow = getMainWindowOrCreate()
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,234 @@
|
|||
import path from "node:path"
|
||||
|
||||
import { PushReceiver } from "@eneris/push-receiver"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { APP_PROTOCOL, DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import type { MessagingData } from "@follow/shared/hono"
|
||||
import { app, nativeTheme, Notification, shell } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { getIconPath } from "../helper"
|
||||
import { initializeIpcServices } from "../ipc"
|
||||
import { checkAndCleanCodeCache, clearCacheCronJob } from "../lib/cleaner"
|
||||
import { t } from "../lib/i18n"
|
||||
import { updateProxy } from "../lib/proxy"
|
||||
import { store } from "../lib/store"
|
||||
import { registerAppTray } from "../lib/tray"
|
||||
import { updateNotificationsToken } from "../lib/user"
|
||||
import { logger } from "../logger"
|
||||
import { registerAppMenu } from "../menu"
|
||||
import { registerUpdater } from "../updater"
|
||||
import { LifecycleManager } from "./lifecycle"
|
||||
|
||||
class AppManagerStatic {
|
||||
private static instance: AppManagerStatic
|
||||
|
||||
public static getInstance(): AppManagerStatic {
|
||||
if (!AppManagerStatic.instance) {
|
||||
AppManagerStatic.instance = new AppManagerStatic()
|
||||
}
|
||||
return AppManagerStatic.instance
|
||||
}
|
||||
|
||||
public init() {
|
||||
initializeIpcServices()
|
||||
LifecycleManager.onReady(this.onReady.bind(this))
|
||||
}
|
||||
|
||||
private onReady() {
|
||||
this.registerProtocols()
|
||||
this.setupAppVisuals()
|
||||
this.setupSystemConfigs()
|
||||
this.runCronJobs()
|
||||
this.registerMenuAndContextMenu()
|
||||
this.registerPushNotifications()
|
||||
|
||||
updateProxy()
|
||||
registerUpdater()
|
||||
registerAppTray()
|
||||
}
|
||||
|
||||
private registerProtocols() {
|
||||
const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL]
|
||||
|
||||
for (const protocolName of protocols) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(protocolName, process.execPath, [
|
||||
path.resolve(process.argv[1]!),
|
||||
])
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(protocolName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupAppVisuals() {
|
||||
if (app.dock) {
|
||||
app.dock.setIcon(getIconPath())
|
||||
}
|
||||
}
|
||||
|
||||
private setupSystemConfigs() {
|
||||
const appearance = store.get("appearance")
|
||||
if (appearance && ["light", "dark", "system"].includes(appearance)) {
|
||||
nativeTheme.themeSource = appearance
|
||||
}
|
||||
}
|
||||
|
||||
private runCronJobs() {
|
||||
clearCacheCronJob()
|
||||
checkAndCleanCodeCache()
|
||||
}
|
||||
|
||||
private async registerPushNotifications() {
|
||||
if (!env.VITE_FIREBASE_CONFIG) {
|
||||
return
|
||||
}
|
||||
|
||||
const credentialsKey = "notifications-credentials"
|
||||
const persistentIdsKey = "notifications-persistent-ids"
|
||||
const credentials = store.get(credentialsKey)
|
||||
const persistentIds = store.get(persistentIdsKey)
|
||||
|
||||
const instance = new PushReceiver({
|
||||
debug: true,
|
||||
firebase: JSON.parse(env.VITE_FIREBASE_CONFIG),
|
||||
persistentIds: persistentIds || [],
|
||||
credentials: credentials || undefined,
|
||||
bundleId: "is.follow",
|
||||
chromeId: "is.follow",
|
||||
})
|
||||
logger.info(
|
||||
`PushReceiver initialized with credentials ${JSON.stringify(credentials)} and firebase config ${
|
||||
env.VITE_FIREBASE_CONFIG
|
||||
}`,
|
||||
)
|
||||
|
||||
instance.onReady(() => {
|
||||
logger.info("PushReceiver ready")
|
||||
})
|
||||
|
||||
instance.onCredentialsChanged(({ newCredentials }) => {
|
||||
logger.info(`PushReceiver credentials changed to ${newCredentials?.fcm?.token}`)
|
||||
updateNotificationsToken(newCredentials)
|
||||
})
|
||||
|
||||
instance.onNotification((notification) => {
|
||||
logger.info(
|
||||
`PushReceiver received notification: ${JSON.stringify(notification.message.data)}`,
|
||||
)
|
||||
const data = notification.message.data as MessagingData
|
||||
switch (data.type) {
|
||||
case "new-entry": {
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.description,
|
||||
})
|
||||
notification.on("click", () => {
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
const handlers = callWindowExpose(mainWindow)
|
||||
handlers.navigateEntry({
|
||||
feedId: data.feedId,
|
||||
entryId: data.entryId,
|
||||
view: Number.parseInt(data.view),
|
||||
})
|
||||
})
|
||||
notification.show()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
store.set(persistentIdsKey, instance.persistentIds)
|
||||
})
|
||||
|
||||
try {
|
||||
await instance.connect()
|
||||
} catch (error) {
|
||||
logger.error(`PushReceiver error: ${error instanceof Error ? error.stack : error}`)
|
||||
}
|
||||
|
||||
logger.info("PushReceiver connected")
|
||||
}
|
||||
|
||||
private contextMenuDisposer?: () => void
|
||||
public registerMenuAndContextMenu() {
|
||||
registerAppMenu()
|
||||
if (this.contextMenuDisposer) {
|
||||
this.contextMenuDisposer()
|
||||
}
|
||||
|
||||
this.contextMenuDisposer = contextMenu({
|
||||
showSaveImageAs: true,
|
||||
showCopyLink: true,
|
||||
showCopyImageAddress: true,
|
||||
showCopyImage: true,
|
||||
showInspectElement: DEV,
|
||||
showSelectAll: true,
|
||||
showCopyVideoAddress: true,
|
||||
showSaveVideoAs: true,
|
||||
|
||||
labels: {
|
||||
saveImageAs: t("contextMenu.saveImageAs"),
|
||||
copyLink: t("contextMenu.copyLink"),
|
||||
copyImageAddress: t("contextMenu.copyImageAddress"),
|
||||
copyImage: t("contextMenu.copyImage"),
|
||||
copyVideoAddress: t("contextMenu.copyVideoAddress"),
|
||||
saveVideoAs: t("contextMenu.saveVideoAs"),
|
||||
inspect: t("contextMenu.inspect"),
|
||||
copy: t("contextMenu.copy"),
|
||||
cut: t("contextMenu.cut"),
|
||||
paste: t("contextMenu.paste"),
|
||||
saveImage: t("contextMenu.saveImage"),
|
||||
saveVideo: t("contextMenu.saveVideo"),
|
||||
selectAll: t("contextMenu.selectAll"),
|
||||
services: t("contextMenu.services"),
|
||||
searchWithGoogle: t("contextMenu.searchWithGoogle"),
|
||||
learnSpelling: t("contextMenu.learnSpelling"),
|
||||
lookUpSelection: t("contextMenu.lookUpSelection"),
|
||||
saveLinkAs: t("contextMenu.saveLinkAs"),
|
||||
},
|
||||
|
||||
prepend: (_defaultActions, params) => {
|
||||
return [
|
||||
{
|
||||
label: t("contextMenu.openImageInBrowser"),
|
||||
visible: params.mediaType === "image",
|
||||
click: () => {
|
||||
shell.openExternal(params.srcURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("contextMenu.openLinkInBrowser"),
|
||||
visible: params.linkURL !== "",
|
||||
click: () => {
|
||||
shell.openExternal(params.linkURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "undo",
|
||||
label: t("menu.undo"),
|
||||
accelerator: "CmdOrCtrl+Z",
|
||||
visible: params.isEditable,
|
||||
},
|
||||
{
|
||||
role: "redo",
|
||||
label: t("menu.redo"),
|
||||
accelerator: "CmdOrCtrl+Shift+Z",
|
||||
visible: params.isEditable,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const AppManager = AppManagerStatic.getInstance()
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { electronApp, optimizer } from "@electron-toolkit/utils"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { DEV, LEGACY_APP_PROTOCOL } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { createBuildSafeHeaders } from "@follow/utils/headers"
|
||||
import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
|
||||
import { parse } from "cookie-es"
|
||||
import { app, BrowserWindow, net, protocol, session } from "electron"
|
||||
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { isMacOS } from "../env"
|
||||
import { handleUrlRouting } from "../lib/router"
|
||||
import { store } from "../lib/store"
|
||||
import { updateNotificationsToken } from "../lib/user"
|
||||
import { logger } from "../logger"
|
||||
import { cleanupOldRender } from "../updater/hot-updater"
|
||||
import { AppManager } from "./app"
|
||||
|
||||
const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL
|
||||
const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [
|
||||
env.VITE_OPENPANEL_API_URL || "",
|
||||
IMAGE_PROXY_URL,
|
||||
env.VITE_API_URL,
|
||||
"https://readwise.io",
|
||||
])
|
||||
|
||||
export class BootstrapManager {
|
||||
public static start() {
|
||||
AppManager.init()
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
this.registerAppEvents()
|
||||
}
|
||||
|
||||
private static registerAppEvents() {
|
||||
app.on("second-instance", (_, commandLine) => {
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
}
|
||||
|
||||
const url = commandLine.pop()
|
||||
if (url) {
|
||||
this.handleOpen(url)
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle("app", (request) => {
|
||||
try {
|
||||
const urlObj = new URL(request.url)
|
||||
return net.fetch(`file://${urlObj.pathname}`)
|
||||
} catch {
|
||||
logger.error("app protocol error", request.url)
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
electronApp.setAppUserModelId(`re.${LEGACY_APP_PROTOCOL}`)
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
details.requestHeaders = buildSafeHeaders({
|
||||
url: details.url,
|
||||
headers: details.requestHeaders,
|
||||
})
|
||||
|
||||
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||
})
|
||||
|
||||
WindowManager.getMainWindowOrCreate()
|
||||
|
||||
app.on("open-url", (_, url) => {
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
url && this.handleOpen(url)
|
||||
})
|
||||
|
||||
if (DEV) {
|
||||
this.installDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("before-quit", async () => {
|
||||
const window = WindowManager.getMainWindow()
|
||||
if (!window || window.isDestroyed()) return
|
||||
const bounds = window.getBounds()
|
||||
|
||||
store.set(WindowManager.windowStateStoreKey, {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
})
|
||||
await session.defaultSession.cookies.flushStore()
|
||||
await cleanupOldRender()
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (!isMacOS) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
windows.forEach((window) => window.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
private static installDevTools() {
|
||||
import("electron-devtools-installer").then(
|
||||
({ default: installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS }) => {
|
||||
;[
|
||||
REDUX_DEVTOOLS,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
{ id: "acndjpgkpaclldomagafnognkcgjignd" },
|
||||
].forEach((extension) => {
|
||||
installExtension(extension, {
|
||||
loadExtensionOptions: { allowFileAccess: true },
|
||||
})
|
||||
.then((extension) => console.info(`Added Extension: ${extension.name}`))
|
||||
.catch((err) => console.info("An error occurred:", err))
|
||||
})
|
||||
|
||||
session.defaultSession.getAllExtensions().forEach((e) => {
|
||||
session.defaultSession.loadExtension(e.path)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private static async handleOpen(url: string) {
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return
|
||||
|
||||
const isValid = URL.canParse(url)
|
||||
if (!isValid) return
|
||||
const urlObj = new URL(url)
|
||||
|
||||
if (urlObj.hostname === "auth" || urlObj.pathname === "//auth") {
|
||||
const token = urlObj.searchParams.get("token")
|
||||
|
||||
if (token) {
|
||||
await callWindowExpose(mainWindow).applyOneTimeToken(token)
|
||||
} else {
|
||||
const ck = urlObj.searchParams.get("ck")
|
||||
const userId = urlObj.searchParams.get("userId")
|
||||
|
||||
if (ck && apiURL) {
|
||||
const cookie = parse(atob(ck), { decode: (value) => value })
|
||||
Object.keys(cookie).forEach(async (name) => {
|
||||
const value = cookie[name]!
|
||||
await mainWindow.webContents.session.cookies.set({
|
||||
url: apiURL,
|
||||
name,
|
||||
value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
domain: new URL(apiURL).hostname,
|
||||
sameSite: "no_restriction",
|
||||
expirationDate: new Date().setDate(new Date().getDate() + 30),
|
||||
})
|
||||
})
|
||||
|
||||
if (userId) {
|
||||
await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId)
|
||||
}
|
||||
mainWindow.reload()
|
||||
|
||||
updateNotificationsToken()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handleUrlRouting(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { app } from "electron"
|
||||
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
class LifecycleManagerStatic {
|
||||
private static instance: LifecycleManagerStatic
|
||||
|
||||
private constructor() {
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
public static getInstance(): LifecycleManagerStatic {
|
||||
if (!LifecycleManagerStatic.instance) {
|
||||
LifecycleManagerStatic.instance = new LifecycleManagerStatic()
|
||||
}
|
||||
return LifecycleManagerStatic.instance
|
||||
}
|
||||
|
||||
private registerListeners() {
|
||||
app.on("window-all-closed", this.onWindowAllClosed.bind(this))
|
||||
app.on("activate", this.onActivate.bind(this))
|
||||
}
|
||||
|
||||
private onWindowAllClosed() {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
|
||||
private onActivate() {
|
||||
const mainWindow = WindowManager.getMainWindowOrCreate()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
public onReady(callback: () => void) {
|
||||
if (app.isReady()) {
|
||||
callback()
|
||||
} else {
|
||||
app.on("ready", callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LifecycleManager = LifecycleManagerStatic.getInstance()
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { is } from "@electron-toolkit/utils"
|
||||
import { LEGACY_APP_PROTOCOL } from "@follow/shared"
|
||||
import { callWindowExpose, WindowState } from "@follow/shared/bridge"
|
||||
import { APP_PROTOCOL, DEV } from "@follow/shared/constants"
|
||||
import type { BrowserWindowConstructorOptions } from "electron"
|
||||
import { app, BrowserWindow, screen, shell } from "electron"
|
||||
import type { Event } from "electron/main"
|
||||
|
||||
import { START_IN_TRAY_ARGS } from "~/constants/app"
|
||||
import { isMacOS, isWindows, isWindows11 } from "~/env"
|
||||
import { filePathToAppUrl, getIconPath } from "~/helper"
|
||||
import { t } from "~/lib/i18n"
|
||||
import { store } from "~/lib/store"
|
||||
import { getTrayConfig } from "~/lib/tray"
|
||||
import { refreshBound } from "~/lib/utils"
|
||||
import { logger } from "~/logger"
|
||||
import { loadDynamicRenderEntry } from "~/updater/hot-updater"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
|
||||
class WindowManagerStatic {
|
||||
static readonly mainWindowDefaultSize = {
|
||||
height: 900,
|
||||
width: 1600,
|
||||
}
|
||||
|
||||
// Window configuration properties for better DX
|
||||
private readonly config = {
|
||||
windowStateStoreKey: "windowState",
|
||||
minWindowSize: {
|
||||
width: 1024,
|
||||
height: 500,
|
||||
},
|
||||
macOSTrafficLight: {
|
||||
x: 18,
|
||||
y: 18,
|
||||
},
|
||||
refreshBoundDelay: 1000,
|
||||
devToolsFont: {
|
||||
family:
|
||||
'consolas, operator mono, Cascadia Code, OperatorMonoSSmLig Nerd Font, "Agave Nerd Font", "Cascadia Code PL", monospace',
|
||||
size: "13px",
|
||||
},
|
||||
ignoreProtocols: [
|
||||
"http",
|
||||
"https",
|
||||
LEGACY_APP_PROTOCOL,
|
||||
APP_PROTOCOL,
|
||||
"file",
|
||||
"code",
|
||||
"cursor",
|
||||
"app",
|
||||
] as const,
|
||||
vibrancy: {
|
||||
macOS: {
|
||||
type: "sidebar" as const,
|
||||
state: "followWindow" as const,
|
||||
},
|
||||
},
|
||||
windowPreferences: {
|
||||
preloadScript: path.join(__dirname, "../preload/index.mjs"),
|
||||
},
|
||||
} as const
|
||||
|
||||
readonly windowStateStoreKey = this.config.windowStateStoreKey
|
||||
|
||||
private windows = {
|
||||
mainWindow: null as BrowserWindow | null,
|
||||
}
|
||||
|
||||
private bindEvents(window: BrowserWindow) {
|
||||
window.on("leave-html-full-screen", () => {
|
||||
// To solve the vibrancy losing issue when leaving full screen mode
|
||||
// @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157
|
||||
refreshBound(window)
|
||||
refreshBound(window, this.config.refreshBoundDelay)
|
||||
})
|
||||
|
||||
window.on("ready-to-show", () => {
|
||||
const shouldShowWindow =
|
||||
!app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS)
|
||||
if (shouldShowWindow) window.show()
|
||||
})
|
||||
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: "deny" }
|
||||
})
|
||||
|
||||
const handleExternalProtocol = async (e: Event, url: string, window: BrowserWindow) => {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
if (this.config.ignoreProtocols.includes(protocol.slice(0, -1) as any)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
const caller = callWindowExpose(window)
|
||||
const confirm = await caller.dialog.ask({
|
||||
title: t("dialog.openExternalApp.title"),
|
||||
message: t("dialog.openExternalApp.message", {
|
||||
url,
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
confirmText: t("dialog.open"),
|
||||
cancelText: t("dialog.cancel"),
|
||||
})
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
shell.openExternal(url)
|
||||
}
|
||||
|
||||
// Handle main window external links
|
||||
window.webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
|
||||
|
||||
// Handle webview external links
|
||||
window.webContents.on("did-attach-webview", (_, webContents) => {
|
||||
webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
// Change the default font-family and font-size of the devtools.
|
||||
// Make it consistent with Chrome on Windows, instead of SimSun.
|
||||
// ref: [[Feature Request]: Add possibility to change DevTools font · Issue #42055 · electron/electron](https://github.com/electron/electron/issues/42055)
|
||||
window.webContents.on("devtools-opened", () => {
|
||||
this.setupDevToolsFont(window)
|
||||
})
|
||||
}
|
||||
|
||||
this.bindWindowStateEvents(window)
|
||||
}
|
||||
|
||||
private setupDevToolsFont(window: BrowserWindow) {
|
||||
// source-code-font: For code such as Elements panel
|
||||
// monospace-font: For sidebar such as Event Listener Panel
|
||||
const css = `:root {--devtool-font-family: ${this.config.devToolsFont.family};--source-code-font-family:var(--devtool-font-family);--source-code-font-size: ${this.config.devToolsFont.size};--monospace-font-family: var(--devtool-font-family);--monospace-font-size: ${this.config.devToolsFont.size};}`
|
||||
const js = `
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.innerHTML = '${css.replaceAll("\n", " ")}';
|
||||
document.body.append(overriddenStyle);
|
||||
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
|
||||
addStyleToAutoComplete();
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
const item = mutation.addedNodes[i];
|
||||
if (item instanceof HTMLElement && item.classList.contains('editor-tooltip-host')) {
|
||||
addStyleToAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {childList: true});
|
||||
function addStyleToAutoComplete() {
|
||||
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
|
||||
if (element.shadowRoot && element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
|
||||
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
|
||||
element.shadowRoot.append(overriddenStyle);
|
||||
}
|
||||
});
|
||||
}
|
||||
`
|
||||
window.webContents.devToolsWebContents?.executeJavaScript(js)
|
||||
}
|
||||
|
||||
private bindWindowStateEvents(window: BrowserWindow) {
|
||||
// async render and main state
|
||||
window.on("maximize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.MAXIMIZED)
|
||||
})
|
||||
|
||||
window.on("unmaximize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.NORMAL)
|
||||
})
|
||||
|
||||
window.on("minimize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.MINIMIZED)
|
||||
})
|
||||
|
||||
window.on("restore", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.NORMAL)
|
||||
})
|
||||
}
|
||||
|
||||
private bindMainWindowCloseHandlers(window: BrowserWindow) {
|
||||
window.on("close", () => {
|
||||
if (isWindows11) {
|
||||
const windowStoreKey = Symbol.for("maximized")
|
||||
if (window[windowStoreKey]) {
|
||||
const stored = window[windowStoreKey]
|
||||
store.set(this.windowStateStoreKey, {
|
||||
width: stored.size[0],
|
||||
height: stored.size[1],
|
||||
x: stored.position[0],
|
||||
y: stored.position[1],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = window.getBounds()
|
||||
store.set(this.windowStateStoreKey, {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
})
|
||||
})
|
||||
|
||||
window.on("close", (event) => {
|
||||
const minimizeToTray = getTrayConfig()
|
||||
if (isMacOS || minimizeToTray) {
|
||||
event.preventDefault()
|
||||
if (window.isFullScreen()) {
|
||||
window.once("leave-full-screen", () => {
|
||||
window.hide()
|
||||
})
|
||||
window.setFullScreen(false)
|
||||
} else {
|
||||
window.hide()
|
||||
}
|
||||
|
||||
const caller = callWindowExpose(window)
|
||||
caller.onWindowClose()
|
||||
} else {
|
||||
this.windows.mainWindow = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getPlatformSpecificWindowConfig(): Partial<BrowserWindowConstructorOptions> {
|
||||
const { platform } = process
|
||||
|
||||
switch (platform) {
|
||||
case "darwin": {
|
||||
return {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: {
|
||||
x: this.config.macOSTrafficLight.x,
|
||||
y: this.config.macOSTrafficLight.y,
|
||||
},
|
||||
vibrancy: this.config.vibrancy.macOS.type,
|
||||
visualEffectState: this.config.vibrancy.macOS.state,
|
||||
transparent: true,
|
||||
}
|
||||
}
|
||||
|
||||
case "win32": {
|
||||
return {
|
||||
icon: getIconPath(),
|
||||
titleBarStyle: "hidden",
|
||||
// Electron material bug, comment this for now
|
||||
// backgroundMaterial: isWindows11 ? "mica" : undefined,
|
||||
frame: true,
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return {
|
||||
icon: getIconPath(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWindow = (
|
||||
options: {
|
||||
extraPath?: string
|
||||
height: number
|
||||
width: number
|
||||
} & BrowserWindowConstructorOptions,
|
||||
) => {
|
||||
const { extraPath, height, width, ...configs } = options
|
||||
|
||||
const baseWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
width,
|
||||
height,
|
||||
show: false,
|
||||
resizable: configs?.resizable ?? true,
|
||||
autoHideMenuBar: true,
|
||||
alwaysOnTop: false,
|
||||
webPreferences: {
|
||||
preload: this.config.windowPreferences.preloadScript,
|
||||
sandbox: false,
|
||||
webviewTag: true,
|
||||
webSecurity: !DEV,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
},
|
||||
...this.getPlatformSpecificWindowConfig(),
|
||||
}
|
||||
|
||||
// Create the browser window.
|
||||
const window = new BrowserWindow({
|
||||
...baseWindowConfig,
|
||||
...configs,
|
||||
})
|
||||
|
||||
this.bindEvents(window)
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
window.loadURL(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
|
||||
logger.log(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
|
||||
} else {
|
||||
// Production entry
|
||||
const dynamicRenderEntry = loadDynamicRenderEntry()
|
||||
logger.info("load dynamic render entry", dynamicRenderEntry)
|
||||
const appLoadFileEntry =
|
||||
dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
|
||||
|
||||
const appLoadEntry = `${filePathToAppUrl(appLoadFileEntry)}${options?.extraPath || ""}`
|
||||
|
||||
window.loadURL(appLoadEntry)
|
||||
logger.log("load URL", appLoadEntry)
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
private ensureWindowBoundsInScreen(windowState?: {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
}) {
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { workArea } = primaryDisplay
|
||||
|
||||
const maxWidth = workArea.width
|
||||
const maxHeight = workArea.height
|
||||
|
||||
const defaultSize = WindowManagerStatic.mainWindowDefaultSize
|
||||
|
||||
const width = Math.min(windowState?.width || defaultSize.width, maxWidth)
|
||||
const height = Math.min(windowState?.height || defaultSize.height, maxHeight)
|
||||
|
||||
const ensureInBounds = (value: number, min: number, max: number): number => {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
const x =
|
||||
windowState?.x !== undefined
|
||||
? ensureInBounds(windowState.x, workArea.x, workArea.x + workArea.width - width)
|
||||
: undefined
|
||||
|
||||
const y =
|
||||
windowState?.y !== undefined
|
||||
? ensureInBounds(windowState.y, workArea.y, workArea.y + workArea.height - height)
|
||||
: undefined
|
||||
|
||||
return { width, height, x, y, maxWidth, maxHeight }
|
||||
}
|
||||
|
||||
createMainWindow = () => {
|
||||
const windowState = store.get(this.windowStateStoreKey) as
|
||||
| {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
| undefined
|
||||
const { width, height, x, y, maxWidth, maxHeight } =
|
||||
this.ensureWindowBoundsInScreen(windowState)
|
||||
|
||||
const window = this.createWindow({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
minWidth: Math.min(this.config.minWindowSize.width, maxWidth),
|
||||
minHeight: Math.min(this.config.minWindowSize.height, maxHeight),
|
||||
})
|
||||
|
||||
this.bindMainWindowCloseHandlers(window)
|
||||
|
||||
this.windows.mainWindow = window
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
showSetting = (path?: string) => {
|
||||
// We need to open the setting modal in the main window when the main window exists,
|
||||
// if we open a new window then the state between the two windows will be out of sync.
|
||||
if (this.windows.mainWindow) {
|
||||
if (this.windows.mainWindow.isMinimized()) {
|
||||
this.windows.mainWindow.restore()
|
||||
}
|
||||
this.windows.mainWindow.show()
|
||||
|
||||
callWindowExpose(this.windows.mainWindow).showSetting(path)
|
||||
return
|
||||
} else {
|
||||
this.windows.mainWindow = this.createMainWindow()
|
||||
this.windows.mainWindow.show()
|
||||
callWindowExpose(this.windows.mainWindow).showSetting(path)
|
||||
}
|
||||
}
|
||||
|
||||
getMainWindow = () => this.windows.mainWindow
|
||||
|
||||
getMainWindowOrCreate = () => {
|
||||
if (!this.windows.mainWindow) {
|
||||
return this.createMainWindow()
|
||||
}
|
||||
return this.windows.mainWindow
|
||||
}
|
||||
|
||||
destroyMainWindow = () => {
|
||||
this.windows.mainWindow?.destroy()
|
||||
this.windows.mainWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
export const WindowManager = new WindowManagerStatic()
|
||||
|
|
@ -9,8 +9,8 @@ import { isMacOS, isMAS } from "./env"
|
|||
import { clearAllDataAndConfirm } from "./lib/cleaner"
|
||||
import { t } from "./lib/i18n"
|
||||
import { revealLogFile } from "./logger"
|
||||
import { WindowManager } from "./manager/window"
|
||||
import { checkForAppUpdates, quitAndInstall } from "./updater"
|
||||
import { createWindow, getMainWindow, showSetting } from "./window"
|
||||
|
||||
export const registerAppMenu = () => {
|
||||
const menus: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
|
|
@ -23,14 +23,14 @@ export const registerAppMenu = () => {
|
|||
type: "normal",
|
||||
label: t("menu.about", { name }),
|
||||
click: () => {
|
||||
showSetting("about")
|
||||
WindowManager.showSetting("about")
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("menu.settings"),
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => showSetting(),
|
||||
click: () => WindowManager.showSetting(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services", label: t("menu.services") },
|
||||
|
|
@ -57,7 +57,7 @@ export const registerAppMenu = () => {
|
|||
label: t("menu.quickAdd"),
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
const caller = callWindowExpose(mainWindow)
|
||||
|
|
@ -70,7 +70,7 @@ export const registerAppMenu = () => {
|
|||
label: t("menu.discover"),
|
||||
accelerator: "CmdOrCtrl+T",
|
||||
click: () => {
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
|
||||
|
|
@ -161,9 +161,9 @@ export const registerAppMenu = () => {
|
|||
{
|
||||
label: "Always on top",
|
||||
type: "checkbox",
|
||||
checked: getMainWindow()?.isAlwaysOnTop(),
|
||||
checked: WindowManager.getMainWindow()?.isAlwaysOnTop(),
|
||||
click: () => {
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return
|
||||
mainWindow.setAlwaysOnTop(!mainWindow.isAlwaysOnTop())
|
||||
registerAppMenu()
|
||||
|
|
@ -186,7 +186,7 @@ export const registerAppMenu = () => {
|
|||
{
|
||||
label: t("menu.checkForUpdates"),
|
||||
click: async () => {
|
||||
getMainWindow()?.show()
|
||||
WindowManager.getMainWindow()?.show()
|
||||
await checkForAppUpdates()
|
||||
},
|
||||
},
|
||||
|
|
@ -203,7 +203,7 @@ export const registerAppMenu = () => {
|
|||
{
|
||||
label: t("menu.followReleases"),
|
||||
click: () => {
|
||||
createWindow({
|
||||
WindowManager.createWindow({
|
||||
extraPath: `#add?url=${encodeURIComponent(
|
||||
"https://github.com/RSSNext/follow/releases.atom",
|
||||
)}`,
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export type RendererHandlers = {
|
||||
invalidateQuery: (key: (string | number | undefined)[]) => void
|
||||
updateDownloaded: () => void
|
||||
navigateEntry: (options: { feedId: string; entryId: string; view: number }) => void
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { captureConsoleIntegration, init, setTag } from "@sentry/electron/main"
|
||||
import { app } from "electron"
|
||||
import { FetchError } from "ofetch"
|
||||
|
||||
import { DEVICE_ID } from "./constants/system"
|
||||
|
||||
export const initializeSentry = async () => {
|
||||
const { captureConsoleIntegration, init, setTag } = await import("@sentry/electron/main")
|
||||
export const initializeSentry = () => {
|
||||
init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
integrations: [
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import path from "node:path"
|
||||
|
||||
import { DEV } from "@follow/shared"
|
||||
import { app } from "electron"
|
||||
|
||||
if (DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
|
||||
|
|
@ -14,7 +14,7 @@ import { load } from "js-yaml"
|
|||
import { x } from "tar"
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app"
|
||||
import { getMainWindow } from "~/window"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
|
||||
import { appUpdaterConfig } from "./configs"
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ export const hotUpdateRender = async (manifest: Manifest) => {
|
|||
)
|
||||
logger.info(`Hot update render success, update to ${manifest.version}`)
|
||||
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return false
|
||||
const caller = callWindowExpose(mainWindow)
|
||||
caller.readyToUpdate()
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { DEV } from "@follow/shared/constants"
|
|||
import { autoUpdater as defaultAutoUpdater } from "electron-updater"
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from "~/constants/app"
|
||||
import { WindowManager } from "~/manager/window"
|
||||
import { canUpdateRender, CanUpdateRenderState, hotUpdateRender } from "~/updater/hot-updater"
|
||||
|
||||
import { channel, isWindows } from "../env"
|
||||
import { logger } from "../logger"
|
||||
import { destroyMainWindow, getMainWindow } from "../window"
|
||||
import { appUpdaterConfig } from "./configs"
|
||||
import { CustomGitHubProvider } from "./custom-github-provider"
|
||||
import { WindowsUpdater } from "./windows-updater"
|
||||
|
|
@ -19,10 +19,9 @@ const disabled = !appUpdaterConfig.enableAppUpdate
|
|||
const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater
|
||||
|
||||
export const quitAndInstall = () => {
|
||||
const mainWindow = getMainWindow()
|
||||
|
||||
destroyMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
logger.info("Quit and install update, close main window, ", mainWindow?.id)
|
||||
WindowManager.destroyMainWindow()
|
||||
|
||||
setTimeout(() => {
|
||||
logger.info("Window is closed, quit and install update")
|
||||
|
|
@ -142,7 +141,7 @@ export const registerUpdater = async () => {
|
|||
downloading = false
|
||||
logger.info("Update downloaded, ready to install")
|
||||
|
||||
const mainWindow = getMainWindow()
|
||||
const mainWindow = WindowManager.getMainWindow()
|
||||
if (!mainWindow) return
|
||||
const handlers = callWindowExpose(mainWindow)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,365 +0,0 @@
|
|||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { is } from "@electron-toolkit/utils"
|
||||
import { LEGACY_APP_PROTOCOL } from "@follow/shared"
|
||||
import { callWindowExpose, WindowState } from "@follow/shared/bridge"
|
||||
import { APP_PROTOCOL, DEV } from "@follow/shared/constants"
|
||||
import type { BrowserWindowConstructorOptions } from "electron"
|
||||
import { app, BrowserWindow, screen, shell } from "electron"
|
||||
import type { Event } from "electron/main"
|
||||
|
||||
import { START_IN_TRAY_ARGS } from "./constants/app"
|
||||
import { isMacOS, isWindows, isWindows11 } from "./env"
|
||||
import { filePathToAppUrl, getIconPath } from "./helper"
|
||||
import { services } from "./ipc"
|
||||
import { t } from "./lib/i18n"
|
||||
import { store } from "./lib/store"
|
||||
import { getTrayConfig } from "./lib/tray"
|
||||
import { refreshBound } from "./lib/utils"
|
||||
import { logger } from "./logger"
|
||||
import { loadDynamicRenderEntry } from "./updater/hot-updater"
|
||||
|
||||
const windows = {
|
||||
mainWindow: null as BrowserWindow | null,
|
||||
}
|
||||
globalThis["windows"] = windows
|
||||
const { platform } = process
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
export function createWindow(
|
||||
options: {
|
||||
extraPath?: string
|
||||
height: number
|
||||
width: number
|
||||
} & BrowserWindowConstructorOptions,
|
||||
) {
|
||||
const { extraPath, height, width, ...configs } = options
|
||||
|
||||
const baseWindowConfig: Electron.BrowserWindowConstructorOptions = {
|
||||
width,
|
||||
height,
|
||||
show: false,
|
||||
resizable: configs?.resizable ?? true,
|
||||
autoHideMenuBar: true,
|
||||
alwaysOnTop: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.mjs"),
|
||||
sandbox: false,
|
||||
webviewTag: true,
|
||||
webSecurity: !DEV,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case "darwin": {
|
||||
Object.assign(baseWindowConfig, {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: {
|
||||
x: 18,
|
||||
y: 18,
|
||||
},
|
||||
vibrancy: "sidebar",
|
||||
visualEffectState: "followWindow",
|
||||
transparent: true,
|
||||
} as Electron.BrowserWindowConstructorOptions)
|
||||
break
|
||||
}
|
||||
|
||||
case "win32": {
|
||||
Object.assign(baseWindowConfig, {
|
||||
icon: getIconPath(),
|
||||
|
||||
titleBarStyle: "hidden",
|
||||
// Electron material bug, comment this for now
|
||||
// backgroundMaterial: isWindows11 ? "mica" : undefined,
|
||||
|
||||
frame: true,
|
||||
} as Electron.BrowserWindowConstructorOptions)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
baseWindowConfig.icon = getIconPath()
|
||||
}
|
||||
}
|
||||
|
||||
// Create the browser window.
|
||||
const window = new BrowserWindow({
|
||||
...baseWindowConfig,
|
||||
...configs,
|
||||
})
|
||||
|
||||
window.on("leave-html-full-screen", () => {
|
||||
// To solve the vibrancy losing issue when leaving full screen mode
|
||||
// @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157
|
||||
refreshBound(window)
|
||||
refreshBound(window, 1000)
|
||||
})
|
||||
|
||||
window.on("ready-to-show", () => {
|
||||
const shouldShowWindow =
|
||||
!app.getLoginItemSettings().wasOpenedAsHidden && !process.argv.includes(START_IN_TRAY_ARGS)
|
||||
if (shouldShowWindow) window.show()
|
||||
})
|
||||
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: "deny" }
|
||||
})
|
||||
|
||||
const handleExternalProtocol = async (e: Event, url: string, window: BrowserWindow) => {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
const ignoreProtocols = [
|
||||
"http",
|
||||
"https",
|
||||
LEGACY_APP_PROTOCOL,
|
||||
APP_PROTOCOL,
|
||||
"file",
|
||||
"code",
|
||||
"cursor",
|
||||
"app",
|
||||
]
|
||||
if (ignoreProtocols.includes(protocol.slice(0, -1))) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
const caller = callWindowExpose(window)
|
||||
const confirm = await caller.dialog.ask({
|
||||
title: t("dialog.openExternalApp.title"),
|
||||
message: t("dialog.openExternalApp.message", { url, interpolation: { escapeValue: false } }),
|
||||
confirmText: t("dialog.open"),
|
||||
cancelText: t("dialog.cancel"),
|
||||
})
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
shell.openExternal(url)
|
||||
}
|
||||
|
||||
// Handle main window external links
|
||||
window.webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
|
||||
|
||||
// Handle webview external links
|
||||
window.webContents.on("did-attach-webview", (_, webContents) => {
|
||||
webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window))
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
window.loadURL(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
|
||||
|
||||
logger.log(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || ""))
|
||||
} else {
|
||||
// Production entry
|
||||
const dynamicRenderEntry = loadDynamicRenderEntry()
|
||||
logger.info("load dynamic render entry", dynamicRenderEntry)
|
||||
const appLoadFileEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
|
||||
|
||||
const appLoadEntry = `${filePathToAppUrl(appLoadFileEntry)}${options?.extraPath || ""}`
|
||||
|
||||
window.loadURL(appLoadEntry)
|
||||
logger.log("load URL", appLoadEntry)
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// Change the default font-family and font-size of the devtools.
|
||||
// Make it consistent with Chrome on Windows, instead of SimSun.
|
||||
// ref: [[Feature Request]: Add possibility to change DevTools font · Issue #42055 · electron/electron](https://github.com/electron/electron/issues/42055)
|
||||
window.webContents.on("devtools-opened", () => {
|
||||
// source-code-font: For code such as Elements panel
|
||||
// monospace-font: For sidebar such as Event Listener Panel
|
||||
const css = `:root {--devtool-font-family: consolas, operator mono, Cascadia Code, OperatorMonoSSmLig Nerd Font,"Agave Nerd Font","Cascadia Code PL", monospace;--source-code-font-family:var(--devtool-font-family);--source-code-font-size: 13px;--monospace-font-family: var(--devtool-font-family);--monospace-font-size: 13px;}`
|
||||
window.webContents.devToolsWebContents?.executeJavaScript(`
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.innerHTML = '${css.replaceAll("\n", " ")}';
|
||||
document.body.append(overriddenStyle);
|
||||
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
|
||||
addStyleToAutoComplete();
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
const item = mutation.addedNodes[i];
|
||||
if (item.classList.contains('editor-tooltip-host')) {
|
||||
addStyleToAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {childList: true});
|
||||
function addStyleToAutoComplete() {
|
||||
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
|
||||
if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
|
||||
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
|
||||
element.shadowRoot.append(overriddenStyle);
|
||||
}
|
||||
});
|
||||
}
|
||||
`)
|
||||
})
|
||||
}
|
||||
|
||||
// async render and main state
|
||||
window.on("maximize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.MAXIMIZED)
|
||||
})
|
||||
|
||||
window.on("unmaximize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.NORMAL)
|
||||
})
|
||||
|
||||
window.on("minimize", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.MINIMIZED)
|
||||
})
|
||||
|
||||
window.on("restore", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
await caller.setWindowState(WindowState.NORMAL)
|
||||
})
|
||||
|
||||
return window
|
||||
}
|
||||
export const windowStateStoreKey = "windowState"
|
||||
export const createMainWindow = () => {
|
||||
const windowState = store.get(windowStateStoreKey)
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { workArea } = primaryDisplay
|
||||
|
||||
const maxWidth = workArea.width
|
||||
const maxHeight = workArea.height
|
||||
|
||||
const width = Math.min(windowState?.width || 1200, maxWidth)
|
||||
const height = Math.min(windowState?.height || 900, maxHeight)
|
||||
|
||||
const ensureInBounds = (value: number, min: number, max: number): number => {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
const x =
|
||||
windowState?.x !== undefined
|
||||
? ensureInBounds(windowState.x, workArea.x, workArea.x + workArea.width - width)
|
||||
: undefined
|
||||
|
||||
const y =
|
||||
windowState?.y !== undefined
|
||||
? ensureInBounds(windowState.y, workArea.y, workArea.y + workArea.height - height)
|
||||
: undefined
|
||||
|
||||
const window = createWindow({
|
||||
width: windowState?.width || 1200,
|
||||
height: windowState?.height || 900,
|
||||
x,
|
||||
y,
|
||||
minWidth: Math.min(1024, maxWidth),
|
||||
minHeight: Math.min(500, maxHeight),
|
||||
})
|
||||
|
||||
window.on("close", () => {
|
||||
if (isWindows11) {
|
||||
const windowStoreKey = Symbol.for("maximized")
|
||||
if (window[windowStoreKey]) {
|
||||
const stored = window[windowStoreKey]
|
||||
store.set(windowStateStoreKey, {
|
||||
width: stored.size[0],
|
||||
height: stored.size[1],
|
||||
x: stored.position[0],
|
||||
y: stored.position[1],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = window.getBounds()
|
||||
store.set(windowStateStoreKey, {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
})
|
||||
})
|
||||
|
||||
windows.mainWindow = window
|
||||
|
||||
window.on("close", (event) => {
|
||||
const minimizeToTray = getTrayConfig()
|
||||
if (isMacOS || minimizeToTray) {
|
||||
event.preventDefault()
|
||||
if (window.isFullScreen()) {
|
||||
window.once("leave-full-screen", () => {
|
||||
window.hide()
|
||||
})
|
||||
window.setFullScreen(false)
|
||||
} else {
|
||||
window.hide()
|
||||
}
|
||||
|
||||
const caller = callWindowExpose(window)
|
||||
caller.onWindowClose()
|
||||
} else {
|
||||
windows.mainWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
window.on("show", () => {
|
||||
services.dock.pollingUpdateUnreadCount()
|
||||
|
||||
const caller = callWindowExpose(window)
|
||||
|
||||
caller.onWindowShow()
|
||||
})
|
||||
|
||||
window.on("hide", async () => {
|
||||
const caller = callWindowExpose(window)
|
||||
const settings = await caller.getUISettings()
|
||||
|
||||
if (settings?.showDockBadge) {
|
||||
services.dock.pollingUpdateUnreadCount()
|
||||
}
|
||||
})
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
export const showSetting = (path?: string) => {
|
||||
// We need to open the setting modal in the main window when the main window exists,
|
||||
// if we open a new window then the state between the two windows will be out of sync.
|
||||
if (windows.mainWindow) {
|
||||
if (windows.mainWindow.isMinimized()) {
|
||||
windows.mainWindow.restore()
|
||||
}
|
||||
windows.mainWindow.show()
|
||||
|
||||
callWindowExpose(windows.mainWindow).showSetting(path)
|
||||
return
|
||||
} else {
|
||||
windows.mainWindow = createMainWindow()
|
||||
windows.mainWindow.show()
|
||||
callWindowExpose(windows.mainWindow).showSetting(path)
|
||||
}
|
||||
}
|
||||
|
||||
export const getMainWindow = () => windows.mainWindow
|
||||
|
||||
export const getMainWindowOrCreate = () => {
|
||||
if (!windows.mainWindow) {
|
||||
return createMainWindow()
|
||||
}
|
||||
return windows.mainWindow
|
||||
}
|
||||
|
||||
export const destroyMainWindow = () => {
|
||||
windows.mainWindow?.destroy()
|
||||
windows.mainWindow = null
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ declare global {
|
|||
electron?: ElectronAPI
|
||||
api?: { canWindowBlur: boolean }
|
||||
platform: NodeJS.Platform
|
||||
mas: boolean
|
||||
}
|
||||
export const APP_NAME = "Folo"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@
|
|||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@electron-toolkit/preload": "3.0.2",
|
||||
"@follow/database": "workspace:*",
|
||||
"@follow/electron-main": "workspace:*",
|
||||
"@follow/shared": "workspace:*",
|
||||
"@follow/store": "workspace:*",
|
||||
"@follow/tracker": "workspace:*",
|
||||
"@fontsource/sn-pro": "5.2.5",
|
||||
"@headlessui/react": "2.2.4",
|
||||
"@hookform/resolvers": "4.1.3",
|
||||
"@lottiefiles/dotlottie-react": "0.13.5",
|
||||
"@hookform/resolvers": "5.1.1",
|
||||
"@lottiefiles/dotlottie-react": "0.14.1",
|
||||
"@openpanel/web": "1.0.1",
|
||||
"@radix-ui/react-avatar": "1.1.10",
|
||||
"@radix-ui/react-context-menu": "2.2.15",
|
||||
|
|
@ -33,13 +34,13 @@
|
|||
"@radix-ui/react-popover": "1.1.14",
|
||||
"@radix-ui/react-slider": "1.3.5",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@sentry/react": "9.22.0",
|
||||
"@shikijs/transformers": "3.4.2",
|
||||
"@tanstack/query-sync-storage-persister": "5.77.2",
|
||||
"@tanstack/react-query": "5.77.2",
|
||||
"@tanstack/react-query-devtools": "5.77.2",
|
||||
"@tanstack/react-query-persist-client": "5.77.2",
|
||||
"@tanstack/react-virtual": "3.13.9",
|
||||
"@sentry/react": "9.30.0",
|
||||
"@shikijs/transformers": "3.7.0",
|
||||
"@tanstack/query-sync-storage-persister": "5.80.10",
|
||||
"@tanstack/react-query": "5.80.10",
|
||||
"@tanstack/react-query-devtools": "5.80.10",
|
||||
"@tanstack/react-query-persist-client": "5.80.10",
|
||||
"@tanstack/react-virtual": "3.13.10",
|
||||
"@use-gesture/react": "10.3.1",
|
||||
"@welldone-software/why-did-you-render": "10.0.1",
|
||||
"@yornaath/batshit": "0.10.1",
|
||||
|
|
@ -47,20 +48,18 @@
|
|||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"dayjs": "1.11.13",
|
||||
"dexie": "4.0.11",
|
||||
"dexie-export-import": "4.1.4",
|
||||
"dnum": "2.15.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"embla-carousel-wheel-gestures": "8.0.2",
|
||||
"es-toolkit": "1.38.0",
|
||||
"firebase": "11.8.1",
|
||||
"foxact": "0.2.45",
|
||||
"es-toolkit": "1.39.3",
|
||||
"firebase": "11.9.1",
|
||||
"foxact": "0.2.49",
|
||||
"franc-min": "6.2.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"hast-util-to-mdast": "10.1.2",
|
||||
"i18next": "25.2.1",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.12.5",
|
||||
|
|
@ -68,34 +67,34 @@
|
|||
"masonic": "4.1.0",
|
||||
"mdast-util-gfm-table": "2.0.0",
|
||||
"mdast-util-to-markdown": "2.1.2",
|
||||
"motion": "12.15.0",
|
||||
"motion": "12.18.1",
|
||||
"nanoid": "5.1.5",
|
||||
"ofetch": "1.4.1",
|
||||
"plain-shiki": "0.2.0",
|
||||
"plain-shiki": "0.3.0",
|
||||
"re-resizable": "6.11.2",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-fast-marquee": "1.6.5",
|
||||
"react-hook-form": "7.56.4",
|
||||
"react-hook-form": "7.58.1",
|
||||
"react-hotkeys-hook": "5.1.0",
|
||||
"react-i18next": "15.5.2",
|
||||
"react-i18next": "15.5.3",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-ios-pwa-prompt": "2.0.6",
|
||||
"react-qr-code": "2.0.15",
|
||||
"react-qr-code": "2.0.16",
|
||||
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
|
||||
"react-router": "7.6.1",
|
||||
"react-router": "7.6.2",
|
||||
"react-selecto": "1.26.3",
|
||||
"react-shadow": "20.6.0",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"shiki": "3.4.2",
|
||||
"sonner": "2.0.3",
|
||||
"shiki": "3.7.0",
|
||||
"sonner": "2.0.5",
|
||||
"tinykeys": "3.0.0",
|
||||
"title-case": "4.3.2",
|
||||
"tldts": "7.0.7",
|
||||
"tldts": "7.0.9",
|
||||
"ufo": "1.6.1",
|
||||
"use-context-selector": "2.0.0",
|
||||
"use-sync-external-store": "1.5.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "3.25.32",
|
||||
"zod": "3.25.67",
|
||||
"zustand": "5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -107,10 +106,10 @@
|
|||
"@follow/models": "workspace:*",
|
||||
"@follow/types": "workspace:*",
|
||||
"@follow/utils": "workspace:*",
|
||||
"@types/node": "22.15.23",
|
||||
"@types/node": "24.0.3",
|
||||
"@vite-pwa/assets-generator": "1.0.0",
|
||||
"fake-indexeddb": "6.0.1",
|
||||
"happy-dom": "17.5.6",
|
||||
"happy-dom": "18.0.1",
|
||||
"react-scan": "0.3.4",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
// This file is auto-generated by scripts/generate-i18n-locale.ts
|
||||
// DONT EDIT THIS FILE MANUALLY
|
||||
import en from "@locales/app/en.json"
|
||||
import app_ja from "@locales/app/ja.json"
|
||||
import app_zhCN from "@locales/app/zh-CN.json"
|
||||
import app_zhTW from "@locales/app/zh-TW.json"
|
||||
import common_en from "@locales/common/en.json"
|
||||
import common_ja from "@locales/common/ja.json"
|
||||
import common_zhCN from "@locales/common/zh-CN.json"
|
||||
import common_zhTW from "@locales/common/zh-TW.json"
|
||||
import errors_en from "@locales/errors/en.json"
|
||||
import errors_ja from "@locales/errors/ja.json"
|
||||
import errors_zhCN from "@locales/errors/zh-CN.json"
|
||||
import errors_zhTW from "@locales/errors/zh-TW.json"
|
||||
import lang_en from "@locales/lang/en.json"
|
||||
import lang_ja from "@locales/lang/ja.json"
|
||||
import lang_zhCN from "@locales/lang/zh-CN.json"
|
||||
import lang_zhTW from "@locales/lang/zh-TW.json"
|
||||
import settings_en from "@locales/settings/en.json"
|
||||
import settings_ja from "@locales/settings/ja.json"
|
||||
import settings_zhCN from "@locales/settings/zh-CN.json"
|
||||
import settings_zhTW from "@locales/settings/zh-TW.json"
|
||||
import shortcuts_en from "@locales/shortcuts/en.json"
|
||||
import shortcuts_ja from "@locales/shortcuts/ja.json"
|
||||
import shortcuts_zhCN from "@locales/shortcuts/zh-CN.json"
|
||||
import shortcuts_zhTW from "@locales/shortcuts/zh-TW.json"
|
||||
|
||||
import type { ns, RendererSupportedLanguages } from "./constants"
|
||||
|
||||
/**
|
||||
* This file is the language resource that is loaded in full when the app is initialized.
|
||||
* In electron, we can load all the language resources synchronously.
|
||||
*/
|
||||
export const defaultResources = {
|
||||
en: {
|
||||
app: en,
|
||||
lang: lang_en,
|
||||
common: common_en,
|
||||
settings: settings_en,
|
||||
shortcuts: shortcuts_en,
|
||||
errors: errors_en,
|
||||
},
|
||||
"zh-CN": {
|
||||
app: app_zhCN,
|
||||
lang: lang_zhCN,
|
||||
common: common_zhCN,
|
||||
settings: settings_zhCN,
|
||||
shortcuts: shortcuts_zhCN,
|
||||
errors: errors_zhCN,
|
||||
},
|
||||
|
||||
ja: {
|
||||
app: app_ja,
|
||||
lang: lang_ja,
|
||||
common: common_ja,
|
||||
settings: settings_ja,
|
||||
shortcuts: shortcuts_ja,
|
||||
errors: errors_ja,
|
||||
},
|
||||
"zh-TW": {
|
||||
app: app_zhTW,
|
||||
lang: lang_zhTW,
|
||||
common: common_zhTW,
|
||||
settings: settings_zhTW,
|
||||
shortcuts: shortcuts_zhTW,
|
||||
errors: errors_zhTW,
|
||||
},
|
||||
} satisfies Record<
|
||||
RendererSupportedLanguages,
|
||||
Partial<Record<(typeof ns)[number], Record<string, string>>>
|
||||
>
|
||||
|
|
@ -1,45 +1,12 @@
|
|||
import { getStorageNS } from "@follow/utils/ns"
|
||||
import { atom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
import { createAtomHooks } from "~/lib/jotai"
|
||||
|
||||
type Readability = {
|
||||
title?: string | null
|
||||
content?: string | null
|
||||
textContent?: string | null
|
||||
length?: number | null
|
||||
excerpt?: string | null
|
||||
byline?: string | null
|
||||
dir?: string | null
|
||||
siteName?: string | null
|
||||
lang?: string | null
|
||||
publishedTime?: string | null
|
||||
}
|
||||
|
||||
const mergeObjectSetter =
|
||||
<T>(setter: (prev: T) => void, getter: () => T) =>
|
||||
(value: Partial<T>) =>
|
||||
setter({ ...getter(), ...value })
|
||||
|
||||
export const [
|
||||
,
|
||||
,
|
||||
useReadabilityContent,
|
||||
,
|
||||
getReadabilityContent,
|
||||
__setReadabilityContent,
|
||||
useReadabilityContentSelector,
|
||||
] = createAtomHooks(
|
||||
atomWithStorage<Record<string, Readability>>(getStorageNS("readability-content"), {}, undefined, {
|
||||
getOnInit: true,
|
||||
}),
|
||||
)
|
||||
export const setReadabilityContent = mergeObjectSetter(
|
||||
__setReadabilityContent,
|
||||
getReadabilityContent,
|
||||
)
|
||||
|
||||
export enum ReadabilityStatus {
|
||||
INITIAL = 1,
|
||||
WAITING = 2,
|
||||
|
|
@ -77,5 +44,3 @@ export const useEntryInReadabilityStatus = (entryId?: string) =>
|
|||
|
||||
export const isInReadability = (status: ReadabilityStatus) =>
|
||||
status !== ReadabilityStatus.INITIAL && !!status
|
||||
export const useEntryReadabilityContent = (entryId: string) =>
|
||||
useReadabilityContentSelector((map) => map[entryId], [entryId])
|
||||
|
|
|
|||
|
|
@ -10,9 +10,5 @@ export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = crea
|
|||
|
||||
export const useIsInMASReview = () => {
|
||||
const serverConfigs = useServerConfigs()
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
process.mas &&
|
||||
serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
|
||||
)
|
||||
return window.mas && serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useInView } from "react-intersection-observer"
|
|||
type ImpressionProps<T extends AllTrackers> = {
|
||||
event: T
|
||||
onTrack?: () => any
|
||||
// @ts-expect-error FIXME
|
||||
properties?: Parameters<TrackerPoints[T]>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useEffect } from "react"
|
|||
import type { Location } from "react-router"
|
||||
import { Navigate, useLocation, useNavigate } from "react-router"
|
||||
|
||||
import { useSyncTheme } from "~/hooks/common"
|
||||
import { removeAppSkeleton } from "~/lib/app"
|
||||
|
||||
import { PoweredByFooter } from "./PoweredByFooter"
|
||||
|
|
@ -26,6 +27,8 @@ class AccessNotFoundError extends Error {
|
|||
}
|
||||
export const NotFound = () => {
|
||||
const location = useLocation()
|
||||
useSyncTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ELECTRON_BUILD) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { getEntry } from "@follow/store/entry/getter"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { getEntry } from "~/store/entry"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
interface SharePanelProps {
|
||||
entryId: string
|
||||
|
|
@ -69,7 +70,7 @@ const getShareUrl = (entryId: string) => {
|
|||
if (!entry) return ""
|
||||
|
||||
// Temporarily use the original link
|
||||
return entry.entries.url!
|
||||
return entry.url!
|
||||
// const params = getRouteParams()
|
||||
|
||||
// let subscriptionId = "all"
|
||||
|
|
@ -95,7 +96,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
(entry: ReturnType<typeof getEntry>) => {
|
||||
if (!entry) return null
|
||||
|
||||
const { title, description } = entry.entries
|
||||
const { title, description } = entry
|
||||
const shareUrl = getShareUrl(entryId)
|
||||
|
||||
// Limit text to 50 characters with ellipsis
|
||||
|
|
@ -136,13 +137,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
})
|
||||
} else {
|
||||
// Fallback to copying link
|
||||
await navigator.clipboard.writeText(shareContent.url)
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
}
|
||||
} catch {
|
||||
// If sharing fails, copy link as fallback
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareContent.url)
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
|
|
@ -153,7 +154,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
const handleCopyLink = useCallback(async () => {
|
||||
const shareUrl = getShareUrl(entryId)
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
await copyToClipboard(shareUrl)
|
||||
toast.success(t("share.link_copied"))
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
|
|
@ -207,7 +208,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
<h3 className="text-text mb-2 mt-1 font-semibold">{t("share.title")}</h3>
|
||||
{(() => {
|
||||
const entry = getEntry(entryId)
|
||||
const title = entry?.entries?.title
|
||||
const title = entry?.title
|
||||
return title ? (
|
||||
<p className="text-text-secondary mt-1 min-w-0 text-wrap text-left text-sm font-medium">
|
||||
{title}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,35 @@
|
|||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { HTMLMotionProps, Variants } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { cloneElement, useRef, useState } from "react"
|
||||
|
||||
interface AnimatedCommandButtonProps {
|
||||
const animatedCommandButtonVariants = cva(
|
||||
["center pointer-events-auto flex text-xs", "rounded-md p-1.5 duration-200"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
solid: ["border-accent/5 bg-accent/80 text-white border backdrop-blur"],
|
||||
outline: ["text-accent hover:bg-material-ultra-thick"],
|
||||
ghost: [
|
||||
"border-accent/5 bg-accent/80 text-accent border backdrop-blur",
|
||||
"bg-theme-item-active hover:bg-theme-item-hover",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "solid",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface AnimatedCommandButtonProps extends VariantProps<typeof animatedCommandButtonVariants> {
|
||||
icon: React.JSX.Element
|
||||
variant?: "solid" | "outline" | "ghost"
|
||||
}
|
||||
|
||||
const iconVariants: Variants = {
|
||||
|
|
@ -31,7 +51,7 @@ export const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionPr
|
|||
icon,
|
||||
className,
|
||||
style,
|
||||
variant = "solid",
|
||||
variant,
|
||||
...props
|
||||
}) => {
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
|
@ -40,15 +60,7 @@ export const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionPr
|
|||
return (
|
||||
<MotionButtonBase
|
||||
type="button"
|
||||
className={cn(
|
||||
"center pointer-events-auto flex text-xs",
|
||||
"rounded-md p-1.5 duration-200",
|
||||
variant === "solid" || variant === "ghost"
|
||||
? "border-accent/5 bg-accent/80 text-accent border backdrop-blur"
|
||||
: "text-accent hover:bg-material-ultra-thick",
|
||||
variant === "ghost" && "bg-theme-item-active hover:bg-theme-item-hover",
|
||||
className,
|
||||
)}
|
||||
className={cn(animatedCommandButtonVariants({ variant }), className)}
|
||||
onClick={useTypeScriptHappyCallback(
|
||||
(e) => {
|
||||
setPressed(true)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useCallback, useRef } from "react"
|
||||
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
import { AnimatedCommandButton } from "./base"
|
||||
import { AnimatedCommandButton } from "./AnimatedCommandButton"
|
||||
|
||||
export const CopyButton: Component<{
|
||||
value: string
|
||||
|
|
@ -11,7 +12,7 @@ export const CopyButton: Component<{
|
|||
}> = ({ value, className, style, variant = "solid" }) => {
|
||||
const copiedTimerRef = useRef<any>(undefined)
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
copyToClipboard(value)
|
||||
|
||||
clearTimeout(copiedTimerRef.current)
|
||||
}, [value])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { m } from "motion/react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
interface HeaderActionButtonProps {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
variant?: "primary" | "accent" | "neutral"
|
||||
className?: string
|
||||
icon?: string
|
||||
iconClassName?: string
|
||||
"data-testid"?: string
|
||||
}
|
||||
|
||||
export const HeaderActionButton = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
variant = "neutral",
|
||||
className,
|
||||
icon,
|
||||
iconClassName,
|
||||
"data-testid": testId,
|
||||
}: HeaderActionButtonProps) => {
|
||||
const getVariantStyles = () => {
|
||||
if (disabled) {
|
||||
return [
|
||||
"text-text-tertiary cursor-not-allowed opacity-50",
|
||||
"bg-fill-quaternary border border-transparent",
|
||||
]
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case "primary": {
|
||||
return [
|
||||
"bg-blue/10 text-blue hover:bg-blue/20",
|
||||
"border border-blue/20 hover:border-blue/30",
|
||||
"active:bg-blue/30 active:scale-95",
|
||||
]
|
||||
}
|
||||
case "accent": {
|
||||
return [
|
||||
"bg-accent/10 text-accent hover:bg-accent/20",
|
||||
"border border-accent/20 hover:border-accent/30",
|
||||
"active:bg-accent/30 active:scale-95",
|
||||
]
|
||||
}
|
||||
default: {
|
||||
return [
|
||||
"bg-fill/10 text-text hover:bg-fill/20",
|
||||
"border border-fill/20 hover:border-fill/30",
|
||||
"active:bg-fill/30 active:scale-95",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const iconClass = loading ? "i-mgc-loading-3-cute-re animate-spin duration-500" : icon
|
||||
|
||||
return (
|
||||
<m.button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"no-drag-region group relative flex items-center gap-2 rounded-lg px-3 py-2",
|
||||
"text-sm font-medium transition-all duration-200",
|
||||
...getVariantStyles(),
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{iconClass && (
|
||||
<i className={cn("size-4 transition-all duration-200", iconClass, iconClassName)} />
|
||||
)}
|
||||
<span className="font-medium">{children}</span>
|
||||
</m.button>
|
||||
)
|
||||
}
|
||||
|
||||
interface HeaderActionGroupProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const HeaderActionGroup = ({ children, className }: HeaderActionGroupProps) => {
|
||||
return <div className={cn("flex items-center gap-2", className)}>{children}</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { DropZone } from "@follow/components/ui/drop-zone/index.js"
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface AvatarUploadModalProps {
|
||||
onConfirm: (blob: Blob) => Promise<void>
|
||||
onCancel: () => void
|
||||
maxSizeKB?: number
|
||||
}
|
||||
|
||||
export const AvatarUploadModal = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
maxSizeKB = 300,
|
||||
}: AvatarUploadModalProps) => {
|
||||
const { t } = useTranslation("settings")
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Crop settings
|
||||
const [cropData, setCropData] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 400,
|
||||
height: 400,
|
||||
})
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [resizeHandle, setResizeHandle] = useState<string | null>(null)
|
||||
const [dragStart, setDragStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
cropX: 0,
|
||||
cropY: 0,
|
||||
cropWidth: 0,
|
||||
cropHeight: 0,
|
||||
})
|
||||
|
||||
// Helper function: ensure the crop data is within the image boundaries and maintain the 1:1 ratio
|
||||
const constrainCropData = useCallback(
|
||||
(newCropData: typeof cropData, imageWidth: number, imageHeight: number) => {
|
||||
const { x, y, width, height } = newCropData
|
||||
|
||||
// Ensure it's a square, use the larger value to avoid shrinking
|
||||
const size = Math.max(width, height)
|
||||
|
||||
// Ensure the minimum size
|
||||
const minSize = 50
|
||||
let finalSize = Math.max(size, minSize)
|
||||
|
||||
// Ensure it's not out of bounds, if it is, shrink it to the appropriate size
|
||||
const maxSize = Math.min(imageWidth, imageHeight)
|
||||
finalSize = Math.min(finalSize, maxSize)
|
||||
|
||||
// Adjust the position to ensure it's within the boundaries
|
||||
const maxX = imageWidth - finalSize
|
||||
const maxY = imageHeight - finalSize
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, maxX)),
|
||||
y: Math.max(0, Math.min(y, maxY)),
|
||||
width: finalSize,
|
||||
height: finalSize,
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(files: FileList) => {
|
||||
const file = files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error(t("profile.avatar.invalidFileType"))
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > maxSizeKB * 1024) {
|
||||
toast.error(t("profile.avatar.fileTooLarge", { size: `${maxSizeKB}KB` }))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string
|
||||
setSelectedImage(result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[maxSizeKB, t],
|
||||
)
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
if (imageRef.current) {
|
||||
const img = imageRef.current
|
||||
// Use the smaller side's 80% as the initial size
|
||||
const maxSize = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
const size = maxSize * 0.8
|
||||
|
||||
const initialCropData = {
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
// Use the helper function to ensure the data is valid
|
||||
const constrainedData = constrainCropData(
|
||||
initialCropData,
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
)
|
||||
setCropData(constrainedData)
|
||||
}
|
||||
}, [constrainCropData])
|
||||
|
||||
const handleCropMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
cropX: cropData.x,
|
||||
cropY: cropData.y,
|
||||
cropWidth: cropData.width,
|
||||
cropHeight: cropData.height,
|
||||
})
|
||||
},
|
||||
[cropData],
|
||||
)
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent, handle: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setResizeHandle(handle)
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
cropX: cropData.x,
|
||||
cropY: cropData.y,
|
||||
cropWidth: cropData.width,
|
||||
cropHeight: cropData.height,
|
||||
})
|
||||
},
|
||||
[cropData],
|
||||
)
|
||||
|
||||
const handleCropMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDragging && !resizeHandle) return
|
||||
e.preventDefault()
|
||||
|
||||
if (!imageRef.current || !containerRef.current) return
|
||||
|
||||
const img = imageRef.current
|
||||
const container = containerRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
// Calculate the actual display size and position of the image in the container
|
||||
const containerWidth = containerRect.width
|
||||
const containerHeight = containerRect.height
|
||||
const imageAspectRatio = img.naturalWidth / img.naturalHeight
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let displayWidth = 0,
|
||||
displayHeight = 0
|
||||
|
||||
if (imageAspectRatio > containerAspectRatio) {
|
||||
// The image is wider, use the container width
|
||||
displayWidth = containerWidth
|
||||
displayHeight = containerWidth / imageAspectRatio
|
||||
} else {
|
||||
// The image is taller, use the container height
|
||||
displayHeight = containerHeight
|
||||
displayWidth = containerHeight * imageAspectRatio
|
||||
}
|
||||
|
||||
const scaleX = img.naturalWidth / displayWidth
|
||||
const scaleY = img.naturalHeight / displayHeight
|
||||
|
||||
const deltaX = e.clientX - dragStart.x
|
||||
const deltaY = e.clientY - dragStart.y
|
||||
|
||||
if (resizeHandle) {
|
||||
const { cropX, cropY, cropWidth, cropHeight } = dragStart
|
||||
let newX = cropX
|
||||
let newY = cropY
|
||||
let newWidth = cropWidth
|
||||
let newHeight = cropHeight
|
||||
|
||||
if (resizeHandle.includes("r")) newWidth += deltaX * scaleX
|
||||
if (resizeHandle.includes("l")) {
|
||||
newWidth -= deltaX * scaleX
|
||||
newX += deltaX * scaleX
|
||||
}
|
||||
if (resizeHandle.includes("b")) newHeight += deltaY * scaleY
|
||||
if (resizeHandle.includes("t")) {
|
||||
newHeight -= deltaY * scaleY
|
||||
newY += deltaY * scaleY
|
||||
}
|
||||
|
||||
// Keep the aspect ratio, use the larger change value
|
||||
const size = Math.max(newWidth, newHeight)
|
||||
|
||||
// Update the coordinates based on the position of the resize handle
|
||||
if (resizeHandle.includes("t")) newY = cropY + cropHeight - size
|
||||
if (resizeHandle.includes("l")) newX = cropX + cropWidth - size
|
||||
|
||||
const newCropData = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
|
||||
// Use the helper function to ensure the data is valid
|
||||
const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)
|
||||
setCropData(constrainedData)
|
||||
} else if (isDragging) {
|
||||
const newX = dragStart.cropX + deltaX * scaleX
|
||||
const newY = dragStart.cropY + deltaY * scaleY
|
||||
|
||||
setCropData((prev) => {
|
||||
const newCropData = {
|
||||
...prev,
|
||||
x: newX,
|
||||
y: newY,
|
||||
}
|
||||
return constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)
|
||||
})
|
||||
}
|
||||
},
|
||||
[isDragging, resizeHandle, dragStart, constrainCropData],
|
||||
)
|
||||
|
||||
const handleCropMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
setResizeHandle(null)
|
||||
}, [])
|
||||
|
||||
const cropImage = useCallback((): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!imageRef.current || !canvasRef.current) {
|
||||
reject(new Error("Image or canvas not available"))
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) {
|
||||
reject(new Error("Canvas context not available"))
|
||||
return
|
||||
}
|
||||
|
||||
const img = imageRef.current
|
||||
canvas.width = 400
|
||||
canvas.height = 400
|
||||
|
||||
ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, 400, 400)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error("Failed to create blob"))
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}, [cropData])
|
||||
|
||||
// Preset functions
|
||||
const handleFullImageCrop = useCallback(() => {
|
||||
if (!imageRef.current) return
|
||||
|
||||
const img = imageRef.current
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
|
||||
const newCropData = {
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
|
||||
const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)
|
||||
setCropData(constrainedData)
|
||||
}, [constrainCropData])
|
||||
|
||||
const handleCenterCrop = useCallback(() => {
|
||||
if (!imageRef.current) return
|
||||
|
||||
const img = imageRef.current
|
||||
const maxSize = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
const size = maxSize * 0.8
|
||||
|
||||
const newCropData = {
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
|
||||
const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight)
|
||||
setCropData(constrainedData)
|
||||
}, [constrainCropData])
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!selectedImage) return
|
||||
|
||||
try {
|
||||
setIsProcessing(true)
|
||||
const blob = await cropImage()
|
||||
await onConfirm(blob)
|
||||
} catch (error) {
|
||||
console.error("Error processing image:", error)
|
||||
toast.error(t("profile.avatar.processingError"))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [selectedImage, cropImage, onConfirm, t])
|
||||
|
||||
const cropStyle = useMemo(() => {
|
||||
if (!imageRef.current || !containerRef.current) return {}
|
||||
|
||||
const img = imageRef.current
|
||||
const container = containerRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
// Calculate the actual display size and position of the image in the container
|
||||
const containerWidth = containerRect.width
|
||||
const containerHeight = containerRect.height
|
||||
const imageAspectRatio = img.naturalWidth / img.naturalHeight
|
||||
const containerAspectRatio = containerWidth / containerHeight
|
||||
|
||||
let displayWidth = 0,
|
||||
displayHeight = 0,
|
||||
offsetX = 0,
|
||||
offsetY = 0
|
||||
|
||||
if (imageAspectRatio > containerAspectRatio) {
|
||||
// The image is wider, use the container width
|
||||
displayWidth = containerWidth
|
||||
displayHeight = containerWidth / imageAspectRatio
|
||||
offsetX = 0
|
||||
offsetY = (containerHeight - displayHeight) / 2
|
||||
} else {
|
||||
// The image is taller, use the container height
|
||||
displayHeight = containerHeight
|
||||
displayWidth = containerHeight * imageAspectRatio
|
||||
offsetX = (containerWidth - displayWidth) / 2
|
||||
offsetY = 0
|
||||
}
|
||||
|
||||
// Calculate the scale ratio
|
||||
const scaleX = displayWidth / img.naturalWidth
|
||||
const scaleY = displayHeight / img.naturalHeight
|
||||
|
||||
return {
|
||||
left: `${offsetX + cropData.x * scaleX}px`,
|
||||
top: `${offsetY + cropData.y * scaleY}px`,
|
||||
width: `${cropData.width * scaleX}px`,
|
||||
height: `${cropData.height * scaleY}px`,
|
||||
}
|
||||
}, [cropData])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!selectedImage ? (
|
||||
<div className="aspect-square h-[400px] space-y-4">
|
||||
<DropZone onDrop={handleFileSelect} className="size-full">
|
||||
<div className="flex flex-col items-center gap-2 p-8">
|
||||
<i className="i-mgc-file-upload-cute-re text-text-secondary text-4xl" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">{t("profile.avatar.dropZoneText")}</p>
|
||||
<p className="text-text-secondary text-xs">{t("profile.avatar.dropZoneSubtext")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropZone>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto size-[400px] select-none overflow-hidden rounded-lg border bg-gray-100 dark:bg-zinc-800"
|
||||
onMouseMove={handleCropMouseMove}
|
||||
onMouseUp={handleCropMouseUp}
|
||||
onMouseLeave={handleCropMouseUp}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={selectedImage}
|
||||
alt="Preview"
|
||||
className="size-full object-contain"
|
||||
draggable={false}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
{/* Crop overlay */}
|
||||
<div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
...cropStyle,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
...cropStyle,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
>
|
||||
<div className="size-full cursor-move" onMouseDown={handleCropMouseDown}>
|
||||
{/* Grid lines */}
|
||||
<div className="bg-material-medium-light absolute left-1/3 top-0 h-full w-px" />
|
||||
<div className="bg-material-medium-light absolute left-2/3 top-0 h-full w-px" />
|
||||
<div className="bg-material-medium-light absolute left-0 top-1/3 h-px w-full" />
|
||||
<div className="bg-material-medium-light absolute left-0 top-2/3 h-px w-full" />
|
||||
|
||||
{/* Resize handles */}
|
||||
<div
|
||||
className="bg-accent absolute -left-1 -top-1 size-3 cursor-nwse-resize rounded-full border-2 border-white"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "tl")}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent absolute -right-1 -top-1 size-3 cursor-nesw-resize rounded-full border-2 border-white"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "tr")}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent absolute -bottom-1 -left-1 size-3 cursor-nesw-resize rounded-full border-2 border-white"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "bl")}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent absolute -bottom-1 -right-1 size-3 cursor-nwse-resize rounded-full border-2 border-white"
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, "br")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-text-secondary text-center text-sm">
|
||||
{t("profile.avatar.cropInstructions")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
{selectedImage ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleFullImageCrop} size="sm">
|
||||
<i className="i-mgc-fullscreen-cute-re mr-1 text-sm" />
|
||||
Full Image
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCenterCrop} size="sm">
|
||||
<i className="i-mgc-round-cute-re mr-1 text-sm" />
|
||||
Center Crop
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t("words.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedImage} isLoading={isProcessing}>
|
||||
{t("words.confirm", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom"
|
|||
import { parseHtml } from "~/lib/parse-html"
|
||||
import { useWrappedElementSize } from "~/providers/wrapped-element-provider"
|
||||
|
||||
import type { MediaInfoRecord } from "../media"
|
||||
import { MediaContainerWidthProvider, MediaInfoRecordProvider } from "../media"
|
||||
import { MediaContainerWidthProvider } from "../media/MediaContainerWidthProvider"
|
||||
import type { MediaInfoRecord } from "../media/MediaInfoRecord"
|
||||
import { MediaInfoRecordProvider } from "../media/MediaInfoRecordProvider"
|
||||
import { MarkdownRenderContainerRefContext } from "./context"
|
||||
|
||||
export type HTMLProps<A extends keyof JSX.IntrinsicElements = "div"> = {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useContextSelector } from "use-context-selector"
|
|||
|
||||
import { useWrappedElementSize } from "~/providers/wrapped-element-provider"
|
||||
|
||||
import { Media } from "../../media"
|
||||
import { Media } from "../../media/Media"
|
||||
import { MarkdownImageRecordContext, MarkdownRenderActionContext } from "../context"
|
||||
|
||||
export const MarkdownBlockImage = (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { cn } from "@follow/utils/utils"
|
|||
import { use } from "react"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
|
||||
import { Media } from "../../media"
|
||||
import { Media } from "../../media/Media"
|
||||
import { MarkdownImageRecordContext, MarkdownRenderActionContext } from "../context"
|
||||
|
||||
export const MarkdownInlineImage = (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MagneticHoverEffect } from "@follow/components/ui/effect/MagneticHoverEffect.js"
|
||||
import type { LinkProps } from "@follow/components/ui/link/LinkWithTooltip.js"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -8,7 +9,6 @@ import {
|
|||
import { useCorrectZIndex } from "@follow/components/ui/z-index/ctx.js"
|
||||
import { use } from "react"
|
||||
|
||||
import type { LinkProps } from "../../link"
|
||||
import { MarkdownRenderActionContext } from "../context"
|
||||
|
||||
export const MarkdownLink = (props: LinkProps) => {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ import { getImageProxyUrl } from "@follow/utils/img-proxy"
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { useForceUpdate } from "motion/react"
|
||||
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
|
||||
import { createContext, memo, use, useMemo, useState } from "react"
|
||||
import * as React from "react"
|
||||
import { memo, use, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Blurhash, BlurhashCanvas } from "react-blurhash"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { saveImageDimensionsToDb } from "~/store/image/db"
|
||||
|
||||
import { usePreviewMedia } from "./media/hooks"
|
||||
import type { VideoPlayerRef } from "./media/VideoPlayer"
|
||||
import { VideoPlayer } from "./media/VideoPlayer"
|
||||
import { useMediaContainerWidth, usePreviewMedia } from "./hooks"
|
||||
import { MediaInfoRecordContext } from "./MediaInfoRecordContext"
|
||||
import type { VideoPlayerRef } from "./VideoPlayer"
|
||||
import { VideoPlayer } from "./VideoPlayer"
|
||||
|
||||
type BaseProps = {
|
||||
mediaContainerClassName?: string
|
||||
|
|
@ -78,67 +80,117 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
const finalHeight = height || ctxHeight
|
||||
const finalWidth = width || ctxWidth
|
||||
|
||||
const [currentState, setCurrentState] = useState<"proxy" | "origin" | "error">(() =>
|
||||
proxy && !preferOrigin ? "proxy" : "origin",
|
||||
)
|
||||
// Define the list of available image sources, sorted by priority
|
||||
const imageSources = useMemo(() => {
|
||||
if (!src) return []
|
||||
|
||||
const [imgSrc, setImgSrc] = useState(() =>
|
||||
currentState === "proxy" && src
|
||||
? getImageProxyUrl({
|
||||
url: src,
|
||||
width: proxy?.width || 0,
|
||||
height: proxy?.height || 0,
|
||||
const sources: Array<{ url: string; type: "proxy" | "origin" }> = []
|
||||
|
||||
// Determine priority based on preferences
|
||||
if (proxy && !preferOrigin) {
|
||||
// Use proxy first
|
||||
sources.push(
|
||||
{
|
||||
url: getImageProxyUrl({
|
||||
url: src,
|
||||
width: proxy.width || 0,
|
||||
height: proxy.height || 0,
|
||||
}),
|
||||
type: "proxy",
|
||||
},
|
||||
{ url: src, type: "origin" },
|
||||
)
|
||||
} else {
|
||||
// Use original URL first
|
||||
sources.push({ url: src, type: "origin" })
|
||||
if (proxy) {
|
||||
sources.push({
|
||||
url: getImageProxyUrl({
|
||||
url: src,
|
||||
width: proxy.width || 0,
|
||||
height: proxy.height || 0,
|
||||
}),
|
||||
type: "proxy",
|
||||
})
|
||||
: src,
|
||||
)
|
||||
|
||||
const previewImageSrc = useMemo(
|
||||
() =>
|
||||
currentState === "proxy" && previewImageUrl
|
||||
? getImageProxyUrl({
|
||||
url: previewImageUrl,
|
||||
width: proxy?.width || 0,
|
||||
height: proxy?.height || 0,
|
||||
})
|
||||
: previewImageUrl,
|
||||
[currentState, previewImageUrl, proxy?.width, proxy?.height],
|
||||
)
|
||||
|
||||
const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded">(() => {
|
||||
if (imgSrc) {
|
||||
return isImageLoadedSet.has(imgSrc) ? "loaded" : "loading"
|
||||
}
|
||||
return "loading"
|
||||
})
|
||||
|
||||
const errorHandle: React.ReactEventHandler<HTMLImageElement> = useEventCallback(() => {
|
||||
switch (currentState) {
|
||||
case "proxy": {
|
||||
if (imgSrc !== props.src && props.src) {
|
||||
setImgSrc(props.src)
|
||||
} else {
|
||||
setCurrentState("error")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "origin": {
|
||||
if (imgSrc === props.src && props.src) {
|
||||
setImgSrc(
|
||||
getImageProxyUrl({
|
||||
url: props.src,
|
||||
width: proxy?.width || 0,
|
||||
height: proxy?.height || 0,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
setCurrentState("error")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return sources
|
||||
}, [src, proxy, preferOrigin])
|
||||
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0)
|
||||
const [isError, setIsError] = useState(false)
|
||||
const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded">("loading")
|
||||
|
||||
const currentSource = imageSources[currentSourceIndex]
|
||||
const imgSrc = currentSource?.url || src
|
||||
|
||||
const previewImageSrc = useMemo(() => {
|
||||
if (!previewImageUrl) return
|
||||
|
||||
// Use the same proxy strategy for preview images
|
||||
if (proxy && currentSource?.type === "proxy") {
|
||||
return getImageProxyUrl({
|
||||
url: previewImageUrl,
|
||||
width: proxy.width || 0,
|
||||
height: proxy.height || 0,
|
||||
})
|
||||
}
|
||||
return previewImageUrl
|
||||
}, [previewImageUrl, proxy, currentSource?.type])
|
||||
|
||||
// When image source list changes, reset to the first source
|
||||
const prevImageSources = useRef(imageSources)
|
||||
useEffect(() => {
|
||||
if (prevImageSources.current !== imageSources && imageSources.length > 0) {
|
||||
prevImageSources.current = imageSources
|
||||
setCurrentSourceIndex(0)
|
||||
setIsError(false)
|
||||
}
|
||||
}, [imageSources])
|
||||
|
||||
// When image source changes, reset loading state
|
||||
const prevImgSrc = useRef(imgSrc)
|
||||
useEffect(() => {
|
||||
if (prevImgSrc.current !== imgSrc) {
|
||||
prevImgSrc.current = imgSrc
|
||||
setMediaLoadState(imgSrc && isImageLoadedSet.has(imgSrc) ? "loaded" : "loading")
|
||||
}
|
||||
}, [imgSrc])
|
||||
|
||||
const errorHandle: React.ReactEventHandler<HTMLImageElement> = useEventCallback((e) => {
|
||||
const nextIndex = currentSourceIndex + 1
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info(
|
||||
`[Media Error] Failed to load image source ${currentSourceIndex + 1}/${imageSources.length}`,
|
||||
{
|
||||
failedSrc: imgSrc,
|
||||
originalSrc: src,
|
||||
error: e,
|
||||
willRetry: nextIndex < imageSources.length,
|
||||
nextSource: imageSources[nextIndex]?.url,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (nextIndex < imageSources.length) {
|
||||
// Try next available image source
|
||||
setCurrentSourceIndex(nextIndex)
|
||||
setMediaLoadState("loading")
|
||||
} else {
|
||||
// All sources failed, mark as error state
|
||||
setIsError(true)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`[Media Error] All image sources failed for: ${src}`, {
|
||||
allSources: imageSources,
|
||||
originalSrc: src,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isError = currentState === "error"
|
||||
const previewMedia = usePreviewMedia()
|
||||
const handleClick = useEventCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -165,6 +217,22 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
setMediaLoadState("loaded")
|
||||
rest.onLoad?.(e as any)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.info(`[Media Success] Image loaded successfully`, {
|
||||
src: imgSrc,
|
||||
originalSrc: src,
|
||||
sourceType: currentSource?.type,
|
||||
sourceIndex: currentSourceIndex + 1,
|
||||
totalSources: imageSources.length,
|
||||
dimensions: {
|
||||
width: e.currentTarget.naturalWidth,
|
||||
height: e.currentTarget.naturalHeight,
|
||||
ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight,
|
||||
},
|
||||
loadTime: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
if (imgSrc) {
|
||||
isImageLoadedSet.add(imgSrc)
|
||||
}
|
||||
|
|
@ -293,6 +361,19 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
return (
|
||||
<span
|
||||
data-state={type !== "video" ? mediaLoadState : undefined}
|
||||
data-media-debug={
|
||||
import.meta.env.DEV
|
||||
? JSON.stringify({
|
||||
originalSrc: src,
|
||||
currentSrc: imgSrc,
|
||||
sourceType: currentSource?.type,
|
||||
sourceIndex: currentSourceIndex,
|
||||
totalSources: imageSources.length,
|
||||
isError,
|
||||
mediaLoadState,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className={cn("relative overflow-hidden rounded", inline ? "inline" : "block", className)}
|
||||
style={style}
|
||||
>
|
||||
|
|
@ -451,32 +532,3 @@ const VideoPreview: FC<{
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MediaContainerWidthContext = createContext<number>(0)
|
||||
export const MediaContainerWidthProvider = ({
|
||||
children,
|
||||
width,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
width: number
|
||||
}) => {
|
||||
return <MediaContainerWidthContext value={width}>{children}</MediaContainerWidthContext>
|
||||
}
|
||||
|
||||
const useMediaContainerWidth = () => {
|
||||
return use(MediaContainerWidthContext)
|
||||
}
|
||||
|
||||
export type MediaInfoRecord = Record<string, { width?: number; height?: number }>
|
||||
const MediaInfoRecordContext = createContext<MediaInfoRecord>({})
|
||||
|
||||
const noop = {} as const
|
||||
export const MediaInfoRecordProvider = ({
|
||||
children,
|
||||
mediaInfo,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
mediaInfo?: Nullable<MediaInfoRecord>
|
||||
}) => {
|
||||
return <MediaInfoRecordContext value={mediaInfo || noop}>{children}</MediaInfoRecordContext>
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
export const MediaContainerWidthContext = createContext<number>(0)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { MediaContainerWidthContext } from "./MediaContainerWidthContext"
|
||||
|
||||
export const MediaContainerWidthProvider = ({
|
||||
children,
|
||||
width,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
width: number
|
||||
}) => {
|
||||
return <MediaContainerWidthContext value={width}>{children}</MediaContainerWidthContext>
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type MediaInfoRecord = Record<string, { width?: number; height?: number }>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
import type { MediaInfoRecord } from "./MediaInfoRecord"
|
||||
|
||||
export const MediaInfoRecordContext = createContext<MediaInfoRecord>({})
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { MediaInfoRecord } from "./MediaInfoRecord"
|
||||
import { MediaInfoRecordContext } from "./MediaInfoRecordContext"
|
||||
|
||||
const noop = {} as const
|
||||
export const MediaInfoRecordProvider = ({
|
||||
children,
|
||||
mediaInfo,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
mediaInfo?: Nullable<MediaInfoRecord>
|
||||
}) => {
|
||||
return <MediaInfoRecordContext value={mediaInfo || noop}>{children}</MediaInfoRecordContext>
|
||||
}
|
||||
|
|
@ -0,0 +1,662 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.js"
|
||||
import type { MediaModel } from "@follow/shared/hono"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
||||
import { useAnimationControls } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ReactZoomPanPinchRef, ReactZoomPanPinchState } from "react-zoom-pan-pinch"
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"
|
||||
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { COPY_MAP } from "~/constants"
|
||||
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
|
||||
|
||||
import { useCurrentModal } from "../modal/stacked/hooks"
|
||||
import { VideoPlayer } from "./VideoPlayer"
|
||||
|
||||
// Calculate the dynamic scale value and offset
|
||||
const calculateDragTransforms = (x: number, y: number) => {
|
||||
// Minimum scale to 0.7, maximum keep 1.0
|
||||
const maxDistance = 300
|
||||
const dragDistance = Math.hypot(x, y)
|
||||
const progress = Math.min(dragDistance / maxDistance, 1)
|
||||
const scale = 1 - progress * 0.3 // From 1.0 to 0.7
|
||||
|
||||
// Calculate the opacity, minimum to 0.5
|
||||
const opacity = 1 - progress * 0.5
|
||||
|
||||
return { scale, opacity, x, y }
|
||||
}
|
||||
|
||||
// Framer Motion variants
|
||||
const modalVariants = {
|
||||
initial: { scale: 0.94, opacity: 0 },
|
||||
visible: { scale: 1, opacity: 1, x: 0, y: 0 },
|
||||
exit: { scale: 0.94, opacity: 0 },
|
||||
closing: (dragOffset: { x: number; y: number }) => ({
|
||||
scale: 0.3,
|
||||
x: dragOffset.x,
|
||||
y: dragOffset.y,
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
const Wrapper: FC<{
|
||||
src: string
|
||||
children:
|
||||
| [React.ReactNode, React.ReactNode | undefined]
|
||||
| React.ReactNode
|
||||
| ((
|
||||
onZoomChange: (isZoomed: boolean) => void,
|
||||
) => [React.ReactNode, React.ReactNode | undefined] | React.ReactNode)
|
||||
className?: string
|
||||
onZoomChange?: (isZoomed: boolean) => void
|
||||
canDragClose?: boolean
|
||||
}> = ({ children, src, onZoomChange, canDragClose = true }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { dismiss } = useCurrentModal()
|
||||
const controls = useAnimationControls()
|
||||
|
||||
// Drag close state
|
||||
const [isImageZoomed, setIsImageZoomed] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Combined zoom change callback
|
||||
const handleZoomChange = useCallback(
|
||||
(isZoomed: boolean) => {
|
||||
setIsImageZoomed(isZoomed)
|
||||
onZoomChange?.(isZoomed)
|
||||
},
|
||||
[onZoomChange],
|
||||
)
|
||||
|
||||
const renderedChildren = typeof children === "function" ? children(handleZoomChange) : children
|
||||
const isArray = Array.isArray(renderedChildren)
|
||||
const hasSideContent = isArray && !!renderedChildren[1]
|
||||
|
||||
const enableDragClose = !isImageZoomed && canDragClose
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(_: any, info: any) => {
|
||||
if (!isDragging) return
|
||||
const { offset } = info
|
||||
setDragOffset(offset)
|
||||
|
||||
// Real-time update the transform when dragging
|
||||
const dragTransforms = calculateDragTransforms(offset.x, offset.y)
|
||||
controls.set({
|
||||
scale: dragTransforms.scale,
|
||||
x: offset.x * 0.3,
|
||||
y: offset.y * 0.3,
|
||||
opacity: dragTransforms.opacity,
|
||||
})
|
||||
},
|
||||
[isDragging, controls],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (_: any, info: any) => {
|
||||
const { offset, velocity } = info
|
||||
// Calculate the drag distance and velocity
|
||||
const dragDistance = Math.hypot(offset.x, offset.y)
|
||||
const velocityDistance = Math.hypot(velocity.x, velocity.y)
|
||||
|
||||
// If the drag distance is greater than 100px or the overall drag distance is greater than 150px or the velocity is greater than 300, close the modal
|
||||
const shouldClose =
|
||||
offset.y > 100 || dragDistance > 150 || velocity.y > 300 || velocityDistance > 500
|
||||
|
||||
if (shouldClose) {
|
||||
// Execute the closing animation
|
||||
await controls.start("closing", {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 40,
|
||||
duration: 0.3,
|
||||
})
|
||||
dismiss()
|
||||
} else {
|
||||
// Reset to normal state
|
||||
setIsDragging(false)
|
||||
setDragOffset({ x: 0, y: 0 })
|
||||
controls.start("visible", {
|
||||
...Spring.presets.snappy,
|
||||
})
|
||||
}
|
||||
},
|
||||
[controls, dismiss],
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
// Initialize the animation
|
||||
useEffect(() => {
|
||||
controls.start("visible", {
|
||||
...Spring.presets.snappy,
|
||||
})
|
||||
}, [controls])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed inset-0">
|
||||
<m.div
|
||||
variants={modalVariants}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
exit="exit"
|
||||
custom={dragOffset}
|
||||
className="bg-material-medium-dark flex size-full pt-[var(--fo-window-padding-top)] backdrop-blur"
|
||||
drag={enableDragClose}
|
||||
dragConstraints={{ top: 0, bottom: 300, left: -200, right: 200 }}
|
||||
dragElastic={{ top: 0, bottom: 0.3, left: 0.2, right: 0.2 }}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
cursor: enableDragClose ? (isDragging ? "grabbing" : "grab") : "default",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/left relative flex h-full w-0 grow overflow-hidden",
|
||||
hasSideContent ? "min-w-96 items-center justify-center" : "",
|
||||
)}
|
||||
>
|
||||
<HeaderActions src={src} />
|
||||
{isArray ? renderedChildren[0] : renderedChildren}
|
||||
</div>
|
||||
{hasSideContent ? (
|
||||
<div
|
||||
className="bg-background box-border flex h-full w-[400px] min-w-0 shrink-0 flex-col px-2 pt-1"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isArray ? renderedChildren[1] : null}
|
||||
</div>
|
||||
) : undefined}
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HeaderActions: FC<{
|
||||
src: string
|
||||
}> = ({ src }) => {
|
||||
const { t } = useTranslation(["shortcuts", "common"])
|
||||
|
||||
const { dismiss } = useCurrentModal()
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-[100] flex h-16 items-center justify-end gap-2 px-3">
|
||||
<HeaderButton description={t(COPY_MAP.OpenInBrowser())} onClick={() => window.open(src)}>
|
||||
<i className="i-mgc-external-link-cute-re" />
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
description={t("common:words.download")}
|
||||
onClick={() => {
|
||||
const a = document.createElement("a")
|
||||
a.href = src
|
||||
a.download = src.split("/").pop()!
|
||||
a.target = "_blank"
|
||||
a.rel = "noreferrer"
|
||||
a.click()
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-download-2-cute-re" />
|
||||
</HeaderButton>
|
||||
|
||||
<HeaderButton
|
||||
description={t("common:words.close")}
|
||||
className="ml-3 !border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re" />
|
||||
</HeaderButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
export interface PreviewMediaProps extends MediaModel {
|
||||
fallbackUrl?: string
|
||||
}
|
||||
export const PreviewMediaContent: FC<{
|
||||
media: PreviewMediaProps[]
|
||||
initialIndex?: number
|
||||
children?: React.ReactNode
|
||||
onZoomChange?: (isZoomed: boolean) => void
|
||||
}> = ({ media, initialIndex = 0, children, onZoomChange }) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [
|
||||
WheelGesturesPlugin(),
|
||||
])
|
||||
const [currentMedia, setCurrentMedia] = useState(media[initialIndex])
|
||||
|
||||
// This only to delay show
|
||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)
|
||||
|
||||
useEffect(() => {
|
||||
if (emblaApi) {
|
||||
emblaApi.on("select", () => {
|
||||
const realIndex = emblaApi.selectedScrollSnap()
|
||||
setCurrentMedia(media[realIndex])
|
||||
setCurrentSlideIndex(realIndex)
|
||||
})
|
||||
}
|
||||
}, [emblaApi, media])
|
||||
|
||||
const { ref } = useCurrentModal()
|
||||
|
||||
// Keyboard
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
const $container = ref.current
|
||||
if (!$container) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") emblaApi?.scrollPrev()
|
||||
if (e.key === "ArrowRight") emblaApi?.scrollNext()
|
||||
}
|
||||
$container.addEventListener("keydown", handleKeyDown)
|
||||
return () => $container.removeEventListener("keydown", handleKeyDown)
|
||||
}, [emblaApi, ref])
|
||||
|
||||
if (media.length === 0) return null
|
||||
if (media.length === 1) {
|
||||
const src = media[0]!.url
|
||||
const { type } = media[0]!
|
||||
const isVideo = type === "video"
|
||||
return (
|
||||
<Wrapper src={src} onZoomChange={onZoomChange} canDragClose>
|
||||
{(handleZoomChange) => [
|
||||
<Fragment key={src}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
className={cn("h-full w-auto object-contain", !!children && "rounded-l-xl")}
|
||||
onClick={stopPropagation}
|
||||
/>
|
||||
) : (
|
||||
<FallbackableImage
|
||||
fallbackUrl={media[0]!.fallbackUrl}
|
||||
className="h-full w-auto object-contain"
|
||||
alt="cover"
|
||||
src={src}
|
||||
height={media[0]!.height}
|
||||
width={media[0]!.width}
|
||||
blurhash={media[0]!.blurhash}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
)}
|
||||
</Fragment>,
|
||||
children,
|
||||
]}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Wrapper src={currentMedia!.url} onZoomChange={onZoomChange} canDragClose={false}>
|
||||
{(handleZoomChange) => [
|
||||
<div key={"left"} className="group size-full overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex size-full">
|
||||
{media.map((med) => (
|
||||
<div className="mr-2 flex w-full flex-none items-center justify-center" key={med.url}>
|
||||
{med.type === "video" ? (
|
||||
<VideoPlayer
|
||||
src={med.url}
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
className="size-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<FallbackableImage
|
||||
fallbackUrl={med.fallbackUrl}
|
||||
className="size-full object-contain"
|
||||
alt="cover"
|
||||
src={med.url}
|
||||
loading="lazy"
|
||||
height={med.height}
|
||||
width={med.width}
|
||||
blurhash={med.blurhash}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentSlideIndex > 0 && (
|
||||
<HeaderButton
|
||||
className={`absolute left-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:left-4 lg:size-10`}
|
||||
onClick={() => {
|
||||
emblaApi?.scrollPrev()
|
||||
}}
|
||||
>
|
||||
<i className={`i-mingcute-left-line text-lg lg:text-xl`} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
|
||||
{currentSlideIndex < media.length - 1 && (
|
||||
<HeaderButton
|
||||
className={`absolute right-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:right-4 lg:size-10`}
|
||||
onClick={() => {
|
||||
emblaApi?.scrollNext()
|
||||
}}
|
||||
>
|
||||
<i className={`i-mingcute-right-line text-lg lg:text-xl`} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
</div>,
|
||||
children,
|
||||
]}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const FallbackableImage: FC<
|
||||
Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
src: string
|
||||
containerClassName?: string
|
||||
fallbackUrl?: string
|
||||
blurhash?: string
|
||||
onZoomChange?: (isZoomed: boolean) => void
|
||||
}
|
||||
> = ({ src, fallbackUrl, containerClassName, onZoomChange }) => {
|
||||
const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))
|
||||
const [isAllError, setIsAllError] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [currentState, setCurrentState] = useState<"proxy" | "origin" | "fallback">(() =>
|
||||
currentSrc === src ? "origin" : "proxy",
|
||||
)
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
switch (currentState) {
|
||||
case "proxy": {
|
||||
if (currentSrc !== src) {
|
||||
setCurrentSrc(src)
|
||||
setCurrentState("origin")
|
||||
} else {
|
||||
if (fallbackUrl) {
|
||||
setCurrentSrc(fallbackUrl)
|
||||
setCurrentState("fallback")
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case "origin": {
|
||||
if (fallbackUrl) {
|
||||
setCurrentSrc(fallbackUrl)
|
||||
setCurrentState("fallback")
|
||||
} else {
|
||||
setIsAllError(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "fallback": {
|
||||
setIsAllError(true)
|
||||
}
|
||||
}
|
||||
}, [currentSrc, currentState, fallbackUrl, src])
|
||||
|
||||
return (
|
||||
<div className={cn("relative size-full", containerClassName)}>
|
||||
{!isAllError && currentSrc && (
|
||||
<DOMImageViewer
|
||||
minZoom={1}
|
||||
maxZoom={2}
|
||||
src={currentSrc}
|
||||
alt="preview"
|
||||
highResLoaded={!isLoading}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={handleError}
|
||||
onZoomChange={onZoomChange}
|
||||
/>
|
||||
)}
|
||||
{isAllError && (
|
||||
<div
|
||||
className="center pointer-events-none absolute inset-0 flex-col gap-3"
|
||||
onClick={stopPropagation}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re text-[60px] text-red-400" />
|
||||
|
||||
<span>Failed to load image</span>
|
||||
<div className="center gap-2">
|
||||
<MotionButtonBase
|
||||
className="pointer-events-auto underline underline-offset-4"
|
||||
onClick={() => {
|
||||
setCurrentSrc(replaceImgUrlIfNeed(src))
|
||||
setIsAllError(false)
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</MotionButtonBase>
|
||||
or
|
||||
<a
|
||||
className="pointer-events-auto underline underline-offset-4"
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Visit Original
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentState === "fallback" && (
|
||||
<div className="bg-material-thick backdrop-blur-background text-text absolute bottom-8 left-1/2 mt-4 -translate-x-1/2 rounded-lg px-3 py-2 text-center text-xs">
|
||||
<span>
|
||||
This image is preview in low quality, because the original image is not available.
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
You can{" "}
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-accent underline duration-200"
|
||||
>
|
||||
visit the original image
|
||||
</a>{" "}
|
||||
if you want to see the full quality.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DOMImageViewer: FC<{
|
||||
height?: number
|
||||
width?: number
|
||||
onZoomChange?: (isZoomed: boolean) => any
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
src: string
|
||||
alt: string
|
||||
highResLoaded: boolean
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
}> = ({
|
||||
height,
|
||||
width,
|
||||
onZoomChange,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
src,
|
||||
alt,
|
||||
highResLoaded,
|
||||
onLoad,
|
||||
onError,
|
||||
}) => {
|
||||
const onTransformed = useCallback(
|
||||
(ref: ReactZoomPanPinchRef, state: Omit<ReactZoomPanPinchState, "previousScale">) => {
|
||||
// 当缩放比例不等于 1 时,认为图片被缩放了
|
||||
const isZoomed = state.scale !== 1
|
||||
onZoomChange?.(isZoomed)
|
||||
},
|
||||
[onZoomChange],
|
||||
)
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
return (
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={minZoom}
|
||||
maxScale={maxZoom}
|
||||
wheel={{
|
||||
step: 0.1,
|
||||
}}
|
||||
pinch={{
|
||||
step: 0.5,
|
||||
}}
|
||||
doubleClick={{
|
||||
step: 2,
|
||||
mode: "toggle",
|
||||
animationTime: 200,
|
||||
animationType: "easeInOutCubic",
|
||||
}}
|
||||
limitToBounds={true}
|
||||
centerOnInit={true}
|
||||
smooth={true}
|
||||
onInit={(e) => {
|
||||
if (e.instance.wrapperComponent) {
|
||||
e.instance.wrapperComponent.onclick = (e) => {
|
||||
if (e.target instanceof HTMLDivElement && e.target.dataset.imageContainer) {
|
||||
e.stopPropagation()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
alignmentAnimation={{
|
||||
sizeX: 0,
|
||||
sizeY: 0,
|
||||
velocityAlignmentTime: 0.2,
|
||||
}}
|
||||
velocityAnimation={{
|
||||
sensitivity: 1,
|
||||
animationTime: 0.2,
|
||||
}}
|
||||
onTransformed={onTransformed}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperProps={{
|
||||
onClick: stopPropagation,
|
||||
}}
|
||||
wrapperClass="!w-full !h-full !absolute !inset-0"
|
||||
contentClass="!w-full !h-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className="relative inline-block h-full overflow-hidden"
|
||||
onClick={stopPropagation}
|
||||
tabIndex={-1}
|
||||
data-image-container
|
||||
>
|
||||
<img
|
||||
height={height}
|
||||
width={width}
|
||||
src={src || undefined}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"mx-auto h-full object-contain",
|
||||
highResLoaded ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onLoad={onLoad}
|
||||
onClick={stopPropagation}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
|||
import { uniqBy } from "es-toolkit/compat"
|
||||
import { useCallback, useRef } from "react"
|
||||
|
||||
import { Media } from "~/components/ui/media"
|
||||
import { Media } from "~/components/ui/media/Media"
|
||||
|
||||
const defaultProxySize = {
|
||||
width: 600,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { useCallback } from "react"
|
||||
import { use, useCallback } from "react"
|
||||
|
||||
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
|
||||
|
||||
import { PlainModal } from "../modal/stacked/custom-modal"
|
||||
import { useModalStack } from "../modal/stacked/hooks"
|
||||
import type { PreviewMediaProps } from "./preview-media"
|
||||
import { PreviewMediaContent } from "./preview-media"
|
||||
import { MediaContainerWidthContext } from "./MediaContainerWidthContext"
|
||||
import type { PreviewMediaProps } from "./PreviewMediaContent"
|
||||
import { PreviewMediaContent } from "./PreviewMediaContent"
|
||||
|
||||
export const usePreviewMedia = (children?: React.ReactNode) => {
|
||||
const { present } = useModalStack()
|
||||
|
|
@ -21,22 +22,25 @@ export const usePreviewMedia = (children?: React.ReactNode) => {
|
|||
}
|
||||
present({
|
||||
content: () => (
|
||||
<div className="relative size-full">
|
||||
<PreviewMediaContent initialIndex={initialIndex} media={media}>
|
||||
{children}
|
||||
</PreviewMediaContent>
|
||||
</div>
|
||||
<PreviewMediaContent initialIndex={initialIndex} media={media}>
|
||||
{children}
|
||||
</PreviewMediaContent>
|
||||
),
|
||||
autoFocus: false,
|
||||
title: "Media Preview",
|
||||
overlay: true,
|
||||
overlay: false,
|
||||
overlayOptions: {
|
||||
blur: true,
|
||||
className: "bg-black/80",
|
||||
blur: false,
|
||||
className: "bg-transparent",
|
||||
},
|
||||
CustomModalComponent: PlainModal,
|
||||
clickOutsideToDismiss: true,
|
||||
clickOutsideToDismiss: false,
|
||||
})
|
||||
},
|
||||
[children, present],
|
||||
)
|
||||
}
|
||||
|
||||
export const useMediaContainerWidth = () => {
|
||||
return use(MediaContainerWidthContext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,533 +0,0 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { useMeasure } from "@follow/hooks"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import type { MediaModel } from "@follow/shared/hono"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
||||
import type { FC } from "react"
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Blurhash } from "react-blurhash"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"
|
||||
import { useWindowSize } from "usehooks-ts"
|
||||
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { COPY_MAP } from "~/constants"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
|
||||
|
||||
import { FixedModalCloseButton } from "../modal/components/close"
|
||||
import { useCurrentModal } from "../modal/stacked/hooks"
|
||||
import { VideoPlayer } from "./VideoPlayer"
|
||||
|
||||
const Wrapper: Component<{
|
||||
src: string
|
||||
showActions?: boolean
|
||||
sideContent?: React.ReactNode
|
||||
}> = ({ children, src, showActions, sideContent }) => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
const { t } = useTranslation(["shortcuts", "common"])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [showActionOverlay, setShowActionOverlay] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !showActions) {
|
||||
return
|
||||
}
|
||||
const $container = containerRef.current
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const atBottom = e.clientY / $container.clientHeight > 0.6
|
||||
if (atBottom) {
|
||||
setShowActionOverlay(true)
|
||||
} else {
|
||||
setShowActionOverlay(false)
|
||||
}
|
||||
}
|
||||
const outOfContainer = () => {
|
||||
setShowActionOverlay(false)
|
||||
}
|
||||
$container.addEventListener("mousemove", handleMouseMove)
|
||||
$container.addEventListener("mouseleave", outOfContainer)
|
||||
return () => {
|
||||
$container.removeEventListener("mousemove", handleMouseMove)
|
||||
$container.removeEventListener("mouseleave", outOfContainer)
|
||||
}
|
||||
}, [sideContent, showActions])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="center relative size-full py-12 lg:px-20 lg:pb-8 lg:pt-10"
|
||||
onClick={dismiss}
|
||||
ref={containerRef}
|
||||
>
|
||||
<m.div
|
||||
onFocusCapture={stopPropagation}
|
||||
initial={true}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
className="safe-inset-top-4 fixed right-4 flex items-center"
|
||||
>
|
||||
<FixedModalCloseButton onClick={dismiss} />
|
||||
</m.div>
|
||||
|
||||
<m.div
|
||||
className="center flex size-full"
|
||||
initial={{ scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.94, opacity: 0 }}
|
||||
transition={Spring.presets.microRebound}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full w-auto overflow-hidden",
|
||||
sideContent
|
||||
? "bg-sidebar min-w-96 items-center justify-center rounded-l-xl"
|
||||
: "rounded-xl",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<RootPortal to={sideContent ? null : undefined}>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[99] overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
opacity: showActionOverlay ? 1 : 0,
|
||||
transform: showActionOverlay ? "translateY(0)" : "translateY(50px)",
|
||||
}}
|
||||
className={cn(
|
||||
"flex justify-end gap-3 p-2 text-white/70 duration-200 [&_button]:hover:text-white",
|
||||
"hover:!transform-none hover:!opacity-100",
|
||||
|
||||
// sideContent ? "rounded-bl-xl" : "rounded-xl",
|
||||
"bg-black/50",
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{showActions && (
|
||||
<Fragment>
|
||||
{IN_ELECTRON && (
|
||||
<ActionButton
|
||||
tooltip={t("common:words.download")}
|
||||
onClick={() => {
|
||||
ipcServices?.app.download(src)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-download-2-cute-re" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
tooltip={t(COPY_MAP.OpenInBrowser())}
|
||||
onClick={() => {
|
||||
window.open(src)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-external-link-cute-re" />
|
||||
</ActionButton>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RootPortal>
|
||||
</div>
|
||||
{!!sideContent && (
|
||||
<div
|
||||
className="bg-theme-background box-border flex h-full w-[400px] min-w-0 shrink-0 flex-col rounded-r-xl px-2 pt-1"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{sideContent}
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PreviewMediaProps extends MediaModel {
|
||||
fallbackUrl?: string
|
||||
}
|
||||
export const PreviewMediaContent: FC<{
|
||||
media: PreviewMediaProps[]
|
||||
initialIndex?: number
|
||||
children?: React.ReactNode
|
||||
}> = ({ media, initialIndex = 0, children }) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [
|
||||
WheelGesturesPlugin(),
|
||||
])
|
||||
const [currentMedia, setCurrentMedia] = useState(media[initialIndex])
|
||||
|
||||
// This only to delay show
|
||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setShowActions(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (emblaApi) {
|
||||
emblaApi.on("select", () => {
|
||||
const realIndex = emblaApi.selectedScrollSnap()
|
||||
setCurrentMedia(media[realIndex])
|
||||
setCurrentSlideIndex(realIndex)
|
||||
})
|
||||
}
|
||||
}, [emblaApi, media])
|
||||
|
||||
const { ref } = useCurrentModal()
|
||||
|
||||
// Keyboard
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
const $container = ref.current
|
||||
if (!$container) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") emblaApi?.scrollPrev()
|
||||
if (e.key === "ArrowRight") emblaApi?.scrollNext()
|
||||
}
|
||||
$container.addEventListener("keydown", handleKeyDown)
|
||||
return () => $container.removeEventListener("keydown", handleKeyDown)
|
||||
}, [emblaApi, ref])
|
||||
|
||||
if (media.length === 0) return null
|
||||
if (media.length === 1) {
|
||||
const src = media[0]!.url
|
||||
const { type } = media[0]!
|
||||
const isVideo = type === "video"
|
||||
return (
|
||||
<Wrapper src={src} showActions={!isVideo} sideContent={children}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
className={cn("h-full w-auto object-contain", !!children && "rounded-l-xl")}
|
||||
onClick={stopPropagation}
|
||||
/>
|
||||
) : (
|
||||
<FallbackableImage
|
||||
fallbackUrl={media[0]!.fallbackUrl}
|
||||
containerClassName="w-auto"
|
||||
className="h-full w-auto object-contain"
|
||||
alt="cover"
|
||||
src={src}
|
||||
height={media[0]!.height}
|
||||
width={media[0]!.width}
|
||||
blurhash={media[0]!.blurhash}
|
||||
haveSideContent={!!children}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
const isVideo = currentMedia!.type === "video"
|
||||
return (
|
||||
<Wrapper src={currentMedia!.url} showActions={!isVideo} sideContent={children}>
|
||||
<div className="size-full overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex size-full">
|
||||
{media.map((med) => (
|
||||
<div className="mr-2 flex w-full flex-none items-center justify-center" key={med.url}>
|
||||
{med.type === "video" ? (
|
||||
<VideoPlayer
|
||||
src={med.url}
|
||||
autoPlay
|
||||
muted
|
||||
controls
|
||||
className="size-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<FallbackableImage
|
||||
fallbackUrl={med.fallbackUrl}
|
||||
className="size-full object-contain"
|
||||
alt="cover"
|
||||
src={med.url}
|
||||
loading="lazy"
|
||||
height={med.height}
|
||||
width={med.width}
|
||||
blurhash={med.blurhash}
|
||||
haveSideContent={!!children}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div tabIndex={-1} onClick={stopPropagation}>
|
||||
<m.button
|
||||
initial={{ opacity: 0, transform: "translate3d(-20px, 0, 0) scale(0.94)" }}
|
||||
animate={{ opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" }}
|
||||
transition={{ ease: "easeInOut", duration: 0.2 }}
|
||||
whileTap={{ transform: "translate3d(0, 0, 0) scale(0.9)" }}
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
type="button"
|
||||
className="center absolute left-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
|
||||
>
|
||||
<i className="i-mingcute-arrow-left-line" />
|
||||
</m.button>
|
||||
|
||||
<m.button
|
||||
initial={{ opacity: 0, transform: "translate3d(20px, 0, 0) scale(0.94)" }}
|
||||
animate={{ opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" }}
|
||||
transition={{ ease: "easeInOut", duration: 0.2 }}
|
||||
whileTap={{ transform: "translate3d(0, 0, 0) scale(0.9)" }}
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
type="button"
|
||||
className="center absolute right-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
|
||||
>
|
||||
<i className="i-mingcute-arrow-right-line" />
|
||||
</m.button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"animate-in fade-in-0 slide-in-from-bottom-6 absolute left-4 text-sm tabular-nums text-white/60",
|
||||
isVideo ? "bottom-12" : "bottom-4",
|
||||
)}
|
||||
>
|
||||
{currentSlideIndex + 1} / {media.length}
|
||||
</div>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={stopPropagation}
|
||||
className={cn(
|
||||
"center animate-in fade-in-0 slide-in-from-bottom-6 absolute left-1/2 z-[99] h-6 -translate-x-1/2 gap-2 rounded-full bg-neutral-700/90 px-4 duration-200",
|
||||
isVideo ? "bottom-12" : "bottom-4",
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: media.length })
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
emblaApi?.scrollTo(index)
|
||||
}}
|
||||
type="button"
|
||||
key={index}
|
||||
className={cn(
|
||||
"inline-block size-[6px] rounded-full",
|
||||
currentSlideIndex === index ? "bg-white" : "bg-white/20",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function parseNumber(value: string | number | undefined) {
|
||||
return typeof value === "string" ? Number.parseInt(value) : value
|
||||
}
|
||||
|
||||
const FallbackableImage: FC<
|
||||
Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
src: string
|
||||
containerClassName?: string
|
||||
fallbackUrl?: string
|
||||
blurhash?: string
|
||||
haveSideContent?: boolean
|
||||
}
|
||||
> = ({ src, onError, fallbackUrl, containerClassName, blurhash, haveSideContent, ...props }) => {
|
||||
const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))
|
||||
const [isAllError, setIsAllError] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [currentState, setCurrentState] = useState<"proxy" | "origin" | "fallback">(() =>
|
||||
currentSrc === src ? "origin" : "proxy",
|
||||
)
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
switch (currentState) {
|
||||
case "proxy": {
|
||||
if (currentSrc !== src) {
|
||||
setCurrentSrc(src)
|
||||
setCurrentState("origin")
|
||||
} else {
|
||||
if (fallbackUrl) {
|
||||
setCurrentSrc(fallbackUrl)
|
||||
setCurrentState("fallback")
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case "origin": {
|
||||
if (fallbackUrl) {
|
||||
setCurrentSrc(fallbackUrl)
|
||||
setCurrentState("fallback")
|
||||
} else {
|
||||
setIsAllError(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "fallback": {
|
||||
setIsAllError(true)
|
||||
}
|
||||
}
|
||||
}, [currentSrc, currentState, fallbackUrl, src])
|
||||
|
||||
const height = parseNumber(props.height)
|
||||
const width = parseNumber(props.width)
|
||||
|
||||
const { height: windowHeight, width: windowWidth } = useWindowSize()
|
||||
// px-20 pb-8 pt-10
|
||||
// wrapper side content w-[400px]
|
||||
const maxContainerHeight = windowHeight - 32 - 40
|
||||
const maxContainerWidth = windowWidth - 80 - 80 - (haveSideContent ? 400 : 0)
|
||||
|
||||
const [zoomingState, setZoomingState] = useState<"zoom-in" | "zoom-out" | null>(null)
|
||||
const [zoomContainerWidth, setZoomContainerWidth] = useState(0)
|
||||
|
||||
const wrapperClass = cn("relative !max-h-full", width && height && width <= height && "!h-full")
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width:
|
||||
width && height && width > height
|
||||
? `${Math.min(maxContainerHeight * (width / height), width)}px`
|
||||
: undefined,
|
||||
maxWidth: width && height && width > height ? `${maxContainerWidth}px` : undefined,
|
||||
}
|
||||
|
||||
const [ref, { width: imgWidth }] = useMeasure()
|
||||
|
||||
return (
|
||||
<div className={cn("center flex size-full flex-col", containerClassName)}>
|
||||
{!isAllError && (
|
||||
<TransformWrapper
|
||||
wheel={{ smoothStep: 0.008 }}
|
||||
onZoom={(e) => {
|
||||
if (e.state.scale !== 1) {
|
||||
setZoomingState(e.state.scale > 1 ? "zoom-in" : "zoom-out")
|
||||
} else {
|
||||
setZoomingState(null)
|
||||
}
|
||||
setZoomContainerWidth(Math.min(maxContainerWidth, e.state.scale * imgWidth))
|
||||
}}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperClass={wrapperClass}
|
||||
wrapperStyle={{
|
||||
...wrapperStyle,
|
||||
minWidth:
|
||||
zoomingState === "zoom-in" && !haveSideContent
|
||||
? `${zoomContainerWidth}px`
|
||||
: undefined,
|
||||
height: zoomingState === "zoom-in" ? "100%" : undefined,
|
||||
}}
|
||||
contentClass={wrapperClass}
|
||||
contentStyle={wrapperStyle}
|
||||
wrapperProps={{
|
||||
onClick: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={ref}
|
||||
data-blurhash={blurhash}
|
||||
src={currentSrc}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={handleError}
|
||||
height={props.height}
|
||||
width={props.width}
|
||||
{...props}
|
||||
className={cn(
|
||||
// See https://github.com/BetterTyped/react-zoom-pan-pinch/issues/135#issuecomment-683463453
|
||||
// See also https://github.com/RSSNext/Folo/issues/3183
|
||||
"!pointer-events-auto mx-auto transition-opacity duration-700",
|
||||
isLoading ? "opacity-0" : "opacity-100",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
maxHeight: `${maxContainerHeight}px`,
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"center pointer-events-none absolute inset-0 size-full transition-opacity duration-700",
|
||||
isLoading ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{blurhash ? (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
resolutionX={32}
|
||||
resolutionY={32}
|
||||
className="!size-full"
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-white/80" />
|
||||
) : null}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)}
|
||||
{isAllError && (
|
||||
<div
|
||||
className="center pointer-events-none absolute inset-0 flex-col gap-6"
|
||||
onClick={stopPropagation}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re text-[60px] text-red-400" />
|
||||
|
||||
<span>Failed to load image</span>
|
||||
<div className="center gap-4">
|
||||
<MotionButtonBase
|
||||
className="pointer-events-auto underline underline-offset-4"
|
||||
onClick={() => {
|
||||
setCurrentSrc(replaceImgUrlIfNeed(src))
|
||||
setIsAllError(false)
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</MotionButtonBase>
|
||||
or
|
||||
<a
|
||||
className="pointer-events-auto underline underline-offset-4"
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Visit Original
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentState === "fallback" && (
|
||||
<div className="mt-4 text-center text-xs text-white/60">
|
||||
<span>
|
||||
This image is preview in low quality, because the original image is not available.
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
You can{" "}
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-accent underline duration-200"
|
||||
>
|
||||
visit the original image
|
||||
</a>{" "}
|
||||
if you want to see the full quality.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import type { MotionProps, Target } from "motion/react"
|
||||
import type { MotionProps, TargetAndTransition } from "motion/react"
|
||||
|
||||
const enterStyle: Target = {
|
||||
const enterStyle: TargetAndTransition = {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}
|
||||
const initialStyle: Target = {
|
||||
const initialStyle: TargetAndTransition = {
|
||||
scale: 0.96,
|
||||
opacity: 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
|
|
@ -85,12 +86,7 @@ export const DrawerModalLayout: FC<PropsWithChildren> = ({ children }) => {
|
|||
initial="initial"
|
||||
animate={controller}
|
||||
variants={modalVariant}
|
||||
transition={{
|
||||
type: "spring",
|
||||
mass: 0.4,
|
||||
tension: 100,
|
||||
friction: 1,
|
||||
}}
|
||||
transition={Spring.presets.snappy}
|
||||
onAnimationComplete={(definition) => {
|
||||
if (definition === "exit") {
|
||||
dismiss()
|
||||
|
|
@ -117,12 +113,7 @@ export const ScaleModal: ModalTemplateType = (props) => {
|
|||
<div className={"center container h-full"} onPointerDown={dismiss} onClick={stopPropagation}>
|
||||
<m.div
|
||||
onPointerDown={stopPropagation}
|
||||
transition={{
|
||||
type: "spring",
|
||||
mass: 0.4,
|
||||
tension: 100,
|
||||
friction: 1,
|
||||
}}
|
||||
transition={Spring.presets.snappy}
|
||||
initial={{ transform: "scale(0)", opacity: 0 }}
|
||||
animate={{ transform: "scale(1)", opacity: 1 }}
|
||||
exit={{ transform: "scale(0.6)", opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -126,13 +126,48 @@ export const useDialog = (): DialogInstance => {
|
|||
const { present } = useModalStack()
|
||||
const { t } = useTranslation()
|
||||
return {
|
||||
/**
|
||||
* Show a confirmation dialog with different visual variants
|
||||
* @param options.variant - Visual style variant:
|
||||
* - "ask" (default): Standard confirmation dialog
|
||||
* - "warning": Warning dialog with yellow icon and yellow confirm button
|
||||
* - "danger": Danger dialog with red icon and red confirm button
|
||||
*/
|
||||
ask: useEventCallback((options) => {
|
||||
const variant = options.variant || "ask"
|
||||
|
||||
// Variant-specific configuration
|
||||
const variantConfig = {
|
||||
ask: {
|
||||
icon: null,
|
||||
confirmVariant: "primary" as const,
|
||||
confirmClassName: "",
|
||||
},
|
||||
warning: {
|
||||
icon: <i className="i-mingcute-warning-fill size-5 text-yellow-500" />,
|
||||
confirmVariant: "primary" as const,
|
||||
confirmClassName: "bg-yellow-600 hover:bg-yellow-700",
|
||||
},
|
||||
danger: {
|
||||
icon: <i className="i-mingcute-warning-fill size-5 text-red-500" />,
|
||||
confirmVariant: "primary" as const,
|
||||
confirmClassName: "bg-red-600 hover:bg-red-700",
|
||||
},
|
||||
}
|
||||
|
||||
const config = variantConfig[variant]
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
present({
|
||||
title: options.title,
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
{config.icon}
|
||||
<span>{options.title}</span>
|
||||
</div>
|
||||
),
|
||||
content: ({ dismiss }) => (
|
||||
<div className="flex max-w-[75ch] flex-col gap-3">
|
||||
{options.message}
|
||||
<div className="flex max-w-prose flex-col gap-3">
|
||||
<div className="whitespace-pre text-wrap">{options.message}</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
|
|
@ -146,6 +181,8 @@ export const useDialog = (): DialogInstance => {
|
|||
{options.cancelText ?? t("words.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.confirmVariant}
|
||||
buttonClassName={config.confirmClassName}
|
||||
onClick={() => {
|
||||
options.onConfirm?.()
|
||||
resolve(true)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface DialogInstance {
|
|||
ask: (options: {
|
||||
title: string
|
||||
message: string
|
||||
variant?: "ask" | "warning" | "danger"
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
confirmText?: string
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const Paper: Component<{
|
|||
className={cn(
|
||||
"bg-background relative md:col-start-1 lg:col-auto",
|
||||
"-m-4 p-[2rem_1rem] md:m-0 lg:p-[30px_45px]",
|
||||
"border-border rounded-[0_6px_6px_0] lg:border",
|
||||
"border-border rounded-lg lg:border",
|
||||
"shadow-perfect perfect-sm",
|
||||
"min-w-0",
|
||||
"print:!border-none print:!bg-transparent print:!shadow-none",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { clsx } from "clsx"
|
||||
import { clsx } from "@follow/utils/utils"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { Target, Transition } from "motion/react"
|
||||
import type { TargetAndTransition, Transition } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { cloneElement, useEffect, useState } from "react"
|
||||
|
||||
type TransitionType = {
|
||||
initial: Target | boolean
|
||||
animate: Target
|
||||
exit: Target
|
||||
initial: TargetAndTransition | boolean
|
||||
animate: TargetAndTransition
|
||||
exit: TargetAndTransition
|
||||
}
|
||||
|
||||
type IconTransitionProps = {
|
||||
|
|
@ -93,9 +93,9 @@ const Presets = {
|
|||
export const IconTransition = (
|
||||
props: React.PropsWithChildren<{
|
||||
animatedKey: string
|
||||
initial?: Target
|
||||
animate?: Target
|
||||
exit?: Target
|
||||
initial?: TargetAndTransition
|
||||
animate?: TargetAndTransition
|
||||
exit?: TargetAndTransition
|
||||
transition?: Transition
|
||||
|
||||
preset?: "fade"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,3 @@ export const ROUTE_FEED_IN_FOLDER = "folder-"
|
|||
export const ROUTE_FEED_IN_LIST = "list-"
|
||||
export const ROUTE_FEED_IN_INBOX = "inbox-"
|
||||
export const ROUTE_TIMELINE_OF_VIEW = "view-"
|
||||
|
||||
// Inbox subscription's feedId is `inbox-${inboxId}`, we need to convert it between unread and entry store.
|
||||
export const INBOX_PREFIX_ID = "inbox-"
|
||||
export const getInboxOrFeedIdFromFeedId = (id: string) =>
|
||||
id.startsWith(INBOX_PREFIX_ID) ? id.slice(INBOX_PREFIX_ID.length) : id
|
||||
export const getInboxIdWithPrefix = (id: string) =>
|
||||
id.startsWith(INBOX_PREFIX_ID) ? id : INBOX_PREFIX_ID + id
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const LOCAL_DB_NAME = "FOLLOW_DB"
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { afterEach } from "node:test"
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { browserDB } from "./db"
|
||||
|
||||
describe("upgradeToV8", () => {
|
||||
afterEach(async () => {
|
||||
await browserDB.delete()
|
||||
})
|
||||
|
||||
it("should set tipUsers to an empty array if tipUsers is not an array", async () => {
|
||||
const insertFeeds = [
|
||||
{ id: 1, tipUsers: {} },
|
||||
{ id: 2, tipUsers: null },
|
||||
{ id: 3, tipUsers: [{ name: "user1" }] },
|
||||
]
|
||||
// @ts-expect-error
|
||||
await browserDB.feeds.bulkAdd(insertFeeds)
|
||||
|
||||
const feeds = await browserDB.feeds.toArray()
|
||||
expect(feeds.length).toEqual(3)
|
||||
expect(feeds[0]!.tipUsers).toEqual(insertFeeds[0]!.tipUsers)
|
||||
expect(feeds[1]!.tipUsers).toEqual(insertFeeds[1]!.tipUsers)
|
||||
expect(feeds[2]!.tipUsers).toEqual(insertFeeds[2]!.tipUsers)
|
||||
|
||||
await browserDB.transaction("rw", [browserDB.feeds], async (tx) => {
|
||||
await browserDB.upgradeToV8(tx)
|
||||
})
|
||||
const feedsAfterMigrate = await browserDB.feeds.toArray()
|
||||
expect(feedsAfterMigrate.length).toEqual(3)
|
||||
expect(feedsAfterMigrate[0]!.tipUsers).toEqual([])
|
||||
expect(feedsAfterMigrate[1]!.tipUsers).toEqual(null)
|
||||
expect(feedsAfterMigrate[2]!.tipUsers).toEqual([{ name: "user1" }])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import type { Transaction } from "dexie"
|
||||
import Dexie from "dexie"
|
||||
|
||||
import { LOCAL_DB_NAME } from "./constants"
|
||||
import {
|
||||
dbSchemaV1,
|
||||
dbSchemaV2,
|
||||
dbSchemaV3,
|
||||
dbSchemaV4,
|
||||
dbSchemaV5,
|
||||
dbSchemaV6,
|
||||
dbSchemaV7,
|
||||
dbSchemaV8,
|
||||
} from "./db_schema"
|
||||
import type { DB_Cleaner } from "./schemas/cleaner"
|
||||
import type { DB_Entry, DB_EntryRelated } from "./schemas/entry"
|
||||
import type { DB_Feed, DB_FeedUnread } from "./schemas/feed"
|
||||
import type { DB_Inbox } from "./schemas/inbox"
|
||||
import type { DB_List } from "./schemas/list"
|
||||
import type { DB_Subscription } from "./schemas/subscription"
|
||||
|
||||
export interface LocalDBSchemaMap {
|
||||
entries: DB_Entry
|
||||
feeds: DB_Feed
|
||||
subscriptions: DB_Subscription
|
||||
entryRelated: DB_EntryRelated
|
||||
feedUnreads: DB_FeedUnread
|
||||
cleaner: DB_Cleaner
|
||||
lists: DB_List
|
||||
inboxes: DB_Inbox
|
||||
}
|
||||
|
||||
// Define a local DB
|
||||
class BrowserDB extends Dexie {
|
||||
public entries: BrowserDBTable<"entries">
|
||||
public feeds: BrowserDBTable<"feeds">
|
||||
public subscriptions: BrowserDBTable<"subscriptions">
|
||||
public entryRelated: BrowserDBTable<"entryRelated">
|
||||
public feedUnreads: BrowserDBTable<"feedUnreads">
|
||||
public lists: BrowserDBTable<"lists">
|
||||
public inboxes: BrowserDBTable<"inboxes">
|
||||
public cleaner: BrowserDBTable<"cleaner">
|
||||
|
||||
constructor() {
|
||||
super(LOCAL_DB_NAME)
|
||||
this.version(1).stores(dbSchemaV1)
|
||||
this.version(2).stores(dbSchemaV2).upgrade(this.upgradeToV2)
|
||||
this.version(3).stores(dbSchemaV3)
|
||||
this.version(4).stores(dbSchemaV4)
|
||||
this.version(5).stores(dbSchemaV5)
|
||||
this.version(6).stores(dbSchemaV6)
|
||||
this.version(7).stores(dbSchemaV7)
|
||||
this.version(8).stores(dbSchemaV8).upgrade(this.upgradeToV8)
|
||||
|
||||
this.entries = this.table("entries")
|
||||
this.feeds = this.table("feeds")
|
||||
this.subscriptions = this.table("subscriptions")
|
||||
this.entryRelated = this.table("entryRelated")
|
||||
this.feedUnreads = this.table("feedUnreads")
|
||||
this.cleaner = this.table("cleaner")
|
||||
this.lists = this.table("lists")
|
||||
this.inboxes = this.table("inboxes")
|
||||
}
|
||||
|
||||
async upgradeToV2(trans: Transaction) {
|
||||
const session = trans.table("feedUnreads")
|
||||
session.delete("feedId")
|
||||
}
|
||||
|
||||
async upgradeToV8(trans: Transaction) {
|
||||
// Fix https://github.com/RSSNext/Follow/issues/1308
|
||||
const session = trans.table("feeds")
|
||||
return session.toCollection().modify((feed) => {
|
||||
if (!feed.tipUsers || Array.isArray(feed.tipUsers)) return
|
||||
feed.tipUsers = []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const browserDB = new BrowserDB()
|
||||
|
||||
export const exportDB = async () => {
|
||||
await import("dexie-export-import")
|
||||
const blob = await browserDB.export({ prettyJson: true })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${LOCAL_DB_NAME}.json`
|
||||
a.click()
|
||||
}
|
||||
|
||||
// ================================================ //
|
||||
// ================================================ //
|
||||
// ================================================ //
|
||||
// ================================================ //
|
||||
// ================================================ //
|
||||
|
||||
// types helper
|
||||
export type BrowserDBSchema = {
|
||||
[t in keyof LocalDBSchemaMap]: {
|
||||
model: LocalDBSchemaMap[t]
|
||||
table: Dexie.Table<LocalDBSchemaMap[t], string>
|
||||
}
|
||||
}
|
||||
type BrowserDBTable<T extends keyof LocalDBSchemaMap> = BrowserDBSchema[T]["table"]
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
export const dbSchemaV1 = {
|
||||
entries: "&id",
|
||||
feeds: "&id",
|
||||
subscriptions: "&id",
|
||||
entryRelated: "&id",
|
||||
feedUnreads: "&id",
|
||||
}
|
||||
|
||||
export const dbSchemaV2 = {
|
||||
...dbSchemaV1,
|
||||
subscriptions: "&id, userId, feedId",
|
||||
}
|
||||
|
||||
export const dbSchemaV3 = {
|
||||
...dbSchemaV2,
|
||||
feedEntries: null,
|
||||
|
||||
subscriptions: "&id, userId, &feedId",
|
||||
}
|
||||
|
||||
export const dbSchemaV4 = {
|
||||
...dbSchemaV3,
|
||||
entries: "&id, feedId",
|
||||
subscriptions: "&id, userId, feedId",
|
||||
}
|
||||
|
||||
export const dbSchemaV5 = {
|
||||
...dbSchemaV4,
|
||||
cleaner: "&refId, visitedAt",
|
||||
}
|
||||
|
||||
export const dbSchemaV6 = {
|
||||
...dbSchemaV5,
|
||||
lists: "&id, title",
|
||||
}
|
||||
|
||||
export const dbSchemaV7 = {
|
||||
...dbSchemaV6,
|
||||
inboxes: "&id",
|
||||
}
|
||||
|
||||
export const dbSchemaV8 = dbSchemaV7
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
declare global {
|
||||
// This flag controls write data in indexedDB, if it's false, pass data insert to db
|
||||
// When app not ready, it's false, after hydrate data, it's true
|
||||
// Or set is false when disable indexedDB in setting
|
||||
|
||||
export let __dbIsReady: boolean
|
||||
|
||||
interface Window {
|
||||
__dbIsReady: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { browserDB } from "./db"
|
||||
|
||||
export * from "./db"
|
||||
export * from "./schemas"
|
||||
|
||||
export const DB_NOT_READY_OR_DISABLED = "Database is not ready or disabled"
|
||||
/**
|
||||
* @description Check if database is ready
|
||||
* If users disabled data persist, it's always false, that means you can't do operation with database.
|
||||
*
|
||||
*/
|
||||
|
||||
export const runTransactionInScope = <T>(
|
||||
fn: (db: typeof browserDB) => T,
|
||||
): T | typeof DB_NOT_READY_OR_DISABLED => {
|
||||
if (!window.__dbIsReady) {
|
||||
// Or, push to waiting queue
|
||||
return DB_NOT_READY_OR_DISABLED
|
||||
}
|
||||
|
||||
return fn(browserDB)
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export const DB_BaseSchema = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type DB_Base = z.infer<typeof DB_BaseSchema>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export type CleanerType = "feed" | "entry" | "list" | "inbox"
|
||||
export type DB_Cleaner = {
|
||||
refId: string
|
||||
visitedAt: number
|
||||
type: CleanerType
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import type { EntryModel } from "@follow/models/types"
|
||||
|
||||
export type DB_Entry = EntryModel & { feedId: string }
|
||||
|
||||
export type DB_EntryRelated = {
|
||||
id: string
|
||||
data: any
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import type { FeedModel } from "@follow/models/types"
|
||||
|
||||
export type DB_FeedUnread = {
|
||||
id: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type DB_Feed = FeedModel & { id: string }
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export type DB_Inbox = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./base"
|
||||
export * from "./feed"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export type DB_List = {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
description: string
|
||||
fee: number
|
||||
image: string
|
||||
ownerUserId: string
|
||||
timelineUpdatedAt: string
|
||||
feedIds: string[]
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import type { SubscriptionFlatModel } from "~/store/subscription"
|
||||
|
||||
export type DB_Subscription = SubscriptionFlatModel & {
|
||||
id: string
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType, UserRole, views } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { useIsEntryStarred } from "@follow/store/collection/hooks"
|
||||
import { getEntry } from "@follow/store/entry/getter"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { entrySyncServices } from "@follow/store/entry/store"
|
||||
import type { EntryModel } from "@follow/store/entry/types"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { useIsInbox } from "@follow/store/inbox/hooks"
|
||||
import { doesTextContainHTML } from "@follow/utils/utils"
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
|
@ -8,26 +15,19 @@ import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
|
|||
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
|
||||
import { MENU_ITEM_SEPARATOR, MenuItemSeparator, MenuItemText } from "~/atoms/context-menu"
|
||||
import {
|
||||
getReadabilityContent,
|
||||
getReadabilityStatus,
|
||||
ReadabilityStatus,
|
||||
setReadabilityContent,
|
||||
setReadabilityStatus,
|
||||
useEntryIsInReadability,
|
||||
} from "~/atoms/readability"
|
||||
import { useShowSourceContent } from "~/atoms/source-content"
|
||||
import { useUserRole, whoami } from "~/atoms/user"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { getCommand, useRunCommandFn } from "~/modules/command/hooks/use-command"
|
||||
import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding"
|
||||
import type { FollowCommandId } from "~/modules/command/types"
|
||||
import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => {
|
||||
const status = getReadabilityStatus()[id]
|
||||
|
|
@ -45,39 +45,22 @@ export const toggleEntryReadability = async ({ id, url }: { id: string; url: str
|
|||
[id]: ReadabilityStatus.WAITING,
|
||||
})
|
||||
try {
|
||||
let data = getReadabilityContent()[id]
|
||||
const data = getEntry(id)?.readabilityContent
|
||||
|
||||
if (!data) {
|
||||
const result = await apiClient.entries.readability.$get({ query: { id } })
|
||||
if (result.data) {
|
||||
data = result.data
|
||||
}
|
||||
await entrySyncServices.fetchEntryReadabilityContent(id, async () => {
|
||||
const res = await ipcServices?.reader.readability({ url })
|
||||
return res?.content
|
||||
})
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const status = getReadabilityStatus()[id]
|
||||
if (status !== ReadabilityStatus.WAITING) return
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.SUCCESS,
|
||||
})
|
||||
setReadabilityContent({
|
||||
[id]: data,
|
||||
})
|
||||
}
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.SUCCESS,
|
||||
})
|
||||
} catch {
|
||||
const result = await ipcServices?.reader.readability({ url })
|
||||
if (result) {
|
||||
setReadabilityContent({
|
||||
[id]: result,
|
||||
})
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.SUCCESS,
|
||||
})
|
||||
} else {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.FAILURE,
|
||||
})
|
||||
}
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.FAILURE,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setReadabilityStatus({
|
||||
|
|
@ -137,28 +120,26 @@ export class EntryActionMenuItem extends MenuItemText {
|
|||
}
|
||||
export type EntryActionItem = EntryActionMenuItem | MenuItemSeparator
|
||||
|
||||
const entrySelector = (state: FlatEntryModel) => {
|
||||
const content = state.entries.content || ""
|
||||
const entrySelector = (state: EntryModel) => {
|
||||
const content = state.content || ""
|
||||
const hasContent = !!content
|
||||
const doesContentContainsHTMLTags = doesTextContainHTML(content)
|
||||
|
||||
const { summary, translation, readability } = state.settings || {}
|
||||
|
||||
const media = state.entries.media || []
|
||||
const media = state.media || []
|
||||
const images = media.filter((a) => a.type === "photo")
|
||||
const imagesLength = images.length
|
||||
|
||||
return {
|
||||
feedId: state.feedId,
|
||||
inboxId: state.inboxId,
|
||||
url: state.entries.url,
|
||||
publishedAt: state.entries.publishedAt,
|
||||
view: state.view,
|
||||
inboxId: state.inboxHandle,
|
||||
url: state.url,
|
||||
publishedAt: state.publishedAt.toISOString(),
|
||||
read: state.read,
|
||||
summary,
|
||||
translation,
|
||||
readability,
|
||||
isInCollection: !!state.collections,
|
||||
hasContent,
|
||||
doesContentContainsHTMLTags,
|
||||
imagesLength,
|
||||
|
|
@ -171,10 +152,11 @@ export const useEntryActions = ({
|
|||
compact,
|
||||
}: {
|
||||
entryId: string
|
||||
view?: FeedViewType
|
||||
view: FeedViewType
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const entry = useEntry(entryId, entrySelector)
|
||||
const isInCollection = useIsEntryStarred(entryId)
|
||||
const isEntryInReadability = useEntryIsInReadability(entryId)
|
||||
|
||||
const feed = useFeedById(entry?.feedId, (feed) => {
|
||||
|
|
@ -186,8 +168,7 @@ export const useEntryActions = ({
|
|||
}
|
||||
})
|
||||
|
||||
const inbox = useInboxById(entry?.inboxId)
|
||||
const isInbox = !!inbox
|
||||
const isInbox = useIsInbox(entry?.inboxId)
|
||||
const isShowSourceContent = useShowSourceContent()
|
||||
const isShowAISummaryAuto = useShowAISummaryAuto(entry?.summary)
|
||||
const isShowAISummaryOnce = useShowAISummaryOnce()
|
||||
|
|
@ -258,7 +239,7 @@ export const useEntryActions = ({
|
|||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.entry.star,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.star, [{ entryId, view }]),
|
||||
active: entry.isInCollection,
|
||||
active: isInCollection,
|
||||
shortcut: shortcuts[COMMAND_ID.entry.star],
|
||||
entryId,
|
||||
}),
|
||||
|
|
@ -307,7 +288,7 @@ export const useEntryActions = ({
|
|||
hide:
|
||||
isShowAISummaryAuto ||
|
||||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
|
||||
entry.view,
|
||||
view,
|
||||
),
|
||||
active: isShowAISummaryOnce,
|
||||
disabled: userRole === UserRole.Trial,
|
||||
|
|
@ -319,7 +300,7 @@ export const useEntryActions = ({
|
|||
hide:
|
||||
isShowAITranslationAuto ||
|
||||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
|
||||
entry.view,
|
||||
view,
|
||||
),
|
||||
active: isShowAITranslationOnce,
|
||||
disabled: userRole === UserRole.Trial,
|
||||
|
|
@ -335,13 +316,13 @@ export const useEntryActions = ({
|
|||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.entry.readAbove,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.publishedAt }]),
|
||||
hide: !!entry.isInCollection,
|
||||
hide: !!isInCollection,
|
||||
entryId,
|
||||
}),
|
||||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.entry.read,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),
|
||||
hide: !!entry.isInCollection,
|
||||
hide: !!isInCollection,
|
||||
active: !!entry.read,
|
||||
shortcut: shortcuts[COMMAND_ID.entry.read],
|
||||
entryId,
|
||||
|
|
@ -349,7 +330,7 @@ export const useEntryActions = ({
|
|||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.entry.readBelow,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.publishedAt }]),
|
||||
hide: !!entry.isInCollection,
|
||||
hide: !!isInCollection,
|
||||
entryId,
|
||||
}),
|
||||
MENU_ITEM_SEPARATOR,
|
||||
|
|
@ -399,11 +380,10 @@ export const useEntryActions = ({
|
|||
isInbox,
|
||||
shortcuts,
|
||||
view,
|
||||
entry?.isInCollection,
|
||||
isInCollection,
|
||||
entry?.url,
|
||||
entry?.publishedAt,
|
||||
entry?.hasContent,
|
||||
entry?.view,
|
||||
entry?.read,
|
||||
entry?.readability,
|
||||
entry?.imagesLength,
|
||||
|
|
@ -427,7 +407,7 @@ export const useSortedEntryActions = ({
|
|||
compact,
|
||||
}: {
|
||||
entryId: string
|
||||
view?: FeedViewType
|
||||
view: FeedViewType
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const entryActions = useEntryActions({ entryId, view, compact })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { getFeedById } from "@follow/store/feed/getter"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { useInboxById, useIsInbox } from "@follow/store/inbox/hooks"
|
||||
import { useListById, useOwnedListByView } from "@follow/store/list/hooks"
|
||||
import { listSyncServices } from "@follow/store/list/store"
|
||||
import {
|
||||
useCategoriesByView,
|
||||
useSubscriptionByFeedId,
|
||||
useSubscriptionsByFeedIds,
|
||||
} from "@follow/store/subscription/hooks"
|
||||
import { unreadSyncService } from "@follow/store/unread/store"
|
||||
import { isBizId } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
|
|
@ -12,7 +23,7 @@ import { MenuItemSeparator, MenuItemText } from "~/atoms/context-menu"
|
|||
import { useIsInMASReview } from "~/atoms/server-configs"
|
||||
import { whoami } from "~/atoms/user"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { UrlBuilder } from "~/lib/url-builder"
|
||||
import { useBoostModal } from "~/modules/boost/hooks"
|
||||
import { useFeedClaimModal } from "~/modules/claim"
|
||||
|
|
@ -25,15 +36,6 @@ import { useConfirmUnsubscribeSubscriptionModal } from "~/modules/modal/hooks/us
|
|||
import { useCategoryCreationModal } from "~/modules/settings/tabs/lists/hooks"
|
||||
import { ListCreationModalContent } from "~/modules/settings/tabs/lists/modals"
|
||||
import { useResetFeed } from "~/queries/feed"
|
||||
import { getFeedById, useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
import { listActions, useListById, useOwnedListByView } from "~/store/list"
|
||||
import {
|
||||
subscriptionActions,
|
||||
useCategoriesByView,
|
||||
useSubscriptionByFeedId,
|
||||
useSubscriptionsByFeedIds,
|
||||
} from "~/store/subscription"
|
||||
|
||||
import { useNavigateEntry } from "./useNavigateEntry"
|
||||
import { getRouteParams } from "./useRouteParams"
|
||||
|
|
@ -63,7 +65,7 @@ export const useFeedActions = ({
|
|||
|
||||
const inbox = useInboxById(feedId)
|
||||
const isInbox = !!inbox
|
||||
const subscription = useSubscriptionByFeedId(feedId)!
|
||||
const subscription = useSubscriptionByFeedId(feedId)
|
||||
|
||||
const subscriptions = useSubscriptionsByFeedIds(
|
||||
useMemo(() => feedIds || [feedId], [feedId, feedIds]),
|
||||
|
|
@ -103,10 +105,7 @@ export const useFeedActions = ({
|
|||
label: t("sidebar.feed_actions.mark_all_as_read"),
|
||||
shortcut: shortcuts[COMMAND_ID.subscription.markAllAsRead],
|
||||
disabled: isEntryList,
|
||||
click: () =>
|
||||
subscriptionActions.markReadByIds({
|
||||
feedIds: isMultipleSelection ? feedIds : [feedId],
|
||||
}),
|
||||
click: () => unreadSyncService.markFeedAsRead(isMultipleSelection ? feedIds : [feedId]),
|
||||
supportMultipleSelection: true,
|
||||
}),
|
||||
!related.ownerUserId &&
|
||||
|
|
@ -295,7 +294,7 @@ export const useFeedActions = ({
|
|||
const { url, siteUrl } = feed || {}
|
||||
const copied = url || siteUrl
|
||||
if (!copied) return
|
||||
navigator.clipboard.writeText(copied)
|
||||
copyToClipboard(copied)
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
|
|
@ -303,14 +302,14 @@ export const useFeedActions = ({
|
|||
shortcut: "$mod+Shift+C",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(feedId)
|
||||
copyToClipboard(feedId)
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
label: t("sidebar.feed_actions.copy_feed_badge"),
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(
|
||||
copyToClipboard(
|
||||
`https://badge.follow.is/feed/${feedId}?color=FF5C00&labelColor=black&style=flat-square`,
|
||||
)
|
||||
},
|
||||
|
|
@ -386,9 +385,7 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
|
|||
label: t("sidebar.feed_actions.mark_all_as_read"),
|
||||
shortcut: shortcuts[COMMAND_ID.subscription.markAllAsRead],
|
||||
click: () => {
|
||||
subscriptionActions.markReadByIds({
|
||||
feedIds: list.feedIds,
|
||||
})
|
||||
unreadSyncService.markFeedAsRead(list.feedIds)
|
||||
},
|
||||
}),
|
||||
MenuItemSeparator.default,
|
||||
|
|
@ -428,14 +425,14 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
|
|||
label: t("sidebar.feed_actions.copy_list_url"),
|
||||
shortcut: "$mod+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(UrlBuilder.shareList(listId, view))
|
||||
copyToClipboard(UrlBuilder.shareList(listId, view))
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
label: t("sidebar.feed_actions.copy_list_id"),
|
||||
shortcut: "$mod+Shift+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(listId)
|
||||
copyToClipboard(listId)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
@ -448,11 +445,11 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
|
|||
|
||||
export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const inbox = useInboxById(inboxId)
|
||||
const isInbox = useIsInbox(inboxId)
|
||||
const { present } = useModalStack()
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!inbox) return []
|
||||
if (!isInbox) return []
|
||||
|
||||
const items: FollowMenuItem[] = [
|
||||
new MenuItemText({
|
||||
|
|
@ -461,7 +458,7 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
|
|||
click: () => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_inbox"),
|
||||
content: ({ dismiss }) => <InboxForm asWidget id={inboxId} onSuccess={dismiss} />,
|
||||
content: () => <InboxForm asWidget id={inboxId} />,
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
|
@ -470,13 +467,13 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
|
|||
label: t("sidebar.feed_actions.copy_email_address"),
|
||||
shortcut: "$mod+Shift+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(`${inboxId}${env.VITE_INBOXES_EMAIL}`)
|
||||
copyToClipboard(`${inboxId}${env.VITE_INBOXES_EMAIL}`)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
return items
|
||||
}, [inbox, t, inboxId, present])
|
||||
}, [isInbox, t, inboxId, present])
|
||||
|
||||
return { items }
|
||||
}
|
||||
|
|
@ -490,11 +487,7 @@ export const useAddFeedToFeedList = (options?: {
|
|||
mutationFn: async (
|
||||
payload: { feedId: string; listId: string } | { feedIds: string[]; listId: string },
|
||||
) => {
|
||||
const feeds = await apiClient.lists.feeds.$post({
|
||||
json: payload,
|
||||
})
|
||||
|
||||
feeds.data.forEach((feed) => listActions.addFeedToFeedList(payload.listId, feed))
|
||||
await listSyncServices.addFeedsToFeedList(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.add.success"))
|
||||
|
|
@ -515,13 +508,7 @@ export const useRemoveFeedFromFeedList = (options?: {
|
|||
const { t } = useTranslation("settings")
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { feedId: string; listId: string }) => {
|
||||
listActions.removeFeedFromFeedList(payload.listId, payload.feedId)
|
||||
await apiClient.lists.feeds.$delete({
|
||||
json: {
|
||||
listId: payload.listId,
|
||||
feedId: payload.feedId,
|
||||
},
|
||||
})
|
||||
await listSyncServices.removeFeedFromFeedList(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.delete.success"))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
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 { t } from "i18next"
|
||||
import { useCallback } from "react"
|
||||
import { withoutTrailingSlash, withTrailingSlash } from "ufo"
|
||||
|
|
@ -13,12 +19,6 @@ 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"
|
||||
import { getFeedByIdOrUrl } from "~/store/feed"
|
||||
import {
|
||||
getSubscriptionByFeedId,
|
||||
useFeedSubscriptionCount,
|
||||
useListSubscriptionCount,
|
||||
} from "~/store/subscription"
|
||||
|
||||
const useCanFollowMoreInboxAndNotify = () => {
|
||||
const role = useUserRole()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/at
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { useSheetContext } from "@follow/components/ui/sheet/context.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { getSubscriptionByFeedId } from "@follow/store/subscription/getter"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { nextFrame } from "@follow/utils"
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -18,14 +20,13 @@ import {
|
|||
ROUTE_FEED_PENDING,
|
||||
ROUTE_TIMELINE_OF_VIEW,
|
||||
} from "~/constants"
|
||||
import { getSubscriptionByFeedId } from "~/store/subscription"
|
||||
|
||||
export type NavigateEntryOptions = Partial<{
|
||||
timelineId: string
|
||||
feedId: string | null
|
||||
entryId: string | null
|
||||
view: FeedViewType
|
||||
folderName: string
|
||||
folderName: string | null
|
||||
inboxId: string
|
||||
listId: string
|
||||
backPath: string
|
||||
|
|
@ -126,10 +127,13 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
|
|||
timelineId: parsedOptions.timelineId,
|
||||
})
|
||||
|
||||
resetShowSourceContent()
|
||||
disableShowAISummaryOnce()
|
||||
disableShowAITranslationOnce()
|
||||
|
||||
nextFrame(() => {
|
||||
resetShowSourceContent()
|
||||
})
|
||||
|
||||
const navigate = getStableRouterNavigate()
|
||||
|
||||
if (!navigate) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue