release(desktop): Release v0.6.0

release(desktop): Release v0.6.0
This commit is contained in:
DIYgod 2025-06-29 09:16:10 +08:00 committed by GitHub
commit 121ff0035a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
670 changed files with 26953 additions and 17627 deletions

27
.cursor/rules/app.mdc Normal file
View File

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

112
.cursor/rules/base.mdc Normal file
View File

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

View File

@ -1,6 +1,6 @@
---
description:
globs:
globs: apps/desktop/**/*,packages/internal/components/**/*
alwaysApply: false
---
# UIKit Colors for Tailwind CSS

View File

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

12
.cursor/rules/rn.mdc Normal file
View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ on:
- "**"
paths:
- "apps/desktop/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/build-desktop.yml"
workflow_dispatch:

View File

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

4
.gitignore vendored
View File

@ -21,4 +21,6 @@ vite.config.*.mjs
apps/desktop/src/renderer/dev-dist
tsconfig.tsbuildinfo
buildServer.json
buildServer.json
**/**/generated-routes.ts

View File

@ -9,3 +9,4 @@ apps/mobile/android
apps/mobile/ios
apps/mobile/native/example
generated-routes.ts

View File

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

View File

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

View File

@ -8,3 +8,6 @@ VITE_OPENPANEL_CLIENT_ID=
VITE_OPENPANEL_API_URL=
VITE_EDITOR=cursor
VITE_PUBLIC_POSTHOG_KEY=
VITE_PUBLIC_POSTHOG_HOST=

View File

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

View File

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

View File

@ -8,4 +8,4 @@
## Thanks
Special thanks to external contributors @ for their valuable contributions
Special thanks to volunteer contributors @ for their valuable contributions

View File

@ -8,4 +8,4 @@
## Thanks
Special thanks to external contributors @ for their valuable contributions
Special thanks to volunteer contributors @ for their valuable contributions

View File

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

View File

@ -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(),
],

View File

@ -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,
})

View File

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

View File

@ -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:"
}
}

View File

@ -5,5 +5,6 @@ declare global {
electron?: ElectronAPI
api?: { canWindowBlur: boolean }
platform: NodeJS.Platform
mas: boolean
}
}

View File

@ -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: () => {

View File

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

View File

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

View File

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

View File

@ -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")
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
)}`,

View File

@ -1,5 +0,0 @@
export type RendererHandlers = {
invalidateQuery: (key: (string | number | undefined)[]) => void
updateDownloaded: () => void
navigateEntry: (options: { feedId: string; entryId: string; view: number }) => void
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ declare global {
electron?: ElectronAPI
api?: { canWindowBlur: boolean }
platform: NodeJS.Platform
mas: boolean
}
export const APP_NAME = "Folo"
}

View File

@ -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:"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"> = {

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

@ -0,0 +1,3 @@
import { createContext } from "react"
export const MediaContainerWidthContext = createContext<number>(0)

View File

@ -0,0 +1,11 @@
import { MediaContainerWidthContext } from "./MediaContainerWidthContext"
export const MediaContainerWidthProvider = ({
children,
width,
}: {
children: React.ReactNode
width: number
}) => {
return <MediaContainerWidthContext value={width}>{children}</MediaContainerWidthContext>
}

View File

@ -0,0 +1 @@
export type MediaInfoRecord = Record<string, { width?: number; height?: number }>

View File

@ -0,0 +1,5 @@
import { createContext } from "react"
import type { MediaInfoRecord } from "./MediaInfoRecord"
export const MediaInfoRecordContext = createContext<MediaInfoRecord>({})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,6 +37,7 @@ export interface DialogInstance {
ask: (options: {
title: string
message: string
variant?: "ask" | "warning" | "danger"
onConfirm?: () => void
onCancel?: () => void
confirmText?: string

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
export const LOCAL_DB_NAME = "FOLLOW_DB"

View File

@ -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" }])
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
export type CleanerType = "feed" | "entry" | "list" | "inbox"
export type DB_Cleaner = {
refId: string
visitedAt: number
type: CleanerType
}

View File

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

View File

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

View File

@ -1,4 +0,0 @@
export type DB_Inbox = {
id: string
title: string
}

View File

@ -1,2 +0,0 @@
export * from "./base"
export * from "./feed"

View File

@ -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[]
}

View File

@ -1,5 +0,0 @@
import type { SubscriptionFlatModel } from "~/store/subscription"
export type DB_Subscription = SubscriptionFlatModel & {
id: string
}

View File

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

View File

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

View File

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

View File

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