release(desktop): Release v0.5.0
release(desktop): Release v0.5.0
This commit is contained in:
commit
3149bda1c1
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}}'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { tipc } from "@egoist/tipc/main"
|
||||
|
||||
export const t = tipc.create()
|
||||
|
||||
export type T = typeof t
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}),
|
||||
}
|
||||
|
|
@ -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)
|
||||
}),
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
|
@ -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
|
||||
}),
|
||||
}
|
||||
|
|
@ -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")
|
||||
}),
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"noUnusedParameters": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@pkg": ["../../package.json"],
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -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,>({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const auth = new Auth({
|
|||
webURL: env.VITE_WEB_URL,
|
||||
})
|
||||
|
||||
export const { authClient } = auth
|
||||
|
||||
// @keep-sorted
|
||||
export const {
|
||||
changeEmail,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 && (
|
||||
|
|
@ -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 (
|
||||
|
|
@ -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,
|
||||
})),
|
||||
)
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
})),
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue