release(desktop): Release v0.5.0

release(desktop): Release v0.5.0
This commit is contained in:
DIYgod 2025-06-08 10:11:18 +08:00 committed by GitHub
commit 3149bda1c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
605 changed files with 17049 additions and 10918 deletions

191
.cursor/rules/color.mdc Normal file
View File

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

View File

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

View File

@ -22,10 +22,6 @@ updates:
- dependency-name: react-native
versions: [">=0.78.0"]
# Stuck by unified 11
- dependency-name: vfile
versions: [">=6.0.0"]
# It's using export map and metro doesn't support it well
- dependency-name: unist-util-visit-parents
versions: [">=6.0.0"]
@ -57,7 +53,6 @@ updates:
- jsonpointer
- workbox-precaching
- "@pengx17/electron-forge-maker-appimage"
- devlop
- "@microflash/remark-callout-directives"
- react-native-track-player
- react-native-sheet-transitions

View File

@ -16,6 +16,10 @@ on:
- preview
- production
description: "Build profile"
release:
type: boolean
default: false
description: "Create a release draft for the build"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }}
@ -82,3 +86,22 @@ jobs:
if: github.event.inputs.profile == 'production'
working-directory: apps/mobile
run: eas submit --platform android --path ${{ github.workspace }}/build.aab --non-interactive
- name: Setup Version
if: github.event.inputs.release == 'true'
id: version
uses: ./.github/actions/setup-version
with:
# We don't want to create a new release for mobile
type: "desktop"
- name: Create Release Draft
if: github.event.inputs.release == 'true'
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.APP_VERSION }}
draft: false
prerelease: true
tag_name: v${{ steps.version.outputs.APP_VERSION }}
# .aab cannot be installed directly on your Android Emulator or device.
files: ${{ github.workspace }}/build.apk

View File

@ -39,7 +39,7 @@ jobs:
run: |
git tag ${{ env.tag_version }}
git push origin ${{ env.tag_version }}
- name: Trigger Build Workflow
- name: Trigger Build Desktop Workflow
if: ${{ env.tag_version }}
run: |
curl -X POST \
@ -47,3 +47,12 @@ jobs:
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-desktop.yml/dispatches \
-d '{"ref":"main", "inputs": {"tag_version": "true"}}'
- name: Trigger Build Android Workflow
if: ${{ env.tag_version }}
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-android.yml/dispatches \
-d '{"ref":"main", "inputs": {"release": "true"}}'

View File

@ -19,6 +19,7 @@
<br />
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fmobile%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Mobile&labelColor=black&color=FF5E01" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black" /></a>
<a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fmobile%2Fpackage.json&style=flat-square&logo=google-play&label=Google%20Play&labelColor=black&color=FF5E01"/></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Desktop&labelColor=black&color=FF5E01" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-mac-app-store-version.rss3.workers.dev%2F&query=version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-microsoft-store-version.rss3.workers.dev%2F&query=version&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600&prefix=v" /></a>
@ -46,9 +47,9 @@ Feel free to try it using the following methods:
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Any | <a href="https://app.follow.is" target="_blank"><img src="https://github.com/user-attachments/assets/51ef7800-b683-4493-83e8-eb4752366997" alt="Browser" height="55"/></a> |
| iOS | <a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604" target="_blank"><img src="https://github.com/user-attachments/assets/a94d8698-2a11-4f43-9b0a-b756b17b61f7" alt="App Store" height="55"/></a> |
| Android | <a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://github.com/user-attachments/assets/0d178e0b-3ace-4f75-bbde-ab3c0a416ce8" alt="Google Play" height="55"/></a> <a href="https://github.com/RSSNext/Folo/releases/latest" target="_blank"><img src="https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc" alt="App Store" height="55"/></a> |
| macOS | <a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604" target="_blank"><img src="https://github.com/user-attachments/assets/0d47f902-7fa3-494e-ad28-9ab11af5e6d4" alt="Microsoft Store" height="55"/></a> <a href="https://github.com/RSSNext/Folo/releases/latest" target="_blank"><img src="https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc" alt="App Store" height="55"/></a> |
| Windows | <a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct" target="_blank"><img src="https://github.com/user-attachments/assets/b3112bab-9dd0-4893-9488-890dcb368f70" alt="Microsoft Store" height="55"/></a> <a href="https://github.com/RSSNext/Folo/releases/latest" target="_blank"><img src="https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc" alt="App Store" height="55"/></a> |
| Android | <a href="https://play.google.com/store/apps/details?id=is.follow" target="_blank"><img src="https://github.com/user-attachments/assets/17ce1d5b-e906-4d42-aec2-66877da9fd95" alt="Google Play" height="55"/></a> |
| Linux | <a href="https://github.com/RSSNext/Folo/releases/latest" target="_blank"><img src="https://github.com/user-attachments/assets/cf61e197-d756-4606-a8ad-fb591f79fdfc" alt="App Store" height="55"/></a> |
You can also install using the following methods maintained by our community:

View File

@ -0,0 +1,29 @@
# What's New in v0.5.0
## Shiny new things
- Double-clicking the draggable edge on either side of the entry list resets it to its default position (26c6853)
- Brand-new Feed Manager panel (dbd43ae)
- OPML import now offers a preview and lets you choose specific feeds to import (de6c2ff)
- All-new Share sheet (5bbd96e)
## Improvements
- Switched to hCaptcha for a smoother human-verification experience (22aec92)
- Gradually rolling out an experimental unified local database for mobile and desktop (#3809)
- Implemented smooth scrolling (6c73ae5)
- Significant overall performance boost
- AI will skip summarising extra-short articles to avoid trivial summaries (852fb1d)
- Added “Reset to defaults” button to the shortcuts page (#3834)
- Added a border around 2-factor QR codes for better scan reliability (f7faf78)
- RSSHub routes top-feed list now respects your Discover-language setting (14d4da3)
- Audio player now prefers episode artwork over feed artwork (#3855)
- You can now manually paste your login token to bypass Microsoft Store deeplink issues (47e07a4)
- Added “Check for updates” button in Settings (ef304cd)
## No longer broken
- Dropdown menus now keep keyboard focus when opened (f31d43d)
- “Mark as read” button is now perfectly centred (#3836)
- Entry list no longer displays stale cached items (0a167ac)
- Squashed numerous shortcut-key bugs

View File

@ -1,5 +1,11 @@
# What's new in vNEXT_VERSION
## Shiny new things
## Improvements
## No longer broken
Performance: Fix context menu re-render loop.
## Thanks
Special thanks to external contributors @ for their valuable contributions

View File

@ -1,3 +1,6 @@
// Export types for renderer to use
export type { IpcServices } from "./src/ipc"
export type { RendererHandlers } from "./src/renderer-handlers"
export type { Router } from "./src/tipc"
export { router } from "./src/tipc"
// Export services for potential main process use
export { services } from "./src/ipc"

View File

@ -11,7 +11,6 @@
},
"exports": {
".": {
"types": "./export.d.ts",
"import": "./export.js"
}
},
@ -21,7 +20,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@egoist/tipc": "0.3.2",
"@electron-toolkit/preload": "3.0.2",
"@electron-toolkit/utils": "4.0.0",
"@eneris/push-receiver": "4.3.0",
@ -29,25 +27,25 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@openpanel/web": "1.0.1",
"@sentry/electron": "6.5.0",
"@sentry/electron": "6.6.0",
"builder-util-runtime": "9.3.1",
"cookie-es": "2.0.0",
"electron-context-menu": "4.0.5",
"electron-log": "5.3.4",
"electron-log": "5.4.0",
"electron-squirrel-startup": "1.0.1",
"electron-store": "10.0.1",
"electron-updater": "6.6.2",
"es-toolkit": "1.36.0",
"es-toolkit": "1.38.0",
"fast-folder-size": "2.4.0",
"font-list": "1.5.1",
"i18next": "25.0.1",
"i18next": "25.2.1",
"js-yaml": "4.1.0",
"linkedom": "0.18.10",
"lowdb": "7.0.1",
"msedge-tts": "2.0.0",
"node-machine-id": "1.1.12",
"ofetch": "1.4.1",
"semver": "7.7.1",
"semver": "7.7.2",
"tar": "7.4.3",
"vscode-languagedetection": "npm:@vscode/vscode-languagedetection@1.0.22"
},
@ -55,10 +53,10 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@types/js-yaml": "4.0.9",
"@types/node": "22.15.3",
"@types/node": "22.15.23",
"electron": "35.1.5",
"electron-devtools-installer": "4.0.0",
"hono": "4.7.7",
"hono": "4.7.10",
"typescript": "catalog:"
}
}

View File

@ -1,7 +1,7 @@
import path from "node:path"
import { getRendererHandlers, registerIpcMain } from "@egoist/tipc/main"
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"
@ -9,15 +9,14 @@ 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 type { RendererHandlers } from "./renderer-handlers"
import { initializeSentry } from "./sentry"
import { router } from "./tipc"
import { getMainWindowOrCreate } from "./window"
if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) {
@ -29,6 +28,7 @@ if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) {
*/
export function initializeAppStage0() {
initializeSentry()
initializeIpcServices()
}
export const initializeAppStage1 = () => {
const protocols = [LEGACY_APP_PROTOCOL, APP_PROTOCOL]
@ -43,8 +43,6 @@ export const initializeAppStage1 = () => {
}
}
registerIpcMain(router)
if (app.dock) {
app.dock.setIcon(getIconPath())
}
@ -189,8 +187,8 @@ const registerPushNotifications = async () => {
const mainWindow = getMainWindowOrCreate()
mainWindow.restore()
mainWindow.focus()
const handlers = getRendererHandlers<RendererHandlers>(mainWindow.webContents)
handlers.navigateEntry.send({
const handlers = callWindowExpose(mainWindow)
handlers.navigateEntry({
feedId: data.feedId,
entryId: data.entryId,
view: Number.parseInt(data.view),

View File

@ -0,0 +1,118 @@
import type { IpcMainInvokeEvent, WebContents } from "electron"
import { ipcMain } from "electron"
// Base context for IPC methods
export interface IpcContext {
sender: WebContents
event: IpcMainInvokeEvent
}
// Metadata storage for decorated methods
const methodMetadata = new WeakMap<any, Map<string, string>>()
// Decorator for IPC methods
export function IpcMethod(methodName?: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const { constructor } = target
if (!methodMetadata.has(constructor)) {
methodMetadata.set(constructor, new Map())
}
const methods = methodMetadata.get(constructor)!
const finalMethodName = methodName || propertyKey
methods.set(propertyKey, finalMethodName)
return descriptor
}
}
// Handler registry for IPC methods
export class IpcHandler {
private static instance: IpcHandler
private registeredChannels = new Set<string>()
static getInstance(): IpcHandler {
if (!IpcHandler.instance) {
IpcHandler.instance = new IpcHandler()
}
return IpcHandler.instance
}
registerMethod<TOutput>(
channel: string,
handler: (context: IpcContext, ...args: any[]) => Promise<TOutput> | TOutput,
) {
if (this.registeredChannels.has(channel)) {
return // Already registered
}
this.registeredChannels.add(channel)
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: any[]) => {
const context: IpcContext = {
sender: event.sender,
event,
}
try {
return await handler(context, ...args)
} catch (error) {
console.error(`Error in IPC method ${channel}:`, error)
throw error
}
})
}
// Send events to renderer
sendToRenderer<T = any>(webContents: WebContents, channel: string, data: T) {
webContents.send(channel, data)
}
}
// Base class for IPC service groups
export abstract class IpcService {
protected handler = IpcHandler.getInstance()
constructor(protected groupName: string) {
this.registerMethods()
}
protected registerMethods(): void {
const { constructor } = this
const methods = methodMetadata.get(constructor)
if (methods) {
for (const [propertyKey, methodName] of methods) {
const method = (this as any)[propertyKey]
if (typeof method === "function") {
this.registerMethod(methodName, method.bind(this))
}
}
}
}
protected registerMethod<TOutput>(
methodName: string,
handler: (context: IpcContext, ...args: any[]) => Promise<TOutput> | TOutput,
) {
const channel = `${this.groupName}.${methodName}`
this.handler.registerMethod(channel, handler)
}
}
// Extract method signatures from service class, removing context parameter
export type ExtractServiceMethods<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K] extends (
context: IpcContext,
...args: infer Args
) => infer Output
? Args extends []
? () => AlwaysPromise<Output>
: Args extends [infer Input]
? (input: Input) => AlwaysPromise<Output>
: (...args: Args) => AlwaysPromise<Output>
: never
}
type AlwaysPromise<T> = Promise<Awaited<T>>

View File

@ -0,0 +1,39 @@
import type { ExtractServiceMethods } from "./base"
import { AppService } from "./services/app"
import { AuthService } from "./services/auth"
import { DebugService } from "./services/debug"
import { DockService } from "./services/dock"
import { IntegrationService } from "./services/integration"
import { MenuService } from "./services/menu"
import { ReaderService } from "./services/reader"
import { SettingService } from "./services/setting"
// Initialize all services
export const services = {
app: new AppService(),
auth: new AuthService(),
debug: new DebugService(),
dock: new DockService(),
menu: new MenuService(),
reader: new ReaderService(),
setting: new SettingService(),
integration: new IntegrationService(),
} as const
// Extract method types automatically from services
export type IpcServices = {
app: ExtractServiceMethods<AppService>
auth: ExtractServiceMethods<AuthService>
debug: ExtractServiceMethods<DebugService>
dock: ExtractServiceMethods<DockService>
menu: ExtractServiceMethods<MenuService>
reader: ExtractServiceMethods<ReaderService>
setting: ExtractServiceMethods<SettingService>
integration: ExtractServiceMethods<IntegrationService>
}
// Initialize all services (this will register all IPC handlers)
export function initializeIpcServices() {
// Services are already initialized in the services constant above
console.info("IPC services initialized")
}

View File

@ -0,0 +1,182 @@
import path from "node:path"
import { fileURLToPath } from "node:url"
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 { 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"
interface WindowActionInput {
action: "close" | "minimize" | "maximum"
}
interface SearchInput {
text: string
options: Electron.FindInPageOptions
}
interface Sender extends Electron.WebContents {
getOwnerBrowserWindow: () => Electron.BrowserWindow | null
}
export class AppService extends IpcService {
constructor() {
super("app")
}
@IpcMethod()
getAppVersion(): string {
return app.getVersion()
}
@IpcMethod()
checkForUpdates(): void {
checkForAppUpdates()
}
@IpcMethod()
switchAppLocale(context: IpcContext, input: string): void {
i18n.changeLanguage(input)
registerMenuAndContextMenu()
registerAppTray()
app.commandLine.appendSwitch("lang", input)
}
@IpcMethod()
rendererUpdateReload(): void {
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const allWindows = BrowserWindow.getAllWindows()
const dynamicRenderEntry = loadDynamicRenderEntry()
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")
logger.info("appLoadEntry", appLoadEntry)
const mainWindow = getMainWindow()
for (const window of allWindows) {
if (window === mainWindow) {
if (DEV) {
logger.verbose("[rendererUpdateReload]: skip reload in dev")
break
}
window.loadFile(appLoadEntry)
} else window.destroy()
}
setTimeout(() => {
cleanupOldRender()
}, 1000)
}
@IpcMethod()
windowAction(context: IpcContext, input: WindowActionInput): void {
if (context.sender.getType() === "window") {
const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow()
if (!window) return
switch (input.action) {
case "close": {
window.close()
break
}
case "minimize": {
window.minimize()
break
}
case "maximum": {
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
break
}
}
}
}
@IpcMethod()
quitAndInstall(_context: IpcContext): void {
quitAndInstall()
}
@IpcMethod()
readClipboard(_context: IpcContext): string {
return clipboard.readText()
}
@IpcMethod()
async search(context: IpcContext, input: SearchInput): Promise<Electron.Result | null> {
const { sender: webContents } = context
const { promise, resolve } = Promise.withResolvers<Electron.Result | null>()
let requestId = -1
webContents.once("found-in-page", (_, result) => {
resolve(result.requestId === requestId ? result : null)
})
requestId = webContents.findInPage(input.text, input.options)
return promise
}
@IpcMethod()
clearSearch(context: IpcContext): void {
context.sender.stopFindInPage("keepSelection")
}
@IpcMethod()
async download(context: IpcContext, input: string): Promise<void> {
const result = await dialog.showSaveDialog({
defaultPath: input.split("/").pop(),
})
if (result.canceled) return
try {
await downloadFile(input, result.filePath)
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
if (senderWindow) {
callWindowExpose(senderWindow).toast.success("Download success!", {
duration: 1000,
})
}
} catch (err) {
const senderWindow = (context.sender as Sender).getOwnerBrowserWindow()
if (senderWindow) {
callWindowExpose(senderWindow).toast.error("Download failed!", {
duration: 1000,
})
}
throw err
}
}
@IpcMethod()
getAppPath(_context: IpcContext): string {
return app.getAppPath()
}
@IpcMethod()
resolveAppAsarPath(context: IpcContext, input: string): string {
if (input.startsWith("file://")) {
input = fileURLToPath(input)
}
if (path.isAbsolute(input)) {
return input
}
return path.join(app.getAppPath(), input)
}
}

View File

@ -0,0 +1,19 @@
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
import type { IpcContext } from "../base"
import { IpcMethod, IpcService } from "../base"
export class AuthService extends IpcService {
constructor() {
super("auth")
}
@IpcMethod()
async sessionChanged(_context: IpcContext): Promise<void> {
await updateNotificationsToken()
}
@IpcMethod()
async signOut(_context: IpcContext): Promise<void> {
await deleteNotificationsToken()
}
}

View File

@ -0,0 +1,18 @@
import type { IpcContext } from "../base"
import { IpcMethod, IpcService } from "../base"
interface InspectElementInput {
x: number
y: number
}
export class DebugService extends IpcService {
constructor() {
super("debug")
}
@IpcMethod()
inspectElement(context: IpcContext, input: InspectElementInput): void {
context.sender.inspectElement(input.x, input.y)
}
}

View File

@ -0,0 +1,41 @@
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 { IpcMethod, IpcService } from "../base"
const pollingMap = {
unread: false,
}
export class DockService extends IpcService {
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()
}
}
}
@IpcMethod()
async cancelPollingUpdateUnreadCount(): Promise<void> {
pollingMap.unread = false
}
@IpcMethod()
async updateUnreadCount(): Promise<void> {
const res = await apiClient.reads["total-count"].$get()
setDockCount(res.data.count)
}
}

View File

@ -0,0 +1,83 @@
import { existsSync } from "node:fs"
import fsp from "node:fs/promises"
import path from "node:path"
import type { IpcContext } from "../base"
import { IpcMethod, IpcService } from "../base"
// Input types
interface SaveToEagleInput {
url: string
mediaUrls: string[]
}
export class IntegrationService extends IpcService {
constructor() {
super("integration")
}
@IpcMethod()
async saveToObsidian(
context: IpcContext,
input: {
url: string
title: string
content: string
author: string
publishedAt: string
vaultPath: string
},
) {
try {
const { url, title, content, author, publishedAt, vaultPath } = input
const fileName = `${(title || publishedAt).trim().slice(0, 20).replaceAll("/", "-")}.md`
const filePath = path.join(vaultPath, fileName)
const exists = existsSync(filePath)
if (exists) {
return { success: false, error: "File already exists" }
}
const markdown = `---
url: ${url}
author: ${author}
publishedAt: ${publishedAt}
---
# ${title}
${content}
`
await fsp.writeFile(filePath, markdown, "utf-8")
return { success: true }
} catch (error) {
console.error("Failed to save to Obsidian:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
@IpcMethod()
async saveToEagle(context: IpcContext, input: SaveToEagleInput): Promise<any> {
try {
const res = await fetch("http://localhost:41595/api/item/addFromURLs", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
items: input.mediaUrls?.map((media) => ({
url: media,
website: input.url,
headers: {
referer: input.url,
},
})),
}),
})
return await res.json()
} catch {
return null
}
}
}

View File

@ -0,0 +1,81 @@
import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron"
import { dialog, Menu, ShareMenu } from "electron"
import type { IpcContext } from "../base"
import { IpcMethod, IpcService } from "../base"
type SerializableMenuItem = Omit<MenuItemConstructorOptions, "click" | "submenu"> & {
submenu?: SerializableMenuItem[]
}
interface ShowContextMenuInput {
items: SerializableMenuItem[]
}
interface ShowConfirmDialogInput {
title: string
message: string
options?: Partial<MessageBoxOptions>
}
export class MenuService extends IpcService {
constructor() {
super("menu")
}
private normalizeMenuItems(
items: SerializableMenuItem[],
context: IpcContext,
path: number[] = [],
): MenuItemConstructorOptions[] {
return items.map((item, index) => {
const curPath = [...path, index]
return {
...item,
click() {
context.sender.send("menu-click", {
id: item.id,
path: curPath,
})
},
submenu: item.submenu ? this.normalizeMenuItems(item.submenu, context, curPath) : undefined,
}
})
}
@IpcMethod()
async showContextMenu(context: IpcContext, input: ShowContextMenuInput): Promise<void> {
const defer = Promise.withResolvers<void>()
const normalizedMenuItems = this.normalizeMenuItems(input.items, context)
const menu = Menu.buildFromTemplate(normalizedMenuItems)
menu.popup({
callback: () => defer.resolve(),
})
return defer.promise
}
@IpcMethod()
async showConfirmDialog(_context: IpcContext, input: ShowConfirmDialogInput): Promise<boolean> {
const result = await dialog.showMessageBox({
message: input.title,
detail: input.message,
buttons: ["Confirm", "Cancel"],
...input.options,
})
return result.response === 0
}
@IpcMethod()
async showShareMenu(context: IpcContext, input: string): Promise<void> {
const menu = new ShareMenu({
urls: [input],
})
menu.popup({
callback: () => {
context.sender.send("menu-closed")
},
})
}
}

View File

@ -0,0 +1,126 @@
import fs from "node:fs"
import path from "node:path"
import { callWindowExpose } from "@follow/shared/bridge"
import { readability } from "@follow-app/readability"
import { app, BrowserWindow } from "electron"
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts"
import type { ModelResult } from "vscode-languagedetection"
import { detectCodeStringLanguage } from "../../modules/language-detection"
import type { IpcContext } from "../base"
import { IpcMethod, IpcService } from "../base"
const tts = new MsEdgeTTS()
interface ReadabilityInput {
url: string
html?: string
}
interface TtsInput {
id: string
text: string
voice: string
}
interface DetectCodeStringLanguageInput {
codeString: string
}
export class ReaderService extends IpcService {
constructor() {
super("reader")
}
@IpcMethod()
async readability(_context: IpcContext, input: ReadabilityInput) {
const { url } = input
if (!url) {
return null
}
const result = await readability(url)
return result
}
@IpcMethod()
async tts(context: IpcContext, input: TtsInput): Promise<string | null> {
const { id, text, voice } = input
if (!text) {
return null
}
const window = BrowserWindow.fromWebContents(context.sender)
if (!window) return null
try {
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) {
callWindowExpose(window).toast.error(error.message, {
duration: 1000,
})
} else {
callWindowExpose(window).toast.error("Failed to set voice", {
duration: 1000,
})
}
return null
}
const dirPath = path.join(app.getPath("userData"), "Cache", "tts", id)
const possibleFilePathList = ["mp3", "webm"].map((ext) => {
return path.join(dirPath, `audio.${ext}`)
})
const filePath = possibleFilePathList.find((p) => fs.existsSync(p))
if (filePath) {
return filePath
} else {
fs.mkdirSync(dirPath, { recursive: true })
const { audioFilePath } = await tts.toFile(dirPath, text)
return audioFilePath
}
}
@IpcMethod()
async getVoices(context: IpcContext) {
const window = BrowserWindow.fromWebContents(context.sender)
try {
const voices = await tts.getVoices()
return voices
} catch (error) {
console.error("Failed to get voices", error)
if (!window) return
if (error instanceof Error) {
void callWindowExpose(window).toast.error(error.message, { duration: 1000 })
return
}
callWindowExpose(window).toast.error("Failed to get voices", { duration: 1000 })
}
}
@IpcMethod()
async detectCodeStringLanguage(
_context: IpcContext,
input: DetectCodeStringLanguageInput,
): Promise<ModelResult | undefined> {
const { codeString } = input
const languages = detectCodeStringLanguage(codeString)
let finalLanguage: ModelResult | undefined
for await (const language of languages) {
if (!finalLanguage) {
finalLanguage = language
continue
}
if (language.confidence > finalLanguage.confidence) {
finalLanguage = language
}
}
return finalLanguage
}
}

View File

@ -0,0 +1,90 @@
import { createRequire } from "node:module"
import { app, nativeTheme } from "electron"
import { setDockCount } from "../../lib/dock"
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"
const require = createRequire(import.meta.url)
interface SetLoginItemSettingsInput {
openAtLogin: boolean
openAsHidden?: boolean
path?: string
args?: string[]
}
export class SettingService extends IpcService {
constructor() {
super("setting")
}
@IpcMethod()
getLoginItemSettings(_context: IpcContext): Electron.LoginItemSettings {
return app.getLoginItemSettings()
}
@IpcMethod()
setLoginItemSettings(_context: IpcContext, input: SetLoginItemSettingsInput): void {
app.setLoginItemSettings(input)
}
@IpcMethod()
openSettingWindow(_context: IpcContext): void {
showSetting()
}
@IpcMethod()
async getSystemFonts(_context: IpcContext): Promise<string[]> {
const fonts = await require("font-list").getFonts()
return fonts.map((font: string) => font.replaceAll('"', ""))
}
@IpcMethod()
getAppearance(_context: IpcContext): "light" | "dark" | "system" {
return nativeTheme.themeSource
}
@IpcMethod()
setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void {
nativeTheme.themeSource = appearance
}
@IpcMethod()
getMinimizeToTray(_context: IpcContext): boolean {
return getTrayConfig()
}
@IpcMethod()
setMinimizeToTray(_context: IpcContext, minimize: boolean): void {
setTrayConfig(minimize)
}
@IpcMethod()
setDockBadge(_context: IpcContext, count: number): void {
setDockCount(count)
}
@IpcMethod()
getProxyConfig(_context: IpcContext) {
const proxy = store.get("proxy")
return proxy ?? undefined
}
@IpcMethod()
setProxyConfig(_context: IpcContext, config: string) {
const result = setProxyConfig(config)
updateProxy()
return result
}
@IpcMethod()
getMessagingToken(_context: IpcContext): string | null {
return store.get("notifications-credentials") as string | null
}
}

View File

@ -1,5 +0,0 @@
import { tipc } from "@egoist/tipc/main"
export const t = tipc.create()
export type T = typeof t

View File

@ -1,331 +0,0 @@
import fs from "node:fs"
import fsp from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getRendererHandlers } from "@egoist/tipc/main"
import { callWindowExpose } from "@follow/shared/bridge"
import { DEV } from "@follow/shared/constants"
import { app, BrowserWindow, clipboard, dialog, shell } from "electron"
import { filePathToAppUrl } from "~/helper"
import { registerMenuAndContextMenu } from "~/init"
import { clearAllData, getCacheSize } from "~/lib/cleaner"
import { store, StoreKey } from "~/lib/store"
import { registerAppTray } from "~/lib/tray"
import { logger, revealLogFile } from "~/logger"
import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater"
import { downloadFile } from "../lib/download"
import { i18n } from "../lib/i18n"
import type { RendererHandlers } from "../renderer-handlers"
import { quitAndInstall } from "../updater"
import { getMainWindow } from "../window"
import { t } from "./_instance"
export const appRoute = {
saveToEagle: t.procedure
.input<{ url: string; mediaUrls: string[] }>()
.action(async ({ input }) => {
try {
const res = await fetch("http://localhost:41595/api/item/addFromURLs", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
items: input.mediaUrls?.map((media) => ({
url: media,
website: input.url,
headers: {
referer: input.url,
},
})),
}),
})
return await res.json()
} catch {
return null
}
}),
invalidateQuery: t.procedure
.input<(string | number | undefined)[]>()
.action(async ({ input }) => {
const mainWindow = getMainWindow()
if (!mainWindow) return
const handlers = getRendererHandlers<RendererHandlers>(mainWindow.webContents)
handlers.invalidateQuery.send(input)
}),
windowAction: t.procedure
.input<{ action: "close" | "minimize" | "maximum" }>()
.action(async ({ input, context }) => {
if (context.sender.getType() === "window") {
const window: BrowserWindow | null = (context.sender as Sender).getOwnerBrowserWindow()
if (!window) return
switch (input.action) {
case "close": {
window.close()
break
}
case "minimize": {
window.minimize()
break
}
case "maximum": {
// FIXME: this is a electron bug, see https://github.com/RSSNext/Follow/issues/231
// So in this way we use a workaround to fix it, that is manually resize the window
// Comemnt the manually handle logic and disable backgroundMaterial for now
// if (isWindows11) {
// const display = screen.getDisplayMatching(window.getBounds())
// const size = display.workAreaSize
// const isMaximized = size.height === window.getSize()[1]
// const storeKey = Symbol.for("maximized")
// if (isMaximized) {
// const stored = window[storeKey]
// if (!stored) return
// window.setResizable(true)
// window.setMovable(true)
// window.setBounds(
// {
// width: stored.size[0],
// height: stored.size[1],
// x: stored.position[0],
// y: stored.position[1],
// },
// true,
// )
// delete window[storeKey]
// } else {
// const currentWindowSize = window.getSize()
// const currentWindowPosition = window.getPosition()
// window[storeKey] = {
// size: currentWindowSize,
// position: currentWindowPosition,
// }
// // Maually Resize
// const { workArea } = display
// window.setBounds(
// {
// x: workArea.x,
// y: workArea.y,
// width: workArea.width,
// height: workArea.height,
// },
// true,
// )
// window.setResizable(false)
// window.setMovable(false)
// }
// return
// }
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
break
}
}
}
}),
quitAndInstall: t.procedure.action(async () => {
quitAndInstall()
}),
/// clipboard
readClipboard: t.procedure.action(async () => clipboard.readText()),
/// search
search: t.procedure
.input<{
text: string
options: Electron.FindInPageOptions
}>()
.action(async ({ input, context }) => {
const { sender: webContents } = context
const { promise, resolve } = Promise.withResolvers<Electron.Result | null>()
let requestId = -1
webContents.once("found-in-page", (_, result) => {
resolve(result.requestId === requestId ? result : null)
})
requestId = webContents.findInPage(input.text, input.options)
return promise
}),
clearSearch: t.procedure.action(async ({ context: { sender: webContents } }) => {
webContents.stopFindInPage("keepSelection")
}),
download: t.procedure.input<string>().action(async ({ input, context: { sender } }) => {
const result = await dialog.showSaveDialog({
defaultPath: input.split("/").pop(),
})
if (result.canceled) return
// return result.filePath;
await downloadFile(input, result.filePath).catch((err) => {
const senderWindow = (sender as Sender).getOwnerBrowserWindow()
if (!senderWindow) return
callWindowExpose(senderWindow).toast.error("Download failed!", {
duration: 1000,
})
throw err
})
const senderWindow = (sender as Sender).getOwnerBrowserWindow()
if (!senderWindow) return
callWindowExpose(senderWindow).toast.success("Download success!", {
duration: 1000,
})
}),
getAppPath: t.procedure.action(async () => app.getAppPath()),
resolveAppAsarPath: t.procedure.input<string>().action(async ({ input }) => {
return filePathToAppUrl(path.resolve(app.getAppPath(), input))
}),
switchAppLocale: t.procedure.input<string>().action(async ({ input }) => {
i18n.changeLanguage(input)
registerMenuAndContextMenu()
registerAppTray()
app.commandLine.appendSwitch("lang", input)
}),
clearAllData: t.procedure.action(clearAllData),
saveToObsidian: t.procedure
.input<{
url: string
title: string
content: string
author: string
publishedAt: string
vaultPath: string
}>()
.action(async ({ input }) => {
try {
const { url, title, content, author, publishedAt, vaultPath } = input
const fileName = `${(title || publishedAt).trim().slice(0, 20).replaceAll("/", "-")}.md`
const filePath = path.join(vaultPath, fileName)
const exists = fs.existsSync(filePath)
if (exists) {
return { success: false, error: "File already exists" }
}
const markdown = `---
url: ${url}
author: ${author}
publishedAt: ${publishedAt}
---
# ${title}
${content}
`
await fsp.writeFile(filePath, markdown, "utf-8")
return { success: true }
} catch (error) {
console.error("Failed to save to Obsidian:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}),
getAppVersion: t.procedure.action(async () => {
return app.getVersion()
}),
rendererUpdateReload: t.procedure.action(async () => {
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const allWindows = BrowserWindow.getAllWindows()
const dynamicRenderEntry = loadDynamicRenderEntry()
const appLoadEntry =
dynamicRenderEntry || path.resolve(__dirname, "../../../../../renderer/index.html")
logger.info("appLoadEntry", appLoadEntry)
const mainWindow = getMainWindow()
for (const window of allWindows) {
if (window === mainWindow) {
if (DEV) {
logger.verbose("[rendererUpdateReload]: skip reload in dev")
break
}
window.loadFile(appLoadEntry)
} else window.destroy()
}
setTimeout(() => {
cleanupOldRender()
}, 1000)
}),
getCacheSize: t.procedure.action(async () => {
return getCacheSize()
}),
openCacheFolder: t.procedure.action(async () => {
const dir = path.join(app.getPath("userData"), "cache")
shell.openPath(dir)
}),
getCacheLimit: t.procedure.action(async () => {
return store.get(StoreKey.CacheSizeLimit)
}),
clearCache: t.procedure.action(async () => {
const cachePath = path.join(app.getPath("userData"), "cache", "Cache_Data")
if (process.platform === "win32") {
// Request elevation on Windows
try {
// Create a bat file to delete cache with elevated privileges
const batPath = path.join(app.getPath("temp"), "clear_cache.bat")
await fsp.writeFile(batPath, `@echo off\nrd /s /q "${cachePath}"\ndel "%~f0"`, "utf-8")
// Execute the bat file with admin privileges
await shell.openPath(batPath)
return
} catch (err) {
logger.error("Failed to clear cache with elevation", { error: err })
}
}
await fsp.rm(cachePath, { recursive: true, force: true }).catch(() => {
logger.error("Failed to clear cache")
})
}),
limitCacheSize: t.procedure.input<number>().action(async ({ input }) => {
logger.info("set limitCacheSize", input)
if (input === 0) {
store.delete(StoreKey.CacheSizeLimit)
} else {
store.set(StoreKey.CacheSizeLimit, input)
}
}),
revealLogFile: t.procedure.action(async () => {
return revealLogFile()
}),
}
interface Sender extends Electron.WebContents {
getOwnerBrowserWindow: () => Electron.BrowserWindow | null
}

View File

@ -1,13 +0,0 @@
import { deleteNotificationsToken, updateNotificationsToken } from "~/lib/user"
import { t } from "./_instance"
export const authRoute = {
sessionChanged: t.procedure.action(async () => {
await updateNotificationsToken()
}),
signOut: t.procedure.action(async () => {
await deleteNotificationsToken()
}),
}

View File

@ -1,9 +0,0 @@
import { t } from "./_instance"
export const debugRoute = {
inspectElement: t.procedure
.input<{ x: number; y: number }>()
.action(async ({ input, context }) => {
context.sender.inspectElement(input.x, input.y)
}),
}

View File

@ -1,41 +0,0 @@
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 { t } from "./_instance"
const pollingMap = {
unread: false,
}
export const dockRoute = {
pollingUpdateUnreadCount: t.procedure.action(pollingUpdateUnreadCount),
cancelPollingUpdateUnreadCount: t.procedure.action(cancelPollingUpdateUnreadCount),
updateUnreadCount: t.procedure.action(async () => {
await updateUnreadCount()
}),
}
async function updateUnreadCount() {
const res = await apiClient.reads["total-count"].$get()
setDockCount(res.data.count)
}
export async function pollingUpdateUnreadCount() {
if (pollingMap.unread) {
return
}
pollingMap.unread = true
while (pollingMap.unread) {
await sleep(UNREAD_BACKGROUND_POLLING_INTERVAL)
if (pollingMap.unread) {
await updateUnreadCount()
}
}
}
export async function cancelPollingUpdateUnreadCount() {
pollingMap.unread = false
}

View File

@ -1,19 +0,0 @@
import { appRoute } from "./app"
import { authRoute } from "./auth"
import { debugRoute } from "./debug"
import { dockRoute } from "./dock"
import { menuRoute } from "./menu"
import { readerRoute } from "./reader"
import { settingRoute } from "./setting"
export const router = {
...debugRoute,
...menuRoute,
...settingRoute,
...appRoute,
...dockRoute,
...readerRoute,
...authRoute,
}
export type Router = typeof router

View File

@ -1,77 +0,0 @@
import type { ActionContext } from "@egoist/tipc/main"
import type { MenuItemConstructorOptions, MessageBoxOptions } from "electron"
import { dialog, Menu, ShareMenu } from "electron"
import { t } from "./_instance"
type SerializableMenuItem = Omit<MenuItemConstructorOptions, "click" | "submenu"> & {
// id: string
submenu?: SerializableMenuItem[]
}
function normalizeMenuItems(
items: SerializableMenuItem[],
context: ActionContext,
path = [] as number[],
): MenuItemConstructorOptions[] {
return items.map((item, index) => {
const curPath = [...path, index]
return {
...item,
click() {
context.sender.send("menu-click", {
id: item.id,
path: curPath,
})
},
submenu: item.submenu ? normalizeMenuItems(item.submenu, context, curPath) : undefined,
}
})
}
export const menuRoute = {
showContextMenu: t.procedure
.input<{
items: SerializableMenuItem[]
}>()
.action(({ input, context }) => {
const defer = Promise.withResolvers<void>()
const normalizedMenuItems = normalizeMenuItems(input.items, context)
// See https://www.electronjs.org/docs/latest/api/menu
const menu = Menu.buildFromTemplate(normalizedMenuItems)
menu.popup({
callback: () => defer.resolve(),
})
return defer.promise
}),
/** @deprecated */
showConfirmDialog: t.procedure
.input<{
title: string
message: string
options?: Partial<MessageBoxOptions>
}>()
.action(async ({ input }) => {
const result = await dialog.showMessageBox({
message: input.title,
detail: input.message,
buttons: ["Confirm", "Cancel"],
...input.options,
})
return result.response === 0
}),
showShareMenu: t.procedure.input<string>().action(async ({ input, context }) => {
const menu = new ShareMenu({
urls: [input],
})
menu.popup({
callback: () => {
context.sender.send("menu-closed")
},
})
}),
}

View File

@ -1,108 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { callWindowExpose } from "@follow/shared/bridge"
import { readability } from "@follow-app/readability"
import { app, BrowserWindow } from "electron"
import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts"
import type { ModelResult } from "vscode-languagedetection"
import { detectCodeStringLanguage } from "~/modules/language-detection"
import { t } from "./_instance"
const tts = new MsEdgeTTS()
export const readerRoute = {
readability: t.procedure.input<{ url: string }>().action(async ({ input }) => {
const { url } = input
if (!url) {
return null
}
const result = await readability(url)
return result
}),
tts: t.procedure
.input<{
id: string
text: string
voice: string
}>()
.action(async ({ input, context: { sender } }) => {
const { id, text, voice } = input
if (!text) {
return null
}
const window = BrowserWindow.fromWebContents(sender)
if (!window) return
// It's ok to set voice every time, because it will be cached by msedge-tts
// Don't know why webm is not working, using mp3 instead
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) {
return callWindowExpose(window).toast.error(error.message, {
duration: 1000,
})
}
return callWindowExpose(window).toast.error("Failed to set voice", {
duration: 1000,
})
})
const dirPath = path.join(app.getPath("userData"), "Cache", "tts", id)
const possibleFilePathList = ["mp3", "webm"].map((ext) => {
return path.join(dirPath, `audio.${ext}`)
})
const filePath = possibleFilePathList.find((p) => fs.existsSync(p))
if (filePath) {
return filePath
} else {
fs.mkdirSync(dirPath, { recursive: true })
const { audioFilePath } = await tts.toFile(dirPath, text)
return audioFilePath
}
}),
getVoices: t.procedure.action(async ({ context: { sender } }) => {
const window = BrowserWindow.fromWebContents(sender)
try {
const voices = await tts.getVoices()
return voices
} catch (error) {
console.error("Failed to get voices", error)
if (!window) return
if (error instanceof Error) {
void callWindowExpose(window).toast.error(error.message, { duration: 1000 })
return
}
callWindowExpose(window).toast.error("Failed to get voices", { duration: 1000 })
}
}),
detectCodeStringLanguage: t.procedure
.input<{ codeString: string }>()
.action(async ({ input }) => {
const { codeString } = input
const languages = detectCodeStringLanguage(codeString)
let finalLanguage: ModelResult | undefined
for await (const language of languages) {
if (!finalLanguage) {
finalLanguage = language
continue
}
if (language.confidence > finalLanguage.confidence) {
finalLanguage = language
}
}
return finalLanguage
}),
}

View File

@ -1,57 +0,0 @@
import { createRequire } from "node:module"
import { app, nativeTheme } from "electron"
import { START_IN_TRAY_ARGS } from "~/constants/app"
import { getTrayConfig, setTrayConfig } from "~/lib/tray"
import { setDockCount } from "../lib/dock"
import { setProxyConfig, updateProxy } from "../lib/proxy"
import { store } from "../lib/store"
import { showSetting } from "../window"
import { t } from "./_instance"
const require = createRequire(import.meta.url)
export const settingRoute = {
getLoginItemSettings: t.procedure.input<void>().action(async () => app.getLoginItemSettings()),
setLoginItemSettings: t.procedure.input<boolean>().action(async ({ input }) => {
app.setLoginItemSettings({
openAtLogin: input,
openAsHidden: true,
args: [START_IN_TRAY_ARGS],
})
}),
openSettingWindow: t.procedure.action(async () => showSetting()),
getSystemFonts: t.procedure.action(
async (): Promise<string[]> =>
new Promise((resolve) => {
// NOTE: should external font-list deps
// use `require` to avoid bundling, vite behavior
require("font-list")
.getFonts()
.then((fonts) => {
resolve(fonts.map((font) => font.replaceAll('"', "")))
})
}),
),
getAppearance: t.procedure.action(async () => nativeTheme.themeSource),
setAppearance: t.procedure.input<"light" | "dark" | "system">().action(async ({ input }) => {
nativeTheme.themeSource = input
store.set("appearance", input)
}),
getMinimizeToTray: t.procedure.action(async () => getTrayConfig()),
setMinimizeToTray: t.procedure.input<boolean>().action(async ({ input }) => setTrayConfig(input)),
setDockBadge: t.procedure.input<number>().action(async ({ input }) => {
setDockCount(input)
}),
getProxyConfig: t.procedure.action(async () => store.get("proxy")),
setProxyConfig: t.procedure.input<string>().action(async ({ input }) => {
const result = setProxyConfig(input)
updateProxy()
return result
}),
getMessagingToken: t.procedure.action(async () => {
return store.get("notifications-credentials")
}),
}

View File

@ -1,12 +1,12 @@
import path from "node:path"
import { DEV } from "@follow/shared/constants"
import { DEV, MODE, ModeEnum } from "@follow/shared/constants"
import { isWindows } from "../env"
export const appUpdaterConfig = {
// Disable renderer hot update will trigger app update when available
enableRenderHotUpdate: !DEV,
enableRenderHotUpdate: !DEV && MODE !== ModeEnum.staging,
enableCoreUpdate:
!process.mas &&
!process.windowsStore &&

View File

@ -1,4 +1,4 @@
import { getRendererHandlers } from "@egoist/tipc/main"
import { callWindowExpose } from "@follow/shared/bridge"
import { DEV } from "@follow/shared/constants"
import { autoUpdater as defaultAutoUpdater } from "electron-updater"
@ -7,7 +7,6 @@ import { canUpdateRender, CanUpdateRenderState, hotUpdateRender } from "~/update
import { channel, isWindows } from "../env"
import { logger } from "../logger"
import type { RendererHandlers } from "../renderer-handlers"
import { destroyMainWindow, getMainWindow } from "../window"
import { appUpdaterConfig } from "./configs"
import { CustomGitHubProvider } from "./custom-github-provider"
@ -145,9 +144,9 @@ export const registerUpdater = async () => {
const mainWindow = getMainWindow()
if (!mainWindow) return
const handlers = getRendererHandlers<RendererHandlers>(mainWindow.webContents)
const handlers = callWindowExpose(mainWindow)
handlers.updateDownloaded.send()
handlers.updateDownloaded()
})
autoUpdater.on("error", (e) => {
logger.error("Error while updating client", e)

View File

@ -12,12 +12,12 @@ 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 { cancelPollingUpdateUnreadCount, pollingUpdateUnreadCount } from "./tipc/dock"
import { loadDynamicRenderEntry } from "./updater/hot-updater"
const windows = {
@ -313,7 +313,7 @@ export const createMainWindow = () => {
})
window.on("show", () => {
cancelPollingUpdateUnreadCount()
services.dock.pollingUpdateUnreadCount()
const caller = callWindowExpose(window)
@ -325,7 +325,7 @@ export const createMainWindow = () => {
const settings = await caller.getUISettings()
if (settings?.showDockBadge) {
pollingUpdateUnreadCount()
services.dock.pollingUpdateUnreadCount()
}
})

View File

@ -10,6 +10,7 @@
"noUnusedParameters": false,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"experimentalDecorators": true,
"baseUrl": ".",
"paths": {
"@pkg": ["../../package.json"],

View File

@ -14,32 +14,32 @@
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@egoist/tipc": "0.3.2",
"@electron-toolkit/preload": "3.0.2",
"@follow/electron-main": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/store": "workspace:*",
"@follow/tracker": "workspace:*",
"@fontsource/sn-pro": "5.2.5",
"@headlessui/react": "2.2.2",
"@headlessui/react": "2.2.4",
"@hookform/resolvers": "4.1.3",
"@lottiefiles/dotlottie-react": "0.13.4",
"@lottiefiles/dotlottie-react": "0.13.5",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.7",
"@radix-ui/react-context-menu": "2.2.12",
"@radix-ui/react-dialog": "1.1.11",
"@radix-ui/react-dropdown-menu": "2.1.12",
"@radix-ui/react-hover-card": "1.1.11",
"@radix-ui/react-label": "2.1.4",
"@radix-ui/react-popover": "1.1.11",
"@radix-ui/react-slider": "1.3.2",
"@radix-ui/react-slot": "1.2.0",
"@sentry/react": "9.14.0",
"@shikijs/transformers": "3.3.0",
"@tanstack/query-sync-storage-persister": "5.74.7",
"@tanstack/react-query": "5.74.7",
"@tanstack/react-query-devtools": "5.74.7",
"@tanstack/react-query-persist-client": "5.74.7",
"@tanstack/react-virtual": "3.13.6",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-context-menu": "2.2.15",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-hover-card": "1.1.14",
"@radix-ui/react-label": "2.1.7",
"@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",
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@yornaath/batshit": "0.10.1",
@ -49,54 +49,54 @@
"dayjs": "1.11.13",
"dexie": "4.0.11",
"dexie-export-import": "4.1.4",
"dnum": "2.14.0",
"dnum": "2.15.0",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.0.2",
"es-toolkit": "1.36.0",
"firebase": "11.6.1",
"es-toolkit": "1.38.0",
"firebase": "11.8.1",
"foxact": "0.2.45",
"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.0.1",
"i18next-browser-languagedetector": "8.0.5",
"idb-keyval": "6.2.1",
"i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0",
"idb-keyval": "6.2.2",
"immer": "10.1.1",
"jotai": "2.12.3",
"jotai": "2.12.5",
"lethargy": "1.0.9",
"masonic": "4.1.0",
"mdast-util-gfm-table": "2.0.0",
"mdast-util-to-markdown": "2.1.2",
"motion": "12.9.2",
"motion": "12.15.0",
"nanoid": "5.1.5",
"ofetch": "1.4.1",
"plain-shiki": "0.2.0",
"re-resizable": "6.11.2",
"react-blurhash": "0.3.0",
"react-fast-marquee": "1.6.5",
"react-hook-form": "7.56.1",
"react-hotkeys-hook": "5.0.1",
"react-i18next": "15.5.1",
"react-hook-form": "7.56.4",
"react-hotkeys-hook": "5.1.0",
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-ios-pwa-prompt": "2.0.6",
"react-qr-code": "2.0.15",
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
"react-router": "7.5.2",
"react-router": "7.6.1",
"react-selecto": "1.26.3",
"react-shadow": "20.6.0",
"react-zoom-pan-pinch": "3.7.0",
"shiki": "3.3.0",
"shiki": "3.4.2",
"sonner": "2.0.3",
"tinykeys": "3.0.0",
"title-case": "4.3.2",
"tldts": "7.0.4",
"tldts": "7.0.7",
"ufo": "1.6.1",
"use-context-selector": "2.0.0",
"use-sync-external-store": "1.5.0",
"usehooks-ts": "3.1.1",
"zod": "3.24.3",
"zustand": "5.0.3"
"zod": "3.25.32",
"zustand": "5.0.5"
},
"devDependencies": {
"@follow/atoms": "workspace:*",
@ -107,11 +107,11 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@types/node": "22.15.3",
"@types/node": "22.15.23",
"@vite-pwa/assets-generator": "1.0.0",
"fake-indexeddb": "6.0.0",
"happy-dom": "17.4.4",
"react-scan": "0.3.3",
"fake-indexeddb": "6.0.1",
"happy-dom": "17.5.6",
"react-scan": "0.3.4",
"typescript": "catalog:"
}
}

View File

@ -5,33 +5,15 @@ import { cn, getOS } from "@follow/utils/utils"
import { useEffect } from "react"
import { Outlet } from "react-router"
import { queryClient } from "~/lib/query-client"
import { useAppIsReady } from "./atoms/app"
import { useUISettingKey } from "./atoms/settings/ui"
import { navigateEntry } from "./hooks/biz/useNavigateEntry"
import { applyAfterReadyCallbacks } from "./initialize/queue"
import { removeAppSkeleton } from "./lib/app"
import { appLog } from "./lib/log"
import { Titlebar } from "./modules/app/Titlebar"
import { RootProviders } from "./providers/root-providers"
import { handlers } from "./tipc"
function App() {
useEffect(() => {
const cleanup = handlers?.invalidateQuery.listen((queryKey) => {
queryClient.invalidateQueries({
queryKey,
})
})
handlers?.navigateEntry.listen((options) => {
navigateEntry(options)
})
return cleanup
}, [])
const windowsElectron = IN_ELECTRON && getOS() === "Windows"
return (
<RootProviders>

View File

@ -1,7 +1,6 @@
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
import type { FlatEntryModel } from "~/store/entry/types"
import { useGeneralSettingKey } from "./settings/general"
@ -12,12 +11,12 @@ export const toggleShowAISummaryOnce = () => setShowAISummaryOnce((prev) => !pre
export const enableShowAISummaryOnce = () => setShowAISummaryOnce(true)
export const disableShowAISummaryOnce = () => setShowAISummaryOnce(false)
export const useShowAISummaryAuto = (entry: FlatEntryModel | null) => {
return useGeneralSettingKey("summary") || !!entry?.settings?.summary
export const useShowAISummaryAuto = (settings?: boolean | null) => {
return useGeneralSettingKey("summary") || !!settings
}
export const useShowAISummary = (entry: FlatEntryModel | null) => {
const showAISummaryAuto = useShowAISummaryAuto(entry)
export const useShowAISummary = (settings?: boolean | null) => {
const showAISummaryAuto = useShowAISummaryAuto(settings)
const showAISummaryOnce = useShowAISummaryOnce()
return showAISummaryAuto || showAISummaryOnce || !!entry?.settings?.summary
return showAISummaryAuto || showAISummaryOnce || !!settings
}

View File

@ -1,7 +1,6 @@
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
import type { FlatEntryModel } from "~/store/entry/types"
import { useGeneralSettingKey } from "./settings/general"
@ -21,12 +20,12 @@ export const toggleShowAITranslationOnce = () => setShowAITranslationOnce((prev)
export const enableShowAITranslationOnce = () => setShowAITranslationOnce(true)
export const disableShowAITranslationOnce = () => setShowAITranslationOnce(false)
export const useShowAITranslationAuto = (entry: FlatEntryModel | null) => {
return useGeneralSettingKey("translation") || !!entry?.settings?.translation
export const useShowAITranslationAuto = (settings?: boolean | null) => {
return useGeneralSettingKey("translation") || !!settings
}
export const useShowAITranslation = (entry: FlatEntryModel | null) => {
const showAITranslationAuto = useShowAITranslationAuto(entry)
export const useShowAITranslation = (settings?: boolean | null) => {
const showAITranslationAuto = useShowAITranslationAuto(settings)
const showAITranslationOnce = useShowAITranslationOnce()
return showAITranslationAuto || showAITranslationOnce
}

View File

@ -3,7 +3,7 @@ import { getOS, transformShortcut } from "@follow/utils/utils"
import { atom } from "jotai"
import { useCallback } from "react"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
import { createAtomHooks } from "~/lib/jotai"
import type { ElectronMenuItem } from "~/lib/native-menu"
import { showElectronContextMenu } from "~/lib/native-menu"
@ -118,7 +118,7 @@ function withDebugMenu(menuItems: Array<FollowMenuItem>, e: MouseEvent | React.M
new MenuItemText({
label: "Inspect Element",
click: () => {
tipcClient?.inspectElement({
ipcServices?.debug.inspectElement({
x: e.pageX,
y: e.pageY,
})

View File

@ -0,0 +1,43 @@
import type { PopoverContentProps } from "@radix-ui/react-popover"
import { atom } from "jotai"
import type { ReactNode } from "react"
import { createAtomHooks, jotaiStore } from "~/lib/jotai"
// Atom
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
/** Custom z-index for popover */
zIndex?: number
/** Whether the popover should close when clicked outside */
modal?: boolean
}
type PopoverState =
| { open: false }
| {
open: true
position: { x: number; y: number }
content: ReactNode
props?: PopoverProps
// Just for abort callback
abortController: AbortController
}
export const [popoverAtom, usePopoverState, usePopoverValue, useSetPopover] = createAtomHooks(
atom<PopoverState>({ open: false }),
)
export const showPopover = (
mouseXY: { x: number; y: number },
element: ReactNode,
props?: PopoverProps,
) => {
jotaiStore.set(popoverAtom, {
open: true,
position: mouseXY,
content: element,
props,
abortController: new AbortController(),
})
}

View File

@ -1,4 +1,5 @@
import { useLayoutEffect, useState } from "react"
import type { JSX } from "react/jsx-runtime"
const NOT_RESOLVED = Symbol("NOT_RESOLVED")
export const ExPromise = <T,>({

View File

@ -15,13 +15,13 @@ export const FocusablePresets = {
isNotFloatingLayerScope: (v: Set<string>) => !FloatingLayerScope.some((s) => v.has(s)),
isSubscriptionList: (scope: Set<string>) => {
return (
scope.size === 0 ||
scope.has(HotkeyScope.SubscriptionList) ||
(scope.has(HotkeyScope.Home) && scope.size === 1)
scope.has(HotkeyScope.SubscriptionList) || (scope.has(HotkeyScope.Home) && scope.size === 1)
)
},
isSubscriptionOrTimeline: (v: Set<string>) => {
return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0
},
}
isTimeline: (v) => v.has(HotkeyScope.Timeline) && !v.has(HotkeyScope.EntryRender),
isEntryRender: (v) => v.has(HotkeyScope.EntryRender),
} satisfies Record<string, (v: Set<string>) => boolean>

View File

@ -0,0 +1,288 @@
import { IN_ELECTRON } from "@follow/shared/constants"
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"
interface SharePanelProps {
entryId: string
}
interface ShareOption {
id: string
label: string
icon: string
action: () => Promise<void> | void
color?: string
bgColor?: string
}
interface SocialShareOption {
id: string
label: string
icon: string
url: string
color: string
bgColor: string
}
const socialOptions: SocialShareOption[] = [
{
id: "twitter",
label: "X",
icon: tw`i-mgc-social-x-cute-re`,
url: "https://x.com/intent/tweet?text={text}&url={url}",
color: "text-white",
bgColor: "bg-black",
},
{
id: "facebook",
label: "Facebook",
icon: tw`i-mgc-facebook-cute-re`,
url: "https://www.facebook.com/sharer/sharer.php?u={url}",
color: "text-white",
bgColor: "bg-[#1877F2]",
},
{
id: "telegram",
label: "Telegram",
icon: tw`i-mgc-telegram-cute-re`,
url: "https://t.me/share/url?url={url}&text={text}",
color: "text-white",
bgColor: "bg-[#0088CC]",
},
{
id: "weibo",
label: "微博",
icon: tw`i-mgc-weibo-cute-re`,
url: "https://service.weibo.com/share/share.php?url={url}&title={text}",
color: "text-white",
bgColor: "bg-[#E6162D]",
},
]
const getShareUrl = (entryId: string) => {
const entry = getEntry(entryId)
if (!entry) return ""
// Temporarily use the original link
return entry.entries.url!
// const params = getRouteParams()
// let subscriptionId = "all"
// if (params.feedId) {
// subscriptionId = params.feedId
// } else if (params.inboxId) {
// subscriptionId = params.inboxId
// } else if (params.listId) {
// subscriptionId = params.listId
// }
// return UrlBuilder.shareEntry(entryId, {
// view: params.view,
// subscriptionId,
// })
}
export const SharePanel = ({ entryId }: SharePanelProps) => {
const { t } = useTranslation()
const generateShareContent = useCallback(
(entry: ReturnType<typeof getEntry>) => {
if (!entry) return null
const { title, description } = entry.entries
const shareUrl = getShareUrl(entryId)
// Limit text to 50 characters with ellipsis
const truncateText = (text: string, maxLength = 50) => {
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text
}
const shareTitle = `${title || t("share.default_title")} - Folo`
const baseText = description || title || t("share.default_description")
const truncatedText = truncateText(baseText)
const shareText = `${truncatedText} | ${t("share.discover_more")}`
return {
title: shareTitle,
text: shareText,
url: shareUrl,
}
},
[entryId, t],
)
const handleNativeShare = useCallback(async () => {
const entry = getEntry(entryId)
const shareContent = generateShareContent(entry)
if (!shareContent) return
try {
if (IN_ELECTRON) {
// Use Electron's share menu
await ipcServices?.menu.showShareMenu(shareContent.url)
} else if (navigator.share) {
// Use Web Share API
await navigator.share({
title: shareContent.title,
text: shareContent.text,
url: shareContent.url,
})
} else {
// Fallback to copying link
await navigator.clipboard.writeText(shareContent.url)
toast.success(t("share.link_copied"))
}
} catch {
// If sharing fails, copy link as fallback
try {
await navigator.clipboard.writeText(shareContent.url)
toast.success(t("share.link_copied"))
} catch {
toast.error(t("share.copy_failed"))
}
}
}, [entryId, generateShareContent, t])
const handleCopyLink = useCallback(async () => {
const shareUrl = getShareUrl(entryId)
try {
await navigator.clipboard.writeText(shareUrl)
toast.success(t("share.link_copied"))
} catch {
toast.error(t("share.copy_failed"))
}
}, [entryId, t])
const handleSocialShare = useCallback(
(shareUrlTemplate: string) => {
const entry = getEntry(entryId)
const shareContent = generateShareContent(entry)
if (!shareContent) return
const encodedUrl = encodeURIComponent(shareContent.url)
const shareTitle = encodeURIComponent(shareContent.title)
const shareText = encodeURIComponent(shareContent.text)
const finalUrl = shareUrlTemplate
.replace("{url}", encodedUrl)
.replace("{title}", shareTitle)
.replace("{text}", shareText)
window.open(finalUrl, "_blank", "width=600,height=400")
},
[entryId, generateShareContent],
)
const actionOptions: ShareOption[] = [
...(IN_ELECTRON || (typeof navigator !== "undefined" && "share" in navigator)
? [
{
id: "native-share",
label: t("share.system_share"),
icon: "i-mgc-share-forward-cute-re",
action: handleNativeShare,
color: "text-blue-500",
},
]
: []),
{
id: "copy-link",
label: t("share.copy_link"),
icon: "i-mgc-link-cute-re",
action: handleCopyLink,
},
]
return (
<div className="pointer-events-auto max-w-[400px] px-2">
<div className="mb-4 flex flex-col text-center">
<h3 className="text-text mb-2 mt-1 font-semibold">{t("share.title")}</h3>
{(() => {
const entry = getEntry(entryId)
const title = entry?.entries?.title
return title ? (
<p className="text-text-secondary mt-1 min-w-0 text-wrap text-left text-sm font-medium">
{title}
</p>
) : null
})()}
</div>
<div className="mb-6">
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium uppercase tracking-wide">
{t("share.social_media")}
</h4>
</div>
<div className="flex items-center gap-4">
{socialOptions.map((option) => (
<button
key={option.id}
type="button"
className="group flex flex-col items-center gap-2"
onClick={() => handleSocialShare(option.url)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-full transition-all duration-200",
option.bgColor,
"group-hover:scale-110 group-active:scale-95",
"shadow-lg",
)}
>
<i className={cn(option.icon, "size-5", option.color)} />
</div>
<span className="text-text-secondary text-xs font-medium">{option.label}</span>
</button>
))}
</div>
</div>
<div>
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium uppercase tracking-wide">
{t("share.actions")}
</h4>
</div>
<div className="flex flex-col gap-1">
{actionOptions.map((option) => (
<button
key={option.id}
type="button"
className={cn(
"cursor-button relative flex select-none items-center rounded-lg",
"text-sm outline-none transition-all duration-200",
"hover:bg-fill-secondary/80 active:bg-fill-secondary",
"group",
)}
onClick={() => option.action()}
>
<div className="flex items-center gap-2">
<div
className={cn(
"flex size-7 items-center justify-center rounded-full",
"bg-fill-tertiary/80 group-hover:bg-fill-tertiary",
"transition-colors duration-200",
)}
>
<i
className={cn(option.icon, "size-3.5", option.color || "text-text-secondary")}
/>
</div>
<span className="text-text text-xs font-medium">{option.label}</span>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View File

@ -11,7 +11,7 @@ import type {
} from "shiki"
import { useUISettingKey } from "~/atoms/settings/ui"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
import { CopyButton } from "../../button/CopyButton"
import { getLanguageIcon } from "../constants"
@ -57,7 +57,7 @@ export const ShikiHighLighter: FC<ShikiProps> = (props) => {
}
function guessLanguage() {
return tipcClient?.detectCodeStringLanguage({ codeString: code }).then((result) => {
return ipcServices?.reader.detectCodeStringLanguage({ codeString: code }).then((result) => {
if (!result) {
return
}

View File

@ -1,3 +1,4 @@
import { useSetGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { Divider } from "@follow/components/ui/divider/Divider.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
@ -6,15 +7,21 @@ import { cn } from "@follow/utils/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import * as React from "react"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
const setGlobalFocusableScope = useSetGlobalFocusableScope()
return (
<DropdownMenuPrimitive.Root
{...props}
onOpenChange={useTypeScriptHappyCallback(
(open) => {
if (open) {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
} else {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
}
props.onOpenChange?.(open)
},
[props.onOpenChange],
@ -91,18 +98,16 @@ const DropdownMenuContent = ({
}) => {
return (
<RootPortal>
<Focusable scope={HotkeyScope.DropdownMenu} className="contents">
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
className,
)}
{...props}
/>
</Focusable>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
className,
)}
{...props}
/>
</RootPortal>
)
}

View File

@ -1,6 +1,7 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import katexStyle from "katex/dist/katex.min.css?raw"
import { createElement, Fragment, memo, useEffect, useMemo, useState } from "react"
import type { JSX } from "react/jsx-runtime"
import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom"
import { parseHtml } from "~/lib/parse-html"

View File

@ -11,7 +11,10 @@ import { PreviewMediaContent } from "./preview-media"
export const usePreviewMedia = (children?: React.ReactNode) => {
const { present } = useModalStack()
return useCallback(
(media: PreviewMediaProps[], initialIndex = 0) => {
(media?: PreviewMediaProps[], initialIndex = 0) => {
if (!media || media.length === 0) {
return
}
if (isMobile()) {
window.open(replaceImgUrlIfNeed(media[initialIndex]!.url))
return

View File

@ -17,7 +17,7 @@ import { useWindowSize } from "usehooks-ts"
import { m } from "~/components/common/Motion"
import { COPY_MAP } from "~/constants"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { FixedModalCloseButton } from "../modal/components/close"
@ -114,7 +114,7 @@ const Wrapper: Component<{
<ActionButton
tooltip={t("common:words.download")}
onClick={() => {
tipcClient?.download(src)
ipcServices?.app.download(src)
}}
>
<i className="i-mgc-download-2-cute-re" />

View File

@ -3,6 +3,7 @@ import { cn } from "@follow/utils/utils"
import { m, useAnimationControls } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { useEffect, useState } from "react"
import type { JSX } from "react/jsx-runtime"
import { ModalClose } from "./components"
import { useCurrentModal } from "./hooks"

View File

@ -366,7 +366,7 @@ export const ModalInternal = memo(function Modal({
onPointerDownCapture={handleDrag}
onPointerDown={relocateModal}
>
{!!icon && <span className="size-4">{icon}</span>}
{!!icon && <span className="center flex size-4">{icon}</span>}
<EllipsisHorizontalTextWithTooltip className="truncate">
<span>{title}</span>
</EllipsisHorizontalTextWithTooltip>

View File

@ -1,6 +1,6 @@
import { useRouteParamsSelector } from "./useRouteParams"
export function useEntryIsRead<T extends { read: Nullable<boolean> }>(entry?: T) {
export function useEntryIsRead<T extends { read: Nullable<boolean> }>(entry?: T | null) {
return useRouteParamsSelector(
(params) => {
if (params.isCollection) {

View File

@ -0,0 +1,33 @@
import { checkIsEditableElement } from "@follow/utils"
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import type { MenuItemInput } from "~/atoms/context-menu"
import { MenuItemText } from "~/atoms/context-menu"
export const useContextMenuActionShortCutTrigger = (items: MenuItemInput[], when: boolean) => {
useEffect(() => {
if (!when) return
const actionMap = items.reduce(
(acc, item) => {
if (item instanceof MenuItemText) {
if (!item.shortcut) return acc
acc[item.shortcut] = (event: KeyboardEvent) => {
if (checkIsEditableElement(event.target as HTMLElement)) return
event.preventDefault()
event.stopPropagation()
if (item.disabled) return
if (item.hide) return
item.click()
}
}
return acc
},
{} as Record<string, (e: KeyboardEvent) => void>,
)
return tinykeys(window, actionMap)
}, [items, when])
}

View File

@ -1,21 +0,0 @@
import { useCallback, useEffect } from "react"
import { useGoogleReCaptcha } from "react-google-recaptcha-v3"
import { useClaimCheck, useClaimWalletDailyRewardMutation } from "~/queries/wallet"
export const useDailyTask = () => {
const { mutateAsync: claimDaily } = useClaimWalletDailyRewardMutation()
const check = useClaimCheck()
const { executeRecaptcha } = useGoogleReCaptcha()
const handleReCaptchaVerify = useCallback(async () => {
if (check.data?.data && executeRecaptcha) {
const token = await executeRecaptcha("useDailyTask")
claimDaily({ tokenV3: token })
}
}, [claimDaily, check.data?.data, executeRecaptcha])
useEffect(() => {
handleReCaptchaVerify()
}, [handleReCaptchaVerify, check.data?.data])
}

View File

@ -18,17 +18,23 @@ import {
import { useShowSourceContent } from "~/atoms/source-content"
import { useUserRole, whoami } from "~/atoms/user"
import { apiClient } from "~/lib/api-fetch"
import { tipcClient } from "~/lib/client"
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"
import { useRouteParamsSelector } from "./useRouteParams"
export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => {
const status = getReadabilityStatus()[id]
const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status
if (isTurnOn) return
toggleEntryReadability({ id, url })
}
export const toggleEntryReadability = async ({ id, url }: { id: string; url: string }) => {
const status = getReadabilityStatus()[id]
@ -59,7 +65,7 @@ export const toggleEntryReadability = async ({ id, url }: { id: string; url: str
})
}
} catch {
const result = await tipcClient?.readability({ url })
const result = await ipcServices?.reader.readability({ url })
if (result) {
setReadabilityContent({
[id]: result,
@ -131,6 +137,34 @@ export class EntryActionMenuItem extends MenuItemText {
}
export type EntryActionItem = EntryActionMenuItem | MenuItemSeparator
const entrySelector = (state: FlatEntryModel) => {
const content = state.entries.content || ""
const hasContent = !!content
const doesContentContainsHTMLTags = doesTextContainHTML(content)
const { summary, translation, readability } = state.settings || {}
const media = state.entries.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,
read: state.read,
summary,
translation,
readability,
isInCollection: !!state.collections,
hasContent,
doesContentContainsHTMLTags,
imagesLength,
}
}
export const useEntryActions = ({
entryId,
view,
@ -140,9 +174,9 @@ export const useEntryActions = ({
view?: FeedViewType
compact?: boolean
}) => {
const entry = useEntry(entryId)
const isEntryInReadability = useEntryIsInReadability(entry?.entries.id)
const imageLength = entry?.entries.media?.filter((a) => a.type === "photo").length || 0
const entry = useEntry(entryId, entrySelector)
const isEntryInReadability = useEntryIsInReadability(entryId)
const feed = useFeedById(entry?.feedId, (feed) => {
return {
type: feed.type,
@ -151,16 +185,13 @@ export const useEntryActions = ({
siteUrl: feed.siteUrl,
}
})
const listId = useRouteParamsSelector((s) => s.listId)
const inList = !!listId
const inbox = useInboxById(entry?.inboxId)
const isInbox = !!inbox
const isContentContainsHTMLTags = doesTextContainHTML(entry?.entries.content)
const isShowSourceContent = useShowSourceContent()
const isShowAISummaryAuto = useShowAISummaryAuto(entry)
const isShowAISummaryAuto = useShowAISummaryAuto(entry?.summary)
const isShowAISummaryOnce = useShowAISummaryOnce()
const isShowAITranslationAuto = useShowAITranslationAuto(entry)
const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
const isShowAITranslationOnce = useShowAITranslationOnce()
const runCmdFn = useRunCommandFn()
@ -227,7 +258,7 @@ export const useEntryActions = ({
new EntryActionMenuItem({
id: COMMAND_ID.entry.star,
onClick: runCmdFn(COMMAND_ID.entry.star, [{ entryId, view }]),
active: !!entry?.collections,
active: entry.isInCollection,
shortcut: shortcuts[COMMAND_ID.entry.star],
entryId,
}),
@ -240,7 +271,7 @@ export const useEntryActions = ({
new EntryActionMenuItem({
id: COMMAND_ID.entry.copyLink,
onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]),
hide: !entry?.entries.url,
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.copyLink],
entryId,
}),
@ -251,13 +282,13 @@ export const useEntryActions = ({
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.imageGallery,
hide: imageLength <= 5,
hide: entry.imagesLength <= 5,
onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]),
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.openInBrowser,
hide: !entry?.entries.url,
hide: !entry.url,
onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),
entryId,
}),
@ -266,7 +297,7 @@ export const useEntryActions = ({
onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [
{ entryId, siteUrl: feed?.siteUrl },
]),
hide: isMobile() || !entry?.entries.url,
hide: isMobile() || !entry.url,
active: isShowSourceContent,
entryId,
}),
@ -276,7 +307,7 @@ export const useEntryActions = ({
hide:
isShowAISummaryAuto ||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
entry?.view,
entry.view,
),
active: isShowAISummaryOnce,
disabled: userRole === UserRole.Trial,
@ -288,7 +319,7 @@ export const useEntryActions = ({
hide:
isShowAITranslationAuto ||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
entry?.view,
entry.view,
),
active: isShowAITranslationOnce,
disabled: userRole === UserRole.Trial,
@ -297,28 +328,28 @@ export const useEntryActions = ({
new EntryActionMenuItem({
id: COMMAND_ID.entry.share,
onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),
hide: !entry?.entries.url || !("share" in navigator || IN_ELECTRON),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.share],
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.readAbove,
onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.entries.publishedAt }]),
hide: !hasEntry || !!entry.collections,
onClick: runCmdFn(COMMAND_ID.entry.readAbove, [{ publishedAt: entry.publishedAt }]),
hide: !!entry.isInCollection,
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.read,
onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),
hide: !hasEntry || !!entry.collections,
active: !!entry?.read,
hide: !!entry.isInCollection,
active: !!entry.read,
shortcut: shortcuts[COMMAND_ID.entry.read],
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.readBelow,
onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.entries.publishedAt }]),
hide: !hasEntry || !!entry.collections,
onClick: runCmdFn(COMMAND_ID.entry.readBelow, [{ publishedAt: entry.publishedAt }]),
hide: !!entry.isInCollection,
entryId,
}),
MENU_ITEM_SEPARATOR,
@ -331,27 +362,17 @@ export const useEntryActions = ({
new EntryActionMenuItem({
id: COMMAND_ID.entry.tts,
onClick: runCmdFn(COMMAND_ID.entry.tts, [
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
{ entryId, entryContent: entry?.entries.content! },
]),
hide: !IN_ELECTRON || compact || !entry?.entries.content,
onClick: runCmdFn(COMMAND_ID.entry.tts, [{ entryId }]),
hide: !IN_ELECTRON || compact || !entry.hasContent,
shortcut: shortcuts[COMMAND_ID.entry.tts],
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.readability,
onClick: runCmdFn(COMMAND_ID.entry.readability, [
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
{ entryId, entryUrl: entry?.entries.url! },
]),
hide:
!!entry.settings?.readability ||
compact ||
(view && views[view]!.wideMode) ||
!entry?.entries.url,
onClick: runCmdFn(COMMAND_ID.entry.readability, [{ entryId, entryUrl: entry.url! }]),
hide: !!entry.readability || compact || (view && views[view]!.wideMode) || !entry.url,
active: isEntryInReadability,
notice: !isContentContainsHTMLTags && !isEntryInReadability,
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
entryId,
}),
new EntryActionMenuItem({
@ -378,24 +399,23 @@ export const useEntryActions = ({
isInbox,
shortcuts,
view,
entry?.collections,
entry?.entries.url,
entry?.entries.publishedAt,
entry?.entries.content,
entry?.isInCollection,
entry?.url,
entry?.publishedAt,
entry?.hasContent,
entry?.view,
entry?.read,
entry?.settings?.readability,
imageLength,
entry?.readability,
entry?.imagesLength,
isShowSourceContent,
isShowAISummaryAuto,
isShowAISummaryOnce,
userRole,
isShowAITranslationAuto,
isShowAITranslationOnce,
inList,
compact,
isEntryInReadability,
isContentContainsHTMLTags,
entry?.doesContentContainsHTMLTags,
])
return actionConfigs

View File

@ -1,4 +1,3 @@
import { Button } from "@follow/components/ui/button/index.js"
import type { FeedViewType } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
@ -22,10 +21,9 @@ import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding
import { FeedForm } from "~/modules/discover/FeedForm"
import { InboxForm } from "~/modules/discover/InboxForm"
import { ListForm } from "~/modules/discover/ListForm"
import {
CategoryCreationModalContent,
ListCreationModalContent,
} from "~/modules/settings/tabs/lists/modals"
import { useConfirmUnsubscribeSubscriptionModal } from "~/modules/modal/hooks/useConfirmUnsubscribeSubscriptionModal"
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"
@ -41,24 +39,6 @@ import { useNavigateEntry } from "./useNavigateEntry"
import { getRouteParams } from "./useRouteParams"
import { useBatchUpdateSubscription, useDeleteSubscription } from "./useSubscriptionActions"
const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) => {
const { t } = useTranslation()
return (
<div className="w-[540px]">
<div className="mb-4">
<i className="i-mingcute-warning-fill text-red -mb-1 mr-1 size-5" />
{t("sidebar.feed_actions.unfollow_feed_many_warning")}
</div>
<div className="flex justify-end">
<Button buttonClassName="bg-red" onClick={onConfirm}>
{t("words.confirm")}
</Button>
</div>
</div>
)
}
export const useFeedActions = ({
feedId,
feedIds,
@ -89,6 +69,7 @@ export const useFeedActions = ({
useMemo(() => feedIds || [feedId], [feedId, feedIds]),
)
const { present } = useModalStack()
const presentDeleteSubscription = useConfirmUnsubscribeSubscriptionModal()
const deleteSubscription = useDeleteSubscription({})
const claimFeed = useFeedClaimModal()
@ -99,6 +80,7 @@ export const useFeedActions = ({
const { mutateAsync: removeFeedFromListMutation } = useRemoveFeedFromFeedList()
const { mutateAsync: resetFeed } = useResetFeed()
const { mutate: addFeedsToCategoryMutation } = useBatchUpdateSubscription()
const presentCategoryCreationModal = useCategoryCreationModal()
const openBoostModal = useBoostModal()
const listByView = useOwnedListByView(view!)
@ -239,20 +221,7 @@ export const useFeedActions = ({
label: t("sidebar.feed_column.context_menu.create_category"),
icon: <i className="i-mgc-add-cute-re" />,
click() {
present({
title: t("sidebar.feed_column.context_menu.title"),
content: () => (
<CategoryCreationModalContent
onSubmit={(category: string) => {
addFeedsToCategoryMutation({
feedIdList: isMultipleSelection ? feedIds : [feedId],
category,
view: view!,
})
}}
/>
),
})
presentCategoryCreationModal(view!, isMultipleSelection ? feedIds : [feedId])
},
}),
],
@ -281,17 +250,7 @@ export const useFeedActions = ({
supportMultipleSelection: true,
click: () => {
if (isMultipleSelection) {
present({
title: t("sidebar.feed_actions.unfollow_feed_many_confirm"),
content: ({ dismiss }) => (
<ConfirmDestroyModalContent
onConfirm={() => {
deleteSubscription.mutate({ feedIdList: feedIds })
dismiss()
}}
/>
),
})
presentDeleteSubscription(feedIds)
return
}
deleteSubscription.mutate({ subscription })
@ -367,30 +326,32 @@ export const useFeedActions = ({
item.supportMultipleSelection),
)
}, [
addFeedToListMutation,
addFeedsToCategoryMutation,
categories,
claimFeed,
deleteSubscription,
feed,
feedId,
feedIds,
inbox,
t,
shortcuts,
isEntryList,
isInMASReview,
isInbox,
listByView,
categories,
isMultipleSelection,
feedId,
feedIds,
claimFeed,
resetFeed,
openBoostModal,
addFeedToListMutation,
removeFeedFromListMutation,
present,
subscriptions,
subscription,
addFeedsToCategoryMutation,
view,
deleteSubscription,
listByView,
navigateEntry,
openBoostModal,
present,
presentCategoryCreationModal,
presentDeleteSubscription,
removeFeedFromListMutation,
resetFeed,
shortcuts,
subscription,
subscriptions,
t,
view,
])
return items

View File

@ -2,12 +2,12 @@ import { IN_ELECTRON } from "@follow/shared/constants"
import { atom, useAtomValue, useSetAtom } from "jotai"
import { useCallback } from "react"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
const proxyAtom = atom("")
proxyAtom.onMount = (setAtom) => {
tipcClient?.getProxyConfig().then((proxy) => {
ipcServices?.setting.getProxyConfig().then((proxy) => {
setAtom(proxy || "")
})
}
@ -22,7 +22,7 @@ export const useSetProxy = () => {
return
}
setProxy(proxyString)
tipcClient?.setProxyConfig(proxyString)
ipcServices?.setting.setProxyConfig(proxyString)
},
[setProxy],
)

View File

@ -2,13 +2,16 @@ import { IN_ELECTRON } from "@follow/shared/constants"
import { atom, useAtomValue, useSetAtom } from "jotai"
import { useCallback } from "react"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
const minimizeToTrayAtom = atom<boolean>(true)
minimizeToTrayAtom.onMount = (setAtom) => {
tipcClient?.getMinimizeToTray().then((proxy: boolean) => {
setAtom(proxy)
const result = ipcServices?.setting.getMinimizeToTray()
Promise.resolve(result).then((proxy) => {
if (typeof proxy === "boolean") {
setAtom(proxy)
}
})
}
@ -20,7 +23,7 @@ export const useSetMinimizeToTray = () => {
(value: boolean) => {
if (!IN_ELECTRON) return
setMinimizeToTray(value)
tipcClient?.setMinimizeToTray(value)
ipcServices?.setting.setMinimizeToTray(value)
},
[setMinimizeToTray],
)

View File

@ -6,13 +6,24 @@ import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
export const useFeedSafeUrl = (entryId: string) => {
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId)
const entry = useEntry(entryId, (state) => {
return {
feedId: state.feedId,
inboxId: state.inboxId,
url: state.entries.url,
authorUrl: state.entries.authorUrl,
}
})
const feed = useFeedById(entry?.feedId, (feed) => ({
type: feed?.type,
siteUrl: feed?.siteUrl,
}))
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
return useMemo(() => {
if (inbox) return entry?.entries.authorUrl
const href = entry?.entries.url
if (inbox) return entry?.authorUrl
const href = entry?.url
if (!href) return "#"
if (href.startsWith("http")) {
@ -21,8 +32,8 @@ export const useFeedSafeUrl = (entryId: string) => {
return href
}
const feedSiteUrl = feed?.type === "feed" ? feed.siteUrl : null
const feedSiteUrl = feed?.type === "feed" ? feed?.siteUrl : null
if (feedSiteUrl) return resolveUrlWithBase(href, feedSiteUrl)
return href
}, [entry?.entries.authorUrl, entry?.entries.url, feed?.type, inbox])
}, [entry?.authorUrl, entry?.url, feed?.type, feed?.siteUrl, inbox])
}

View File

@ -8,14 +8,14 @@ import {
import { IN_ELECTRON } from "@follow/shared/constants"
import { useCallback, useLayoutEffect } from "react"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
const useSyncThemeElectron = () => {
const appIsDark = useDarkQuery()
const setTheme = internal_useSetTheme()
useLayoutEffect(() => {
let isMounted = true
tipcClient?.getAppearance().then((appearance) => {
ipcServices?.setting.getAppearance().then((appearance) => {
if (!isMounted) return
setTheme(appearance)
disableTransition(["[role=switch]>*"])()
@ -38,7 +38,7 @@ export const useSetTheme = () => {
setTheme(colorMode)
if (IN_ELECTRON) {
tipcClient?.setAppearance(colorMode)
ipcServices?.setting.setAppearance(colorMode)
}
},
[setTheme],

View File

@ -34,10 +34,6 @@ const cleanup = subscribeShouldUseIndexedDB((value) => {
declare global {
interface Window {
version: string
recaptchaOptions: {
useRecaptchaNet: boolean
enterprise: boolean
}
}
}
@ -124,11 +120,6 @@ export const initializeApp = async () => {
version: APP_VERSION,
rn: false,
})
// Options for react-google-recaptcha
window.recaptchaOptions = {
useRecaptchaNet: true,
enterprise: true,
}
}
import.meta.hot?.dispose(cleanup)

View File

@ -6,6 +6,8 @@ const auth = new Auth({
webURL: env.VITE_WEB_URL,
})
export const { authClient } = auth
// @keep-sorted
export const {
changeEmail,

View File

@ -1,8 +1,25 @@
import { createClient } from "@egoist/tipc/renderer"
import type { Router } from "@follow/electron-main"
import type { IpcServices } from "@follow/electron-main"
export const tipcClient = window.electron
? createClient<Router>({
ipcInvoke: window.electron.ipcRenderer.invoke,
})
: null
function createIpcProxy(): IpcServices | null {
if (!window.electron) {
return null
}
return new Proxy({} as IpcServices, {
get(target, groupName: string) {
return new Proxy(
{},
{
get(_, methodName: string) {
return (...args: any[]) => {
const channel = `${groupName}.${methodName}`
return window.electron!.ipcRenderer.invoke(channel, args[0])
}
},
},
)
},
})
}
export const ipcServices = createIpcProxy()

View File

@ -0,0 +1,59 @@
import type { RendererHandlers } from "@follow/electron-main"
type EventCallback<T> = (data: T) => void
export function createEventHandlers() {
if (!window.electron) {
// Web fallback - return empty handlers
return {
invalidateQuery: {
listen: () => () => {},
},
updateDownloaded: {
listen: () => () => {},
},
navigateEntry: {
listen: () => () => {},
},
}
}
return {
invalidateQuery: {
listen: (callback: EventCallback<Parameters<RendererHandlers["invalidateQuery"]>[0]>) => {
const channel = "renderer-event:invalidateQuery"
const handler = (_: any, data: Parameters<RendererHandlers["invalidateQuery"]>[0]) => {
callback(data)
}
window.electron!.ipcRenderer.on(channel, handler)
return () => {
window.electron!.ipcRenderer.removeListener(channel, handler)
}
},
},
updateDownloaded: {
listen: (callback: EventCallback<void>) => {
const channel = "renderer-event:updateDownloaded"
const handler = () => {
callback()
}
window.electron!.ipcRenderer.on(channel, handler)
return () => {
window.electron!.ipcRenderer.removeListener(channel, handler)
}
},
},
navigateEntry: {
listen: (callback: EventCallback<Parameters<RendererHandlers["navigateEntry"]>[0]>) => {
const channel = "renderer-event:navigateEntry"
const handler = (_: any, data: Parameters<RendererHandlers["navigateEntry"]>[0]) => {
callback(data)
}
window.electron!.ipcRenderer.on(channel, handler)
return () => {
window.electron!.ipcRenderer.removeListener(channel, handler)
}
},
},
}
}

View File

@ -10,7 +10,7 @@ import { defaultResources } from "~/@types/default-resource"
import { i18nAtom, langChain, LocaleCache } from "~/i18n"
import { jotaiStore } from "~/lib/jotai"
import { tipcClient } from "./client"
import { ipcServices } from "./client"
import { appLog } from "./log"
const loadingLangLock = new Set<string>()
@ -29,7 +29,7 @@ export const loadLanguageAndApply = async (lang: string) => {
})
}
tipcClient?.switchAppLocale(lang)
ipcServices?.app.switchAppLocale(lang)
const { t } = jotaiStore.get(i18nAtom)
if (loadingLangLock.has(lang)) return
@ -78,7 +78,8 @@ export const loadLanguageAndApply = async (lang: string) => {
if (IN_ELECTRON) {
importFilePath =
(await tipcClient?.resolveAppAsarPath(`dist/renderer/locales/${lang}.js`)) || ""
(await (ipcServices as any)?.app.resolveAppAsarPath(`dist/renderer/locales/${lang}.js`)) ||
""
} else {
importFilePath = `/locales/${lang}.js`
}

View File

@ -1,6 +1,6 @@
import type { MenuItemConstructorOptions } from "electron"
import { tipcClient } from "./client"
import { ipcServices } from "./client"
export type ElectronMenuItem = Omit<MenuItemConstructorOptions, "click" | "submenu"> & {
click?: () => void
@ -21,34 +21,34 @@ export const showElectronContextMenu = async (items: Array<ElectronMenuItem>) =>
},
)
const itemsWithoutClick = removeClick(items)
await tipcClient?.showContextMenu({ items: itemsWithoutClick })
await ipcServices?.menu.showContextMenu({ items: itemsWithoutClick })
dispose()
}
const removeClick = (item: ElectronMenuItem[]): ElectronMenuItem[] =>
item.map(({ click, ...rest }) => {
if (rest.submenu)
return {
...rest,
submenu: removeClick(rest.submenu),
}
return rest
})
// Function to retrieve the menu item based on the provided path
const getMenuItemByPath = (items: ElectronMenuItem[], path: number[]): ElectronMenuItem | null => {
let currentItems = items
let currentItem: ElectronMenuItem | null = null
function getMenuItemByPath(
items: Array<ElectronMenuItem>,
path: number[],
): ElectronMenuItem | null {
let current: ElectronMenuItem | null = null
let currentLevel = items
for (const index of path) {
if (!currentItems || index >= currentItems.length) return null
currentItem = currentItems[index]!
if (currentItem.submenu && Array.isArray(currentItem.submenu)) {
currentItems = currentItem.submenu
} else {
currentItems = []
if (index >= currentLevel.length) {
return null
}
current = currentLevel[index] || null
if (current?.submenu && path.indexOf(index) < path.length - 1) {
currentLevel = current.submenu
}
}
return currentItem
return current
}
function removeClick(items: Array<ElectronMenuItem>): Array<ElectronMenuItem> {
return items.map((item) => ({
...item,
click: undefined,
submenu: item.submenu ? removeClick(item.submenu) : undefined,
}))
}

View File

@ -108,7 +108,6 @@ export const parseHtml = (
markInlineImage(node)
return createElement("i", props, props.children)
},
// @ts-expect-error
math: Math,
hr: ({ node, ...props }) =>
createElement("hr", {
@ -117,12 +116,11 @@ export const parseHtml = (
}),
input: ({ node, ...props }) => {
if (props.type === "checkbox") {
// @ts-expect-error
return createElement(Checkbox, {
...props,
disabled: false,
className: tw`pointer-events-none mr-2`,
})
} as any)
}
return createElement("input", props)
},

View File

@ -1,10 +1,8 @@
import type { EntryModel } from "@follow/models/types"
import { isTwitterUrl, isXUrl } from "@follow/utils/link-parser"
export const parseSocialMedia = (entry: EntryModel) => {
const { authorUrl, url, guid } = entry
export const parseSocialMedia = (parsedUrl?: string | null) => {
if (!parsedUrl) return
const parsedUrl = authorUrl || url || guid
const isX = isXUrl(parsedUrl).validate || isTwitterUrl(parsedUrl).validate
if (isX) {

View File

@ -6,7 +6,7 @@ import { duplicateIfLengthLessThan } from "@follow/utils/utils"
import { franc } from "franc-min"
import { getReadabilityContent } from "~/atoms/readability"
import type { FlatEntryModel } from "~/store/entry"
import { getEntry } from "~/store/entry"
import { apiClient } from "./api-fetch"
@ -34,27 +34,28 @@ export const checkLanguage = ({
}
export async function translate({
entry,
entryId,
view,
language,
extraFields,
part,
}: {
entry?: FlatEntryModel | null
view?: number
entryId?: string | null
view?: number | null
language?: SupportedActionLanguage
extraFields?: string[]
part?: string
}) {
if (!language || !entry) {
if (!language || !entryId) {
return null
}
let fields = language && view !== undefined ? views[view!]!.translation.split(",") : []
let fields = language && typeof view === "number" ? views[view!]!.translation.split(",") : []
if (extraFields) {
fields = [...fields, ...extraFields]
}
const readabilityContent = getReadabilityContent()[entry.entries.id]?.content
const readabilityContent = getReadabilityContent()[entryId]?.content
const entries = getEntry(entryId)?.entries
fields = fields.filter((field) => {
if (language && field === "readabilityContent") {
if (!readabilityContent) return false
@ -65,9 +66,9 @@ export async function translate({
return !isLanguageMatch
}
if (language && entry.entries[field]) {
if (language && entries?.[field]) {
const isLanguageMatch = checkLanguage({
content: entry.entries[field],
content: entries[field],
language,
})
return !isLanguageMatch
@ -82,7 +83,7 @@ export async function translate({
const res = await apiClient.ai.translation.$get({
query: {
id: entry.entries.id,
id: entryId,
language,
fields: fields?.join(",") || "title",
part,
@ -97,7 +98,7 @@ export async function translate({
} = {}
fields.forEach((field) => {
const content = field === "readabilityContent" ? readabilityContent : entry.entries[field]
const content = field === "readabilityContent" ? readabilityContent : entries?.[field]
if (content !== res.data?.[field]) {
data[field] = res.data?.[field]
}

View File

@ -1,5 +1,24 @@
import { FeedViewType } from "@follow/constants"
import { UrlBuilder as UrlBuilderClass } from "@follow/utils/url-builder"
import { WEB_URL } from "~/constants/env"
export const UrlBuilder = new UrlBuilderClass(WEB_URL)
class WebUrlBuilder extends UrlBuilderClass {
constructor() {
super(WEB_URL)
}
shareEntry(
id: string,
options?: {
view?: FeedViewType
subscriptionId?: string
},
) {
const { view = FeedViewType.Articles, subscriptionId = "all" } = options || {}
return super.join(`timeline/view-${view}/${subscriptionId}/${id}`, { share: "1" })
}
}
export const UrlBuilder = new WebUrlBuilder()

View File

@ -3,17 +3,24 @@ import "@follow/components/tailwind"
import "./styles/main.css"
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context"
import { getOS } from "@follow/utils/utils"
import * as React from "react"
import ReactDOM from "react-dom/client"
import { RouterProvider } from "react-router/dom"
import { apiClient } from "~/lib/api-fetch"
import { authClient } from "~/lib/auth"
import { setAppIsReady } from "./atoms/app"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants"
import { initializeApp } from "./initialize"
import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts"
import { router } from "./router"
apiClientSimpleContext.provide(apiClient)
authClientSimpleContext.provide(authClient)
initializeApp().finally(() => {
import("./push-notification").then(({ registerWebPushNotifications }) => {
if (navigator.serviceWorker && WEB_BUILD) {

View File

@ -2,11 +2,11 @@ import { Card, CardContent, CardHeader } from "@follow/components/ui/card/index.
import { Input } from "@follow/components/ui/input/index.js"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { useActionRule } from "@follow/store/action/hooks"
import { actionActions } from "@follow/store/action/store"
import { clsx } from "@follow/utils/utils"
import { useTranslation } from "react-i18next"
import { actionActions, useActionByIndex } from "~/store/action"
import { FeedFilter } from "./feed-filter"
import { TargetActionList } from "./target-action-list"
@ -23,7 +23,7 @@ export const ActionCard = ({ index }: { index: number }) => {
"absolute -right-2 -top-2 z-[1] opacity-100 duration-200 hover:!opacity-100 group-hover:opacity-70 lg:opacity-0",
)}
onClick={() => {
actionActions.removeByIndex(index)
actionActions.deleteRule(index)
}}
>
<i className="i-mgc-close-cute-re text-lg" />
@ -44,10 +44,9 @@ export const ActionCard = ({ index }: { index: number }) => {
const ActionCardToolbar = ({ index }: { index: number }) => {
const { t } = useTranslation("settings")
const name = useActionByIndex(index, (a) => a.name)
const disabled = useActionByIndex(index, (a) => a.result.disabled)
const name = useActionRule(index, (a) => a.name)
const disabled = useActionRule(index, (a) => a.result.disabled)
const onChange = actionActions.updateByIndex.bind(null, index)
return (
<div className="flex w-full items-center gap-3">
<p className="shrink-0 font-medium text-zinc-500">{t("actions.action_card.name")}</p>
@ -55,9 +54,7 @@ const ActionCardToolbar = ({ index }: { index: number }) => {
value={name}
className="h-8 max-w-64"
onChange={(e) => {
onChange((data) => {
data.name = e.target.value
})
actionActions.patchRule(index, { name: e.target.value })
}}
/>
<div className="grow" />
@ -65,8 +62,8 @@ const ActionCardToolbar = ({ index }: { index: number }) => {
<Switch
checked={!disabled}
onCheckedChange={(checked) => {
onChange((data) => {
data.result.disabled = !checked
actionActions.patchRule(index, {
result: { disabled: !checked },
})
}}
/>

View File

@ -1,19 +1,22 @@
import { Button } from "@follow/components/ui/button/index.js"
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { useMutation } from "@tanstack/react-query"
import {
useActionRules,
useIsActionDataDirty,
usePrefetchActions,
useUpdateActionsMutation,
} from "@follow/store/action/hooks"
import { actionActions } from "@follow/store/action/store"
import { useTranslation } from "react-i18next"
import { unstable_usePrompt } from "react-router"
import { toast } from "sonner"
import { toastFetchError } from "~/lib/error-parser"
import { queryClient } from "~/lib/query-client"
import { ActionCard } from "~/modules/action/action-card"
import { useActionsQuery } from "~/queries/actions"
import { actionActions, useActions, useIsActionDataDirty } from "~/store/action"
export const ActionSetting = () => {
const actionQuery = useActionsQuery()
const actionLength = useActions((actions) => actions.length)
const actionQuery = usePrefetchActions()
const actionLength = useActionRules((actions) => actions.length)
if (actionQuery.isPending) {
return <LoadingWithIcon icon={<i className="i-mgc-magic-2-cute-re" />} size="large" />
@ -32,7 +35,7 @@ export const ActionSetting = () => {
function ActionSettingOperations() {
const { t } = useTranslation("settings")
const actionLength = useActions((actions) => actions.length)
const actionLength = useActionRules((actions) => actions.length)
const isDirty = useIsActionDataDirty()
unstable_usePrompt({
message: t("actions.navigate.prompt"),
@ -40,8 +43,7 @@ function ActionSettingOperations() {
isDirty && currentLocation.pathname !== nextLocation.pathname,
})
const mutation = useMutation({
mutationFn: () => actionActions.updateRemoteActions(),
const mutation = useUpdateActionsMutation({
onSuccess: () => {
// apply new action settings
queryClient.invalidateQueries({
@ -50,7 +52,7 @@ function ActionSettingOperations() {
toast(t("actions.saveSuccess"))
},
onError: (error) => {
toastFetchError(error)
toast.error(error)
},
})
@ -59,7 +61,7 @@ function ActionSettingOperations() {
<Button
variant={actionLength > 0 ? "outline" : "primary"}
onClick={() => {
actionActions.insertNewEmptyAction(t("actions.actionName", { number: actionLength + 1 }))
actionActions.addRule((number) => t("actions.actionName", { number }))
}}
>
<i className="i-mgc-add-cute-re mr-1" />

View File

@ -10,72 +10,21 @@ import {
} from "@follow/components/ui/select/index.jsx"
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import type { ActionFeedField, ActionOperation } from "@follow/models/types"
import { filterFieldOptions, filterOperatorOptions } from "@follow/store/action/constant"
import { useActionRule } from "@follow/store/action/hooks"
import { actionActions } from "@follow/store/action/store"
import { cn } from "@follow/utils/utils"
import { Fragment, useMemo } from "react"
import { Fragment } from "react"
import { useTranslation } from "react-i18next"
import { ViewSelectContent } from "~/modules/feed/view-select-content"
import { actionActions, useActionByIndex } from "~/store/action"
export const FeedFilter = ({ index }: { index: number }) => {
const { t } = useTranslation("settings")
const FeedOptions = useMemo(() => {
return [
{
label: t("actions.action_card.feed_options.status"),
value: "status",
type: "status",
},
{
label: t("actions.action_card.feed_options.subscription_view"),
value: "view",
type: "view",
},
{
label: t("actions.action_card.feed_options.feed_title"),
value: "title",
},
{
label: t("actions.action_card.feed_options.feed_category"),
value: "category",
},
{
label: t("actions.action_card.feed_options.site_url"),
value: "site_url",
},
{
label: t("actions.action_card.feed_options.feed_url"),
value: "feed_url",
},
{
label: t("actions.action_card.feed_options.entry_title"),
value: "entry_title",
},
{
label: t("actions.action_card.feed_options.entry_content"),
value: "entry_content",
},
{
label: t("actions.action_card.feed_options.entry_url"),
value: "entry_url",
},
{
label: t("actions.action_card.feed_options.entry_author"),
value: "entry_author",
},
{
label: t("actions.action_card.feed_options.entry_media_length"),
value: "entry_media_length",
type: "number",
},
]
}, [t])
const disabled = useActionRule(index, (a) => a.result.disabled)
const condition = useActionRule(index, (a) => a.condition)
const disabled = useActionByIndex(index, (a) => a.result.disabled)
const condition = useActionByIndex(index, (a) => a.condition)
const onChange = actionActions.updateByIndex.bind(null, index)
return (
<div className="w-full shrink space-y-3 overflow-auto">
<p className="font-medium text-zinc-500">{t("actions.action_card.when_feeds_match")}</p>
@ -83,12 +32,8 @@ export const FeedFilter = ({ index }: { index: number }) => {
<RadioGroup
value={condition.length > 0 ? "filter" : "all"}
onValueChange={(value) => {
onChange((data) => {
if (value === "all") {
data.condition = []
} else {
data.condition = [[{}]]
}
actionActions.patchRule(index, {
condition: value === "all" ? [] : [[{}]],
})
}}
>
@ -108,16 +53,20 @@ export const FeedFilter = ({ index }: { index: number }) => {
<div className="mt-2">
{condition.flatMap((orConditions, orConditionIdx) => {
return orConditions.map((condition, conditionIdx) => {
const actionConditionIndex = {
ruleIndex: index,
groupIndex: orConditionIdx,
conditionIndex: conditionIdx,
}
const change = (key: string, value: string | number) => {
onChange((data) => {
if (!data.condition[orConditionIdx]) {
data.condition[orConditionIdx] = [{}]
}
data.condition[orConditionIdx][conditionIdx]![key] = value
actionActions.pathCondition(actionConditionIndex, {
[key]: value,
})
}
const type =
FeedOptions.find((option) => option.value === condition.field)?.type || "text"
filterFieldOptions.find((option) => option.value === condition.field)?.type ||
"text"
return (
<Fragment key={`${orConditionIdx}${conditionIdx}`}>
{conditionIdx === 0 && orConditionIdx !== 0 && (
@ -135,7 +84,10 @@ export const FeedFilter = ({ index }: { index: number }) => {
disabled={disabled}
value={condition.field}
onValueChange={(value) => change("field", value as ActionFeedField)}
items={FeedOptions}
items={filterFieldOptions.map((option) => ({
...option,
label: t(option.label),
}))}
triggerClassName="max-sm:w-fit h-8"
/>
</div>
@ -164,9 +116,7 @@ export const FeedFilter = ({ index }: { index: number }) => {
variant="outline"
disabled={disabled}
onClick={() => {
onChange((data) => {
data.condition[orConditionIdx]!.push({})
})
actionActions.addConditionItem(actionConditionIndex)
}}
>
{t("actions.action_card.and")}
@ -176,9 +126,7 @@ export const FeedFilter = ({ index }: { index: number }) => {
buttonClassName="w-full max-sm:hidden"
disabled={disabled}
onClick={() => {
onChange((data) => {
data.condition[orConditionIdx]!.push({})
})
actionActions.addConditionItem(actionConditionIndex)
}}
>
{t("actions.action_card.and")}
@ -187,13 +135,7 @@ export const FeedFilter = ({ index }: { index: number }) => {
variant="ghost"
disabled={disabled}
onClick={() => {
onChange((data) => {
if (data.condition[orConditionIdx]!.length === 1) {
data.condition.splice(orConditionIdx, 1)
} else {
data.condition[orConditionIdx]!.splice(conditionIdx, 1)
}
})
actionActions.deleteConditionItem(actionConditionIndex)
}}
>
<i className="i-mgc-delete-2-cute-re size-5 text-zinc-600" />
@ -221,9 +163,7 @@ export const FeedFilter = ({ index }: { index: number }) => {
variant="outline"
buttonClassName="mt-4 w-full gap-1 py-1"
onClick={() => {
onChange((data) => {
data.condition.push([{}])
})
actionActions.addConditionGroup({ ruleIndex: index })
}}
disabled={disabled}
>
@ -241,54 +181,19 @@ const OperationSelect = ({
onValueChange,
disabled,
}: {
type: string
type: "text" | "number" | "view" | "status"
value?: ActionOperation
onValueChange?: (value: ActionOperation) => void
disabled?: boolean
}) => {
const { t } = useTranslation("settings")
const OperationOptions = useMemo(() => {
return [
{
label: t("actions.action_card.operation_options.contains"),
value: "contains",
types: ["text"],
},
{
label: t("actions.action_card.operation_options.does_not_contain"),
value: "not_contains",
types: ["text"],
},
{
label: t("actions.action_card.operation_options.is_equal_to"),
value: "eq",
types: ["number", "text", "view", "status"],
},
{
label: t("actions.action_card.operation_options.is_not_equal_to"),
value: "not_eq",
types: ["number", "text", "view"],
},
{
label: t("actions.action_card.operation_options.is_greater_than"),
value: "gt",
types: ["number"],
},
{
label: t("actions.action_card.operation_options.is_less_than"),
value: "lt",
types: ["number"],
},
{
label: t("actions.action_card.operation_options.matches_regex"),
value: "regex",
types: ["text"],
},
]
}, [t])
const options = OperationOptions.filter((option) => option.types.includes(type))
const options = filterOperatorOptions
.filter((option) => option.types.includes(type))
.map((option) => ({
...option,
label: t(option.label),
}))
if (options.length === 1 && value === undefined) {
onValueChange?.(options[0]!.value as ActionOperation)
}

View File

@ -9,7 +9,12 @@ import {
TableHeader,
TableRow,
} from "@follow/components/ui/table/index.jsx"
import type { ActionModel } from "@follow/models/types"
import type { ActionId } from "@follow/models/types"
import type { ActionAction } from "@follow/store/action/constant"
import { availableActionMap } from "@follow/store/action/constant"
import { useActionRule } from "@follow/store/action/hooks"
import { actionActions } from "@follow/store/action/store"
import { merge } from "es-toolkit/compat"
import { Fragment, useMemo } from "react"
import { useTranslation } from "react-i18next"
@ -19,21 +24,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { actionActions, useActionByIndex } from "~/store/action"
import { useSettingModal } from "../settings/modal/use-setting-modal"
type Action = {
title: string
icon: React.ReactNode
config?: () => React.ReactNode
configInline?: boolean
enabled: boolean
settingsPath?: string
onInit: (data: ActionModel) => void
onRemove: (data: ActionModel) => void
}
const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
const { t } = useTranslation("settings")
return (
@ -58,131 +51,24 @@ const DeleteTableCell = ({ disabled, onClick }: { disabled?: boolean; onClick?:
)
export const TargetActionList = ({ index }: { index: number }) => {
const summary = useActionByIndex(index, (a) => a.result.summary)
const translation = useActionByIndex(index, (a) => a.result.translation)
const readability = useActionByIndex(index, (a) => a.result.readability)
const sourceContent = useActionByIndex(index, (a) => a.result.sourceContent)
const newEntryNotification = useActionByIndex(index, (a) => a.result.newEntryNotification)
const silence = useActionByIndex(index, (a) => a.result.silence)
const block = useActionByIndex(index, (a) => a.result.block)
const star = useActionByIndex(index, (a) => a.result.star)
const rewriteRules = useActionByIndex(index, (a) => a.result.rewriteRules)
const webhooks = useActionByIndex(index, (a) => a.result.webhooks)
const result = useActionRule(index, (a) => a.result)
const rewriteRules = useActionRule(index, (a) => a.result.rewriteRules)
const webhooks = useActionRule(index, (a) => a.result.webhooks)
const settingModalPresent = useSettingModal()
const disabled = useActionByIndex(index, (a) => a.result.disabled)
const disabled = useActionRule(index, (a) => a.result.disabled)
const { t } = useTranslation("settings")
const onChange = actionActions.updateByIndex.bind(null, index)
const availableActions: Action[] = useMemo(
() => [
{
title: t("actions.action_card.generate_summary"),
icon: <i className="i-mgc-ai-cute-re" />,
enabled: !!summary,
settingsPath: "general",
onInit: (data) => {
data.result.summary = true
},
onRemove: (data) => {
delete data.result.summary
},
},
{
title: t("actions.action_card.translate_into"),
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
enabled: !!translation,
settingsPath: "general",
onInit: (data) => {
data.result.translation = true
},
onRemove: (data) => {
delete data.result.translation
},
},
{
title: t("actions.action_card.enable_readability"),
icon: <i className="i-mgc-docment-cute-re" />,
enabled: !!readability,
onInit: (data) => {
data.result.readability = true
},
onRemove: (data) => {
delete data.result.readability
},
},
{
title: t("actions.action_card.source_content"),
icon: <i className="i-mgc-web-cute-re" />,
enabled: !!sourceContent,
onInit: (data) => {
data.result.sourceContent = true
},
onRemove: (data) => {
delete data.result.sourceContent
},
},
{
title: t("actions.action_card.new_entry_notification"),
icon: <i className="i-mgc-notification-cute-re" />,
settingsPath: "notifications",
enabled: !!newEntryNotification,
onInit: (data) => {
data.result.newEntryNotification = true
},
onRemove: (data) => {
delete data.result.newEntryNotification
},
},
{
title: t("actions.action_card.silence"),
icon: <i className="i-mgc-volume-mute-cute-re" />,
enabled: !!silence,
onInit: (data) => {
data.result.silence = true
},
onRemove: (data) => {
delete data.result.silence
},
},
{
title: t("actions.action_card.block"),
icon: <i className="i-mgc-delete-2-cute-re" />,
enabled: !!block,
onInit: (data) => {
data.result.block = true
},
onRemove: (data) => {
delete data.result.block
},
},
{
title: t("actions.action_card.star"),
icon: <i className="i-mgc-star-cute-re" />,
enabled: !!star,
onInit: (data) => {
data.result.star = true
},
onRemove: (data) => {
delete data.result.star
},
},
{
title: t("actions.action_card.rewrite_rules"),
icon: <i className="i-mgc-quill-pen-cute-re" />,
enabled: !!rewriteRules,
onInit: (data) => {
data.result.rewriteRules = [
{
from: "",
to: "",
},
]
},
onRemove: (data) => {
delete data.result.rewriteRules
},
const availableActions = useMemo(() => {
const extendedAvailableActionMap: Record<
ActionId,
ActionAction & {
config?: () => React.ReactNode
configInline?: boolean
}
> = merge(availableActionMap, {
rewriteRules: {
config: () => (
<>
<Table className="mt-2">
@ -195,9 +81,12 @@ export const TargetActionList = ({ index }: { index: number }) => {
</TableHeader>
<TableBody>
{rewriteRules?.map((rule, rewriteIdx) => {
const change = (key: string, value: string) => {
onChange((data) => {
data.result.rewriteRules![rewriteIdx]![key] = value
const change = (key: "from" | "to", value: string) => {
actionActions.updateRewriteRule({
index,
rewriteRuleIndex: rewriteIdx,
key,
value,
})
}
return (
@ -221,13 +110,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
<DeleteTableCell
disabled={disabled}
onClick={() => {
onChange((data) => {
if (data.result.rewriteRules?.length === 1) {
delete data.result.rewriteRules
} else {
data.result.rewriteRules?.splice(rewriteIdx, 1)
}
})
actionActions.deleteRewriteRule(index, rewriteIdx)
}}
/>
</TableRow>
@ -238,56 +121,35 @@ export const TargetActionList = ({ index }: { index: number }) => {
<AddTableRow
disabled={disabled}
onClick={() => {
onChange((data) => {
if (!data.result.rewriteRules) {
data.result.rewriteRules = []
}
data.result.rewriteRules!.push({
from: "",
to: "",
})
})
actionActions.addRewriteRule(index)
}}
/>
</>
),
},
{
title: t("actions.action_card.webhooks"),
icon: <i className="i-mgc-webhook-cute-re" />,
enabled: !!webhooks,
onInit: (data) => {
data.result.webhooks = [""]
},
onRemove: (data) => {
delete data.result.webhooks
},
webhooks: {
config: () => (
<>
{webhooks?.map((webhook, rewriteIdx) => {
{webhooks?.map((webhook, webhookIdx) => {
return (
<div key={rewriteIdx} className="flex items-center gap-2">
<div key={webhookIdx} className="flex items-center gap-2">
<Input
disabled={disabled}
value={webhook}
className="h-8"
placeholder="https://"
onChange={(e) => {
onChange((data) => {
data.result.webhooks![rewriteIdx] = e.target.value
actionActions.updateWebhook({
index,
webhookIndex: webhookIdx,
value: e.target.value,
})
}}
/>
<DeleteTableCell
disabled={disabled}
onClick={() => {
onChange((data) => {
if (data.result.webhooks?.length === 1) {
delete data.result.webhooks
} else {
data.result.webhooks?.splice(rewriteIdx, 1)
}
})
actionActions.deleteWebhook(index, webhookIdx)
}}
/>
</div>
@ -296,40 +158,22 @@ export const TargetActionList = ({ index }: { index: number }) => {
<AddTableRow
disabled={disabled}
onClick={() => {
onChange((data) => {
if (!data.result.webhooks) {
data.result.webhooks = []
}
data.result.webhooks!.push("")
})
actionActions.addWebhook(index)
}}
/>
</>
),
},
],
[
block,
disabled,
newEntryNotification,
onChange,
readability,
rewriteRules,
silence,
sourceContent,
summary,
t,
translation,
webhooks,
],
)
})
return Object.values(extendedAvailableActionMap)
}, [disabled, index, rewriteRules, t, webhooks])
const enabledActions = useMemo(
() => availableActions.filter((action) => action.enabled),
[availableActions],
() => availableActions.filter((action) => !!result?.[action.value]),
[availableActions, result],
)
const notEnabledActions = useMemo(
() => availableActions.filter((action) => !action.enabled),
[availableActions],
() => availableActions.filter((action) => !result?.[action.value]),
[availableActions, result],
)
return (
@ -346,16 +190,18 @@ export const TargetActionList = ({ index }: { index: number }) => {
{notEnabledActions.map((action) => {
return (
<DropdownMenuItem
key={action.title}
key={action.label}
onClick={() => {
onChange((data) => {
action.onInit(data)
})
if (action.onEnable) {
action.onEnable(index)
} else {
actionActions.patchRule(index, { result: { [action.value]: true } })
}
}}
>
<div className="flex items-center gap-2">
{action.icon}
{action.title}
<i className={action.iconClassname} />
{t(action.label)}
</div>
</DropdownMenuItem>
)
@ -364,48 +210,44 @@ export const TargetActionList = ({ index }: { index: number }) => {
</DropdownMenu>
<section className="pl-6">
{enabledActions
.filter((action) => action.enabled)
.map((action, index) => {
return (
<Fragment key={action.title}>
<div className="group/action mt-3 flex w-full items-center justify-between">
<div className="flex items-center gap-2">
{action.icon}
<span className="shrink grow truncate">{action.title}</span>
{action.settingsPath && (
<Button
buttonClassName="ml-4"
variant="outline"
size="sm"
onClick={() => {
settingModalPresent(action.settingsPath)
}}
>
{t("actions.action_card.settings")}
</Button>
)}
</div>
{action.configInline && action.config && action.config()}
<Button
buttonClassName="absolute opacity-100 group-hover/action:opacity-70 hover:!opacity-100 duration-200 lg:opacity-0 left-0 z-[1] size-5 rounded-full border"
variant={"ghost"}
disabled={disabled}
onClick={() => {
onChange((data) => {
action.onRemove(data)
})
}}
>
<i className="i-mgc-close-cute-re size-3" />
</Button>
{enabledActions.map((action, index) => {
return (
<Fragment key={action.label}>
<div className="group/action mt-3 flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<i className={action.iconClassname} />
<span className="shrink grow truncate">{t(action.label)}</span>
{action.settingsPath && (
<Button
buttonClassName="ml-4"
variant="outline"
size="sm"
onClick={() => {
settingModalPresent(action.settingsPath)
}}
>
{t("actions.action_card.settings")}
</Button>
)}
</div>
{!action.configInline && action.config && action.config()}
{index !== enabledActions.length - 1 && <Divider className="my-2" />}
</Fragment>
)
})}
{action.configInline && action.config && action.config()}
<Button
buttonClassName="absolute opacity-100 group-hover/action:opacity-70 hover:!opacity-100 duration-200 lg:opacity-0 left-0 z-[1] size-5 rounded-full border"
variant={"ghost"}
disabled={disabled}
onClick={() => {
actionActions.deleteRuleAction(index, action.value)
}}
>
<i className="i-mgc-close-cute-re size-3" />
</Button>
</div>
{!action.configInline && action.config && action.config()}
{index !== enabledActions.length - 1 && <Divider className="my-2" />}
</Fragment>
)
})}
</section>
</div>
</div>

View File

@ -47,6 +47,7 @@ import { hasCommand } from "~/modules/command/hooks/use-command"
import { StarIcon } from "~/modules/entry-column/star-icon"
import { EntryContent } from "~/modules/entry-content"
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
@ -215,11 +216,12 @@ const DailyReportContent: Component<DailyReportContentProps> = ({
return pipeline
}}
components={{
// @ts-expect-error
"snowflake-id": SnowflakeId,
a: RelatedEntryLink as Components["a"],
}}
components={
{
"snowflake-id": SnowflakeId,
a: RelatedEntryLink as Components["a"],
} as any as Components
}
className="prose-sm prose-p:my-1 prose-ul:my-1 prose-ul:list-outside prose-ul:list-disc prose-li:marker:text-accent mt-4 px-6"
>
{content.data}
@ -321,7 +323,8 @@ const usePeekModal = () => {
"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0",
CustomModalComponent: ({ children }) => {
const { feedId } = useEntry(entryId) || {}
const feedId = useEntry(entryId, (state) => state.feedId)
if (!feedId) return null
return (
<PeekModal
@ -363,8 +366,31 @@ const EntryToastPreview = ({ entryId }: { entryId: string }) => {
opacity: 0,
},
}
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId || "")
const entry = useEntry(entryId, (state) => {
const { collections, feedId } = state
const { author, authorAvatar, description, publishedAt } = state.entries
const isInCollection = !!collections
const media = state.entries.media || []
const firstPhotoUrl = media.find((a) => a.type === "photo")?.url
const iconEntry: FeedIconEntry = {
firstPhotoUrl,
authorAvatar,
}
return {
author,
description,
feedId,
iconEntry,
isInCollection,
media,
publishedAt,
}
})
const feed = useFeedById(entry?.feedId)
const controller = useAnimationControls()
const isDisplay = !!entry && !!feed
@ -405,21 +431,21 @@ const EntryToastPreview = ({ entryId }: { entryId: string }) => {
fallback
className="mask-squircle mask"
feed={feed}
entry={entry.entries}
entry={entry.iconEntry}
size={36}
/>
<div className="flex min-w-0 grow flex-col">
<div className="w-[calc(100%-10rem)] space-x-1">
<span className="font-semibold">{entry.entries.author}</span>
<span className="font-semibold">{entry.author}</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-500">
<RelativeTime date={entry.entries.publishedAt} />
<RelativeTime date={entry.publishedAt} />
</span>
</div>
<div
className={cn(
"relative mt-0.5 whitespace-pre-line text-base",
!!entry.collections && "pr-5",
entry.isInCollection && "pr-5",
)}
>
<div
@ -430,11 +456,11 @@ const EntryToastPreview = ({ entryId }: { entryId: string }) => {
"break-words",
)}
>
{entry.entries.description}
{entry.description}
{!!entry.entries.media?.length && (
{!!entry.media?.length && (
<div className="mt-1 flex w-full gap-2 overflow-x-auto">
{entry.entries.media.map((media, i, mediaList) => (
{entry.media.map((media, i, mediaList) => (
<Media
key={media.url}
src={media.url}
@ -455,7 +481,7 @@ const EntryToastPreview = ({ entryId }: { entryId: string }) => {
</div>
)}
</div>
{!!entry.collections && <StarIcon />}
{entry.isInCollection && <StarIcon />}
</div>
{/* End right column */}

View File

@ -2,7 +2,7 @@ import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content/index.mobile"
import { EntryColumnMobile } from "../entry-column/mobile"
import { EntryColumnMobile } from "../timeline-column/mobile"
export const EntryContentMobile = () => {
const { entryId } = useRouteParamsSelector((s) => ({

View File

@ -6,6 +6,7 @@ import Marquee from "react-fast-marquee"
import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
import { RelativeTime } from "~/components/ui/datetime"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { PlayerProgress } from "~/modules/player/corner-player"
import { useEntry } from "~/store/entry"
@ -20,7 +21,15 @@ export const PodcastButton = ({ feed }: { feed: FeedModel }) => {
const isMute = useAudioPlayerAtomSelector((v) => v.isMute)
const playerValue = { entryId, status, isMute }
const entry = useEntry(playerValue.entryId)
const entry = useEntry(playerValue.entryId, (state) => {
const { authorAvatar, publishedAt, title } = state.entries
const media = state.entries.media || []
const firstPhotoUrl = media.find((a) => a.type === "photo")?.url
const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }
return { iconEntry, title, publishedAt }
})
if (!entry || !feed) return null
@ -30,21 +39,19 @@ export const PodcastButton = ({ feed }: { feed: FeedModel }) => {
content={
<>
<div className="mb-6 flex gap-4">
<FeedIcon feed={feed} entry={entry.entries} size={58} fallback={false} noMargin />
<FeedIcon feed={feed} entry={entry.iconEntry} size={58} fallback={false} noMargin />
<div className="flex flex-col justify-center">
<Marquee
play={playerValue.status === "playing"}
className="mask-horizontal font-medium"
speed={30}
>
{entry.entries.title}
{entry.title}
</Marquee>
<div className="text-text mt-0.5 overflow-hidden truncate text-xs">
<span>{feed.title}</span>
<span> · </span>
<span>
{!!entry.entries.publishedAt && <RelativeTime date={entry.entries.publishedAt} />}
</span>
<span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>
</div>
</div>
</div>

View File

@ -1,11 +1,12 @@
import type { DragEndEvent } from "@dnd-kit/core"
import { DndContext, PointerSensor, pointerWithin, useSensor, useSensors } from "@dnd-kit/core"
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import type { FeedViewType } from "@follow/constants"
import { DEV, IN_ELECTRON, PROD } from "@follow/shared/constants"
import { defaultUISettings } from "@follow/shared/settings/defaults"
import { preventDefault } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { Slot } from "@radix-ui/react-slot"
@ -21,7 +22,6 @@ import { setMainContainerElement, setRootContainerElement } from "~/atoms/dom"
import { getIsZenMode, getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import {
getTimelineColumnTempShow,
setTimelineColumnShow,
setTimelineColumnTempShow,
useTimelineColumnShow,
useTimelineColumnTempShow,
@ -33,7 +33,6 @@ import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
import { FloatingLayerScope } from "~/constants"
import { ROOT_CONTAINER_ID } from "~/constants/dom"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions"
import { useI18n } from "~/hooks/common"
import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
@ -66,8 +65,6 @@ export function MainDestopLayout() {
const containerRef = useRef<HTMLDivElement | null>(null)
useDailyTask()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@ -191,11 +188,11 @@ const FeedResponsiveResizerContainer = ({
}: {
containerRef: React.RefObject<HTMLDivElement | null>
} & PropsWithChildren) => {
const { isDragging, position, separatorProps, separatorCursor } = useResizable({
const { isDragging, position, separatorProps, separatorCursor, setPosition } = useResizable({
axis: "x",
min: 256,
max: 300,
initial: getUISettings().feedColWidth,
initial: React.useMemo(() => getUISettings().feedColWidth, []),
containerRef: containerRef as React.RefObject<HTMLElement>,
onResizeEnd({ position }) {
@ -241,11 +238,14 @@ const FeedResponsiveResizerContainer = ({
}
}, [feedColumnShow])
const activeScopes = useGlobalFocusableScope()
const when = useGlobalFocusableScopeSelector(
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-callback
React.useCallback((activeScope) => !activeScope.or(...FloatingLayerScope), []),
)
useCommandBinding({
commandId: COMMAND_ID.layout.toggleSubscriptionColumn,
when: !FloatingLayerScope.some((scope) => activeScopes.has(scope)),
when,
})
const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow)
@ -300,7 +300,8 @@ const FeedResponsiveResizerContainer = ({
cursor={separatorCursor}
{...separatorProps}
onDoubleClick={() => {
setTimelineColumnShow(false)
setUISetting("feedColWidth", defaultUISettings.feedColWidth)
setPosition(defaultUISettings.feedColWidth)
}}
tooltip={
!isDragging && (

View File

@ -3,14 +3,12 @@ import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js"
import { Outlet } from "react-router"
import { useLoginModalShow, useWhoami } from "~/atoms/user"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
import { UpdateNotice } from "~/modules/update-notice/UpdateNotice.mobile"
import { NewUserGuide } from "./index.shared"
export const MobileRootLayout = () => {
useDailyTask()
const isAuthFail = useLoginModalShow()
const user = useWhoami()
return (

View File

@ -6,17 +6,17 @@ const noop = () =>
})
export const LeftSidebarLayout = withResponsiveComponent<object>(
() =>
import("~/modules/app-layout/feed-column/desktop").then((m) => ({
import("~/modules/app-layout/subscription-column/desktop").then((m) => ({
default: m.MainDestopLayout,
})),
() =>
import("~/modules/app-layout/feed-column/index.mobile").then((m) => ({
import("~/modules/app-layout/subscription-column/index.mobile").then((m) => ({
default: m.MobileRootLayout,
})),
)
export const MobileFeedScreen = withResponsiveComponent<object>(noop, () =>
import("~/modules/app-layout/feed-column/mobile").then((m) => ({
import("~/modules/app-layout/subscription-column/mobile").then((m) => ({
default: m.FeedColumnMobile,
})),
)

View File

@ -1,5 +1,5 @@
import { getReadonlyRoute } from "@follow/components/atoms/route.js"
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { useGlobalFocusableHasScope } from "@follow/components/common/Focusable/hooks.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Routes } from "@follow/constants"
@ -71,9 +71,9 @@ function SubviewLayoutInner() {
navigate(-1)
}
}
const activeScope = useGlobalFocusableScope()
useHotkeys("Escape", backHandler, {
enabled: activeScope.has(HotkeyScope.SubLayer),
enabled: useGlobalFocusableHasScope(HotkeyScope.SubLayer),
})
return (
<div className="relative flex size-full">

View File

@ -1,5 +1,6 @@
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { views } from "@follow/constants"
import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn, isSafari } from "@follow/utils/utils"
import { useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
@ -27,14 +28,19 @@ export function CenterColumnDesktop() {
const { view } = useRouteParams()
const inWideMode = (view ? views[view]!.wideMode : false) || settingWideMode
const feedColumnWidth = useUISettingKey("feedColWidth")
const { position, separatorProps, isDragging, separatorCursor } = useResizable({
const startDragPosition = useRef(0)
const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({
axis: "x",
// FIXME: Less than this width causes grid images to overflow on safari
min: isSafari() ? 356 : 300,
max: Math.max((window.innerWidth - feedColumnWidth) / 2, 600),
initial: entryColWidth,
containerRef: containerRef as React.RefObject<HTMLElement>,
onResizeStart({ position }) {
startDragPosition.current = position
},
onResizeEnd({ position }) {
if (position === startDragPosition.current) return
setUISetting("entryColWidth", position)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
@ -55,7 +61,15 @@ export function CenterColumnDesktop() {
</AppLayoutGridContainerProvider>
</div>
{!inWideMode && (
<PanelSplitter {...separatorProps} cursor={separatorCursor} isDragging={isDragging} />
<PanelSplitter
{...separatorProps}
cursor={separatorCursor}
isDragging={isDragging}
onDoubleClick={() => {
setUISetting("entryColWidth", defaultUISettings.entryColWidth)
setPosition(defaultUISettings.entryColWidth)
}}
/>
)}
<Outlet />
</Focusable>

View File

@ -7,7 +7,7 @@ const noop = () =>
})
export const CenterColumnLayout = withResponsiveComponent<object>(
() =>
import("~/modules/app-layout/entry-column/desktop").then((m) => ({
import("~/modules/app-layout/timeline-column/desktop").then((m) => ({
default: m.CenterColumnDesktop,
})),
() =>
@ -17,7 +17,7 @@ export const CenterColumnLayout = withResponsiveComponent<object>(
)
export const MobileCenterColumnScreen = withResponsiveComponent<object>(noop, () =>
import("~/modules/app-layout/entry-column/mobile").then((m) => ({
import("~/modules/app-layout/timeline-column/mobile").then((m) => ({
default: m.EntryColumnMobile,
})),
)

View File

@ -8,7 +8,7 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { usePreventOverscrollBounce } from "~/hooks/common"
import { EntryColumn } from "~/modules/entry-column"
import { MobileFloatBar } from "../feed-column/float-bar.mobile"
import { MobileFloatBar } from "../subscription-column/float-bar.mobile"
export const EntryColumnMobile = () => {
const isStartupTimeline = useGeneralSettingKey("startupScreen") === "timeline"

View File

@ -4,7 +4,7 @@ import { preventDefault } from "@follow/utils/dom"
import { useWindowState } from "~/atoms/app"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "~/constants"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
export const Titlebar = () => {
const isMaximized = useWindowState() === WindowState.MAXIMIZED
@ -24,7 +24,7 @@ export const Titlebar = () => {
className="no-drag-region hover:bg-theme-item-active pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200"
type="button"
onClick={() => {
tipcClient?.windowAction({ action: "minimize" })
ipcServices?.app.windowAction({ action: "minimize" })
}}
>
<i className="i-mingcute-minimize-line" />
@ -34,7 +34,7 @@ export const Titlebar = () => {
type="button"
className="no-drag-region hover:bg-theme-item-active pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200"
onClick={async () => {
await tipcClient?.windowAction({ action: "maximum" })
await ipcServices?.app.windowAction({ action: "maximum" })
}}
>
{isMaximized ? (
@ -48,7 +48,7 @@ export const Titlebar = () => {
type="button"
className="no-drag-region pointer-events-auto flex h-full w-[50px] items-center justify-center duration-200 hover:bg-red-500 hover:!text-white"
onClick={() => {
tipcClient?.windowAction({ action: "close" })
ipcServices?.app.windowAction({ action: "close" })
}}
>
<i className="i-mingcute-close-line" />

View File

@ -10,9 +10,9 @@ import {
import { Input } from "@follow/components/ui/input/Input.js"
import type { LoginRuntime } from "@follow/shared/auth"
import { env } from "@follow/shared/env.desktop"
import HCaptcha from "@hcaptcha/react-hcaptcha"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRef } from "react"
import ReCAPTCHA from "react-google-recaptcha"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -38,19 +38,20 @@ export function LoginWithPassword({ runtime }: { runtime: LoginRuntime }) {
email: "",
password: "",
},
mode: "all",
})
const { present } = useModalStack()
const recaptchaRef = useRef<ReCAPTCHA>(null)
const captchaRef = useRef<HCaptcha>(null)
async function onSubmit(values: z.infer<typeof formSchema>) {
const token = await recaptchaRef.current?.executeAsync()
const response = await captchaRef.current?.execute({ async: true })
const res = await loginHandler("credential", runtime, {
email: values.email,
password: values.password,
headers: {
"x-token": `r2:${token}`,
"x-token": `hc:${response?.response}`,
},
})
if (res?.error) {
@ -123,7 +124,9 @@ export function LoginWithPassword({ runtime }: { runtime: LoginRuntime }) {
)}
/>
<div className="flex flex-col space-y-3">
<ReCAPTCHA ref={recaptchaRef} sitekey={env.VITE_RECAPTCHA_V2_SITE_KEY} size="invisible" />
{!import.meta.env.DEV && (
<HCaptcha sitekey={env.VITE_HCAPTCHA_SITE_KEY} ref={captchaRef} size="invisible" />
)}
<Button
type="submit"
isLoading={form.formState.isSubmitting}
@ -159,14 +162,13 @@ export function RegisterForm() {
password: "",
confirmPassword: "",
},
mode: "all",
})
const { isValid } = form.formState
const recaptchaRef = useRef<ReCAPTCHA>(null)
const captchaRef = useRef<HCaptcha>(null)
async function onSubmit(values: z.infer<typeof registerFormSchema>) {
const token = await recaptchaRef.current?.executeAsync()
const response = await captchaRef.current?.execute({ async: true })
return signUp.email({
email: values.email,
password: values.password,
@ -180,7 +182,7 @@ export function RegisterForm() {
toast.error(context.error.message)
},
headers: {
"x-token": `r2:${token}`,
"x-token": `hc:${response?.response}`,
},
},
})
@ -229,8 +231,10 @@ export function RegisterForm() {
</FormItem>
)}
/>
<ReCAPTCHA ref={recaptchaRef} sitekey={env.VITE_RECAPTCHA_V2_SITE_KEY} size="invisible" />
<Button disabled={!isValid} type="submit" buttonClassName="w-full" size="lg">
{!import.meta.env.DEV && (
<HCaptcha sitekey={env.VITE_HCAPTCHA_SITE_KEY} ref={captchaRef} size="invisible" />
)}
<Button type="submit" buttonClassName="w-full" size="lg">
{t("register.submit")}
</Button>
</form>

View File

@ -16,6 +16,7 @@ import { useAuthProviders } from "~/queries/users"
import { LoginWithPassword, RegisterForm } from "./Form"
import { LegalModalContent } from "./LegalModal"
import { TokenModalContent } from "./TokenModal"
interface LoginModalContentProps {
runtime: LoginRuntime
@ -49,6 +50,14 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
})
}
const handleOpenToken = () => {
present({
id: "token",
title: t("login.enter_token"),
content: () => <TokenModalContent />,
})
}
const Inner = (
<>
<div className="-mt-9 mb-4 flex items-center justify-center">
@ -108,12 +117,35 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<span>{t("login.continueWith", { provider: provider.name })}</span>
</MotionButtonBase>
))}
<div className="text-text-secondary -mb-1.5 mt-1 text-center text-xs leading-4">
<a onClick={() => handleOpenToken()} className="hover:underline">
{t("login.enter_token")}
</a>
</div>
<div className="text-text-secondary text-center text-xs leading-4">
<span>{t("login.agree_to")}</span>{" "}
<a onClick={() => handleOpenLegal("tos")} className="text-accent hover:underline">
{t("login.terms")}
</a>{" "}
&{" "}
<a onClick={() => handleOpenLegal("privacy")} className="text-accent hover:underline">
{t("login.privacy")}
</a>
</div>
</div>
)}
<Divider className="mb-5 mt-6" />
<Divider className="mb-5 mt-4" />
{isEmail ? (
<div className="pb-2 text-center font-medium" onClick={() => setIsEmail(false)}>
{t("login.back")}
<div className="flex items-center justify-center pb-2">
<MotionButtonBase
className="cursor-button hover:text-accent flex items-center gap-2 text-center font-medium duration-200"
onClick={() => setIsEmail(false)}
>
<i className="i-mgc-left-cute-fi" />
{t("login.back")}
</MotionButtonBase>
</div>
) : (
<div className="pb-2 text-center font-medium" onClick={() => setIsRegister(!isRegister)}>
@ -126,23 +158,6 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
/>
</div>
)}
<div className="text-text-secondary mt-3 text-center text-xs leading-5">
<span>{t("login.agree_to")}</span> <br />
<a
onClick={() => handleOpenLegal("tos")}
className="text-accent cursor-pointer hover:underline"
>
{t("login.terms")}
</a>{" "}
&{" "}
<a
onClick={() => handleOpenLegal("privacy")}
className="text-accent cursor-pointer hover:underline"
>
{t("login.privacy")}
</a>
</div>
</>
)
if (isMobile) {
@ -160,7 +175,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div
onClick={stopPropagation}
tabIndex={-1}
className="bg-background w-[25rem] rounded-xl border p-3 px-8 shadow-2xl shadow-stone-300 dark:border-neutral-700 dark:shadow-stone-800"
className="bg-background w-[26rem] rounded-xl border p-3 px-8 shadow-2xl shadow-stone-300 dark:border-neutral-700 dark:shadow-stone-800"
>
{Inner}
</div>

View File

@ -0,0 +1,80 @@
import { Button } from "@follow/components/ui/button/index.js"
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/index.js"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { oneTimeToken } from "~/lib/auth"
import { handleSessionChanges } from "~/queries/auth"
const formSchema = z.object({
token: z.string().min(1),
})
export const TokenModalContent = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
})
const { t } = useTranslation("common")
const [isLoading, setIsLoading] = useState(false)
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true)
const urlObj = new URL(values.token)
const token = urlObj.searchParams.get("token")
if (token) {
await oneTimeToken.apply({ token })
handleSessionChanges()
}
setIsLoading(false)
}
return (
<div className="size-full overflow-hidden">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-[512px] max-w-full overflow-hidden px-0.5"
>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem className="flex flex-col items-center gap-2 md:block">
<FormControl>
<Input
className="mt-1 dark:text-zinc-200"
placeholder="folo://auth?token=xxx"
{...field}
/>
</FormControl>
<div className="h-6">
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="center relative flex">
<Button
variant="primary"
type="submit"
disabled={!form.formState.isValid}
isLoading={isLoading}
>
{t("words.submit")}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@ -1,3 +1,4 @@
import { getMousePosition } from "@follow/components/hooks/useMouse.js"
import { FeedViewType, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn, resolveUrlWithBase } from "@follow/utils/utils"
@ -8,6 +9,7 @@ import { toast } from "sonner"
import { toggleShowAISummaryOnce } from "~/atoms/ai-summary"
import { toggleShowAITranslationOnce } from "~/atoms/ai-translation"
import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player"
import { showPopover } from "~/atoms/popover"
import { useIsInMASReview } from "~/atoms/server-configs"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import {
@ -16,16 +18,17 @@ import {
useSourceContentModal,
} from "~/atoms/source-content"
import { useUserRole } from "~/atoms/user"
import { SharePanel } from "~/components/common/SharePanel"
import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
import { parseHtml } from "~/lib/parse-html"
import { useActivationModal } from "~/modules/activation"
import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll"
import { useGalleryModal } from "~/modules/entry-content/hooks"
import { useTipModal } from "~/modules/wallet/hooks"
import { entryActions, useEntryStore } from "~/store/entry"
import { entryActions, getEntry, useEntryStore } from "~/store/entry"
import { useRegisterFollowCommand } from "../hooks/use-register-command"
import type { Command, CommandCategory } from "../types"
@ -283,19 +286,15 @@ export const useRegisterEntryCommands = () => {
toast.error("Failed to share: url is not available", { duration: 3000 })
return
}
if (!entry.entries.url) return
if (IN_ELECTRON) {
return tipcClient?.showShareMenu(entry.entries.url)
} else {
const { title, description } = entry.entries
navigator.share({
title: title || undefined,
text: description || undefined,
url: entry.entries.url,
})
}
return
const xy = getMousePosition()
showPopover(
{
x: xy.x,
y: xy.y + 20,
},
<SharePanel entryId={entry.entries.id} />,
)
},
},
{
@ -354,11 +353,15 @@ export const useRegisterEntryCommands = () => {
label: t("entry_content.header.play_tts"),
category,
icon: <i className="i-mgc-voice-cute-re" />,
run: async ({ entryId, entryContent }) => {
run: async ({ entryId }) => {
if (getAudioPlayerAtomValue().entryId === entryId) {
AudioPlayer.togglePlayAndPause()
} else {
const filePath = await tipcClient?.tts({
const entryContent = getEntry(entryId)?.entries.content
if (!entryContent) {
return
}
const filePath = await ipcServices?.reader.tts({
id: entryId,
text: parseHtml(entryContent).toText(),
voice,
@ -509,7 +512,7 @@ export type ImageGalleryCommand = Command<{
export type TTSCommand = Command<{
id: typeof COMMAND_ID.entry.tts
fn: (data: { entryId: string; entryContent: string }) => void
fn: (data: { entryId: string }) => void
}>
export type ReadabilityCommand = Command<{

View File

@ -1,7 +1,7 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import { useShortcutsModal } from "~/modules/modal/shortcuts"
import { useShortcutsModal } from "~/modules/modal/hooks/useShortcutsModal"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command, CommandCategory } from "../types"

View File

@ -20,7 +20,7 @@ import { getReadabilityContent, getReadabilityStatus, ReadabilityStatus } from "
import { getActionLanguage } from "~/atoms/settings/general"
import { getIntegrationSettings, useIntegrationSettingKey } from "~/atoms/settings/integration"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { tipcClient } from "~/lib/client"
import { ipcServices } from "~/lib/client"
import { parseHtml } from "~/lib/parse-html"
import { queryClient } from "~/lib/query-client"
import { Queries } from "~/queries"
@ -90,7 +90,7 @@ const useRegisterEagleCommands = () => {
})
return
}
const response = await tipcClient?.saveToEagle({
const response = await ipcServices?.integration.saveToEagle({
url: entry.entries.url,
mediaUrls: entry.entries.media.map((m) => m.url),
})
@ -283,7 +283,7 @@ const useRegisterObsidianCommands = () => {
publishedAt: string
vaultPath: string
}) => {
return await tipcClient?.saveToObsidian(data)
return await ipcServices?.integration.saveToObsidian(data)
},
onSuccess: (data) => {
if (data?.success) {

View File

@ -14,7 +14,7 @@ export const defaultCommandShortcuts = {
// Layout commands
[COMMAND_ID.layout.toggleSubscriptionColumn]: transformShortcut("$mod+B"),
[COMMAND_ID.layout.toggleWideMode]: transformShortcut("$mod+["),
[COMMAND_ID.layout.toggleZenMode]: transformShortcut("$mod+Shift+Z"),
[COMMAND_ID.layout.toggleZenMode]: transformShortcut("Shift+$mod+Z"),
// Subscription commands
[COMMAND_ID.subscription.markAllAsRead]: transformShortcut("Shift+$mod+A"),

View File

@ -1,10 +1,11 @@
import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { Button } from "@follow/components/ui/button/index.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import { cn, sortShortcutKeys } from "@follow/utils/utils"
import type { FC, RefObject, SVGProps } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useOnClickOutside } from "usehooks-ts"
@ -47,10 +48,31 @@ export const ShortcutsGuideline = () => {
export const ShortcutSetting = () => {
const { t } = useTranslation("shortcuts")
const commandShortcuts = useCommandShortcutItems()
const currentShortcuts = useCommandShortcuts()
const setCustomCommandShortcut = useSetCustomCommandShortcut()
// Check if any shortcuts have been customized
const hasCustomizedShortcuts = useMemo(() => {
return Object.entries(currentShortcuts).some(([commandId, shortcut]) => {
return (
allowCustomizeCommands.has(commandId as AllowCustomizeCommandId) &&
shortcut !== defaultCommandShortcuts[commandId as keyof typeof defaultCommandShortcuts]
)
})
}, [currentShortcuts])
const resetDefaults = () => {
Object.entries(defaultCommandShortcuts).forEach(([commandId, shortcut]) => {
if (allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)) {
setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)
}
})
}
return (
<div>
<p className="mb-6 mt-4 space-y-2 text-sm">{t("settings.shortcuts.description")}</p>
{Object.entries(commandShortcuts).map(([type, commands]) => (
<section key={type} className="mb-8">
<div className="text-text border-border mb-4 border-b pb-2 text-base font-medium">
@ -63,11 +85,20 @@ export const ShortcutSetting = () => {
</div>
</section>
))}
<div className="mb-4 flex min-h-6 items-center justify-end">
{hasCustomizedShortcuts && (
<Button variant={"outline"} onClick={resetDefaults}>
Reset Defaults
</Button>
)}
</div>
</div>
)
}
const EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
const { t } = useTranslation("shortcuts")
const command = useCommand(commandId)
const commandShortcuts = useCommandShortcuts()
const [isEditing, setIsEditing] = useState(false)
@ -91,15 +122,12 @@ const EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowComm
{isUserCustomize && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="bg-accent/10 text-accent hover:bg-accent/20 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-all duration-200"
title="User customized shortcut"
>
<div className="bg-accent/10 text-accent hover:bg-accent/20 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-all duration-200">
<div className="bg-accent mr-1 size-2 rounded-full" />
Custom
{t("settings.shortcuts.custom")}
</div>
</TooltipTrigger>
<TooltipContent>This shortcut is customized by you</TooltipContent>
<TooltipContent>{t("settings.shortcuts.custom_content")}</TooltipContent>
</Tooltip>
)}
</div>
@ -143,6 +171,7 @@ const ShortcutInputWrapper = memo(
onEditingChange,
onShortcutChange,
}: ShortcutInputWrapperProps) => {
const { t } = useTranslation("shortcuts")
const conflictResult = useIsShortcutConflict(shortcut, commandId as AllowCustomizeCommandId)
const hasConflict = allowCustomize && conflictResult.hasConflict
@ -180,7 +209,7 @@ const ShortcutInputWrapper = memo(
type="button"
data-customized={isUserCustomize}
className={cn(
"flex h-full cursor-text justify-end rounded-md border px-1 duration-200",
"relative flex h-full cursor-text justify-end rounded-md border px-1 duration-200",
allowCustomize && "hover:!border-border hover:!bg-material-medium",
getBorderColor(),
getBackgroundColor(),
@ -210,11 +239,13 @@ const ShortcutInputWrapper = memo(
</TooltipTrigger>
{hasConflict && (
<RootPortal>
<TooltipContent className="max-w-xs">
<TooltipContent className="max-w-xs p-2">
<div className="space-y-1">
<div className="font-medium text-red-400">Shortcut Conflict</div>
<div className="text-xs">
This shortcut conflicts with command:
<div className="font-medium text-red-400">{t("settings.shortcuts.conflict")}</div>
<div className="leading-6">
<span className="text-text-secondary text-xs">
{t("settings.shortcuts.conflict_command")}
</span>
<p className="text-sm font-medium">{conflictCommand?.label.title}</p>
</div>
</div>
@ -230,6 +261,7 @@ const KeyRecorder: FC<{
onChange: (keys: string[] | null) => void
onBlur: () => void
}> = ({ onChange, onBlur }) => {
const { t } = useTranslation("shortcuts")
const { currentKeys } = useShortcutRecorder()
const setGlobalScope = useReplaceGlobalFocusableScope()
@ -251,7 +283,7 @@ const KeyRecorder: FC<{
})
return (
<div
className="text-text-secondary flex h-full items-center justify-center px-1 text-xs"
className="text-text-secondary relative flex h-full items-center justify-center px-1 text-xs"
tabIndex={-1}
role="textbox"
ref={ref}
@ -263,13 +295,13 @@ const KeyRecorder: FC<{
</KbdCombined>
</div>
) : (
<span className="text-text-secondary pr-4">Press keys to record</span>
<span className="text-text-secondary pr-4">{t("settings.shortcuts.press_to_record")}</span>
)}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
type="button"
className="hover:text-text absolute inset-y-0 right-0 z-[1] flex items-center justify-center px-1"
className="hover:text-text absolute inset-y-0 -right-1 z-[1] flex items-center justify-center px-1"
onClick={(e) => {
e.stopPropagation()
if (currentKeys.length === 0) {
@ -280,13 +312,15 @@ const KeyRecorder: FC<{
}}
>
{currentKeys.length > 0 ? (
<i className="i-mingcute-close-circle-fill size-4" />
) : (
<FamiconsArrowUndoCircle className="size-4" />
) : (
<i className="i-mingcute-close-circle-fill size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{currentKeys.length > 0 ? "Undo" : "Reset"}</TooltipContent>
<TooltipContent>
{currentKeys.length > 0 ? t("settings.shortcuts.undo") : t("settings.shortcuts.reset")}
</TooltipContent>
</Tooltip>
</div>
)
@ -338,27 +372,6 @@ const MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))
const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/
function getKeySortValue(key: string): number {
if (key === MODIFIER_KEYS_MAP.Meta) return 0
if (key === MODIFIER_KEYS_MAP.Control) return 1
if (key === MODIFIER_KEYS_MAP.Alt) return 2
if (key === MODIFIER_KEYS_MAP.Shift) return 3
if (F_KEY_REGEX.test(key)) return 4
return 5
}
function sortShortcutKeys(keys: string[]): string[] {
return [...keys].sort((a, b) => {
const sortValueA = getKeySortValue(a)
const sortValueB = getKeySortValue(b)
if (sortValueA !== sortValueB) {
return sortValueA - sortValueB
}
return a.localeCompare(b)
})
}
const useShortcutRecorder = () => {
const [currentKeys, setCurrentKeys] = useState<string[]>([])
@ -380,11 +393,10 @@ const useShortcutRecorder = () => {
const pressedKeysSet = new Set<string>()
// 添加修饰键
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)
if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)
if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
// If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.
// If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"),

View File

@ -2,9 +2,9 @@ import { Button } from "@follow/components/ui/button/index.js"
import { Card, CardContent, CardHeader } from "@follow/components/ui/card/index.jsx"
import { RelativeTime } from "@follow/components/ui/datetime/index.js"
import { getBackgroundGradient } from "@follow/utils/color"
import { cn } from "@follow/utils/utils"
import { cn, formatNumber } from "@follow/utils/utils"
import type { FC } from "react"
import { memo, useMemo } from "react"
import { memo } from "react"
import { useTranslation } from "react-i18next"
import { useLocation } from "react-router"
@ -95,8 +95,7 @@ interface DiscoverFeedCardProps {
export const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(
({ item, onSuccess, className }) => {
const numberFormatter = useMemo(() => new Intl.NumberFormat("en-US", {}), [])
const { t } = useTranslation()
const { t } = useTranslation("common")
const isSubscribed = useIsSubscribed(item.feed?.id || item.list?.id || "")
@ -140,7 +139,7 @@ export const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(
<i className="i-mgc-user-3-cute-re" />
<span>
{numberFormatter.format(item.analytics.subscriptionCount)}{" "}
{formatNumber(item.analytics.subscriptionCount)}{" "}
{t("feed.follower", { count: item.analytics.subscriptionCount })}
</span>
</div>
@ -223,6 +222,7 @@ const FeedCardMediaThumbnail: FC<{
type={entry.media[0].type}
previewImageUrl={entry.media[0].preview_image_url}
className="size-full object-cover"
mediaContainerClassName="size-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/5 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>

View File

@ -100,23 +100,7 @@ export const DiscoverFeedForm = ({
rootClassName?: string
}) => {
const { t } = useTranslation()
const keys = useMemo(
() =>
parseRegexpPathParams(route.path, {
excludeNames: [
"routeParams",
"functionalFlag",
"fulltext",
"disableEmbed",
"date",
"language",
"lang",
"sort",
],
forceExcludeNames: routeParams ? ["routeParams"] : [],
}),
[route.path, routeParams],
)
const keys = useMemo(() => parseRegexpPathParams(route.path), [route.path])
const formPlaceholder = useMemo<Record<string, string>>(() => {
if (!route.example) return {}

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