release(mobile): Release v0.1.6
release(mobile): Release v0.1.6
This commit is contained in:
commit
40d47faaec
|
|
@ -43,6 +43,11 @@ updates:
|
|||
versions: [">=35.0.0"]
|
||||
- dependency-name: react-native-sheet-transitions
|
||||
versions: [">0.1.2"]
|
||||
|
||||
# filter not work
|
||||
- dependency-name: unplugin-ast
|
||||
versions: ["0.14.5"]
|
||||
|
||||
open-pull-requests-limit: 100
|
||||
groups:
|
||||
minor-and-patch:
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@ name: Build Android
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "pnpm-lock.yaml"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "pnpm-lock.yaml"
|
||||
|
|
@ -22,7 +18,7 @@ on:
|
|||
description: "Build profile"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
|
@ -81,3 +77,8 @@ jobs:
|
|||
name: aab-android
|
||||
path: ${{ github.workspace }}/build.aab
|
||||
retention-days: 90
|
||||
|
||||
- name: Submit to Google Play
|
||||
if: github.event.inputs.profile == 'production'
|
||||
working-directory: apps/mobile
|
||||
run: eas submit --platform android --path ${{ github.workspace }}/build.aab --non-interactive
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths:
|
||||
- "apps/desktop/**"
|
||||
- "pnpm-lock.yaml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_version:
|
||||
|
|
@ -124,20 +125,17 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
- name: Update main hash
|
||||
run: |
|
||||
cd apps/desktop
|
||||
pnpm update:main-hash
|
||||
working-directory: apps/desktop
|
||||
run: pnpm update:main-hash
|
||||
- name: Build - Vite
|
||||
run: |
|
||||
cd apps/desktop
|
||||
pnpm build:electron-vite ${{ env.PROD == 'false' && '--mode staging' || '' }}
|
||||
working-directory: apps/desktop
|
||||
run: pnpm build:electron-vite ${{ env.PROD == 'false' && '--mode staging' || '' }}
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
- name: Build - Windows and Linux
|
||||
if: runner.os != 'macOS'
|
||||
run: |
|
||||
cd apps/desktop
|
||||
pnpm build:electron-forge ${{ env.PROD == 'false' && '--mode=staging' || '' }}
|
||||
working-directory: apps/desktop
|
||||
run: pnpm build:electron-forge ${{ env.PROD == 'false' && '--mode=staging' || '' }}
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
|
|
@ -166,15 +164,13 @@ jobs:
|
|||
OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY_MAS }}
|
||||
OSX_SIGN_PROVISIONING_PROFILE_PATH: ${{ runner.temp }}/build_pp.provisionprofile
|
||||
BUILD_VERSION: ${{ github.event.inputs.build_version }}
|
||||
run: |
|
||||
cd apps/desktop
|
||||
pnpm build:electron-forge:mas
|
||||
working-directory: apps/desktop
|
||||
run: pnpm build:electron-forge:mas
|
||||
|
||||
- name: Build - Renderer
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
cd apps/desktop
|
||||
pnpm build:render
|
||||
working-directory: apps/desktop
|
||||
run: pnpm build:render
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,8 @@ name: Build iOS for development
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "apps/mobile/web-app/**"
|
||||
- "apps/mobile/native/**"
|
||||
- "apps/mobile/package.json"
|
||||
- "apps/mobile/app.config.ts"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "apps/mobile/web-app/**"
|
||||
- "apps/mobile/native/**"
|
||||
|
|
@ -69,9 +63,8 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: 🔨 Build iOS IPA
|
||||
run: |
|
||||
cd apps/mobile
|
||||
eas build --platform ios --profile development --non-interactive --local --output=./build.ipa
|
||||
working-directory: apps/mobile
|
||||
run: eas build --platform ios --profile development --non-interactive --local --output=./build.ipa
|
||||
env:
|
||||
CI: true
|
||||
|
||||
|
|
@ -116,9 +109,8 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: 🔨 Build iOS IPA
|
||||
run: |
|
||||
cd apps/mobile
|
||||
eas build --platform ios --profile ios-simulator --non-interactive --local --output=./build-simulator.ipa
|
||||
working-directory: apps/mobile
|
||||
run: eas build --platform ios --profile ios-simulator --non-interactive --local --output=./build-simulator.ipa
|
||||
env:
|
||||
CI: true
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@ name: Build iOS
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "pnpm-lock.yaml"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "pnpm-lock.yaml"
|
||||
|
|
@ -22,7 +18,7 @@ on:
|
|||
description: "Build profile"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
|
@ -74,9 +70,8 @@ jobs:
|
|||
run: pnpm install
|
||||
|
||||
- name: 🔨 Build iOS IPA
|
||||
run: |
|
||||
cd apps/mobile
|
||||
eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa
|
||||
working-directory: apps/mobile
|
||||
run: eas build --platform ios --profile ${{ github.event.inputs.profile || 'preview' }} --non-interactive --local --output=./build.ipa
|
||||
env:
|
||||
CI: true
|
||||
|
||||
|
|
@ -95,6 +90,5 @@ jobs:
|
|||
|
||||
- name: Submit to App Store
|
||||
if: github.event.inputs.profile == 'production'
|
||||
run: |
|
||||
cd apps/mobile
|
||||
eas submit --platform ios --path build.ipa --non-interactive
|
||||
working-directory: apps/mobile
|
||||
run: eas submit --platform ios --path build.ipa --non-interactive
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
name: Branch Synchronization
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
sync-branches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --global user.name 'GitHub Actions'
|
||||
git config --global user.email 'actions@github.com'
|
||||
|
||||
- name: Prepare sync branch
|
||||
id: branch
|
||||
run: |
|
||||
echo "SYNC_BRANCH_MAIN_DEV=sync/main-to-dev-$(date +'%Y%m%d')" >> $GITHUB_ENV
|
||||
|
||||
- name: Sync main to dev
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Sync main to dev
|
||||
git checkout main
|
||||
SYNC_BRANCH_DEV=${{ env.SYNC_BRANCH_MAIN_DEV }}
|
||||
git checkout -B $SYNC_BRANCH_DEV
|
||||
DIFF=$(git diff origin/dev...)
|
||||
if [ -z "$DIFF" ]; then
|
||||
echo "No changes to sync"
|
||||
exit 0
|
||||
fi
|
||||
git push origin $SYNC_BRANCH_DEV -f
|
||||
gh pr create --base dev --head $SYNC_BRANCH_DEV --title "Sync main branch to dev branch" --body "Automatic sync" || exit 0
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -45,5 +45,5 @@ jobs:
|
|||
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.yml/dispatches \
|
||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-desktop.yml/dispatches \
|
||||
-d '{"ref":"main", "inputs": {"tag_version": "true"}}'
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ You can also install using the following methods maintained by our community:
|
|||
|
||||
- If you are using Arch Linux, you can install package [follow-appimage](https://aur.archlinux.org/packages/follow-appimage) that maintained by [timochan](https://github.com/ttimochan).
|
||||
- If you are using Nix, you can install package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that maintained by [iosmanthus](https://github.com/iosmanthus).
|
||||
- If you are using macOS with [Homebrew](https://brew.sh), you can install cask [follow](https://formulae.brew.sh/cask/follow) (also [@alpha](https://formulae.brew.sh/cask/follow@alpha) and [@nightly](https://formulae.brew.sh/cask/follow@nightly)) that maintained by [realSunyz](https://github.com/realSunyz).
|
||||
- If you are using macOS with [Homebrew](https://brew.sh), you can install cask [folo](https://formulae.brew.sh/cask/folo) that maintained by [realSunyz](https://github.com/realSunyz).
|
||||
- If you are using Windows with [Scoop](https://scoop.sh), you can install manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that maintained by [cscnk52](https://github.com/cscnk52).
|
||||
|
||||
| [](https://discord.gg/followapp) | Join our Discord server to connect with developers, request features, and receive support. |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/* eslint-disable no-template-curly-in-string */
|
||||
import { defineConfig } from "nbump"
|
||||
|
||||
export default defineConfig({
|
||||
leading: [
|
||||
"git pull --rebase",
|
||||
"tsx scripts/apply-changelog.ts ${NEW_VERSION}",
|
||||
"git add changelog",
|
||||
"tsx plugins/vite/generate-main-hash.ts",
|
||||
"pnpm eslint --fix package.json",
|
||||
"pnpm prettier --ignore-unknown --write package.json",
|
||||
"git add package.json",
|
||||
],
|
||||
trailing: ["git checkout -b release/desktop/${NEW_VERSION}"],
|
||||
finally: [
|
||||
"git push origin release/desktop/${NEW_VERSION}",
|
||||
"gh pr create --title 'release(desktop): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head release/desktop/${NEW_VERSION}",
|
||||
],
|
||||
push: false,
|
||||
commitMessage: "release(desktop): release v${NEW_VERSION}",
|
||||
tagPrefix: "desktop@",
|
||||
changelog: false,
|
||||
allowedBranches: ["dev"],
|
||||
})
|
||||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## Shiny new things
|
||||
|
||||
- Server side readability with AI summary and translation support (#3498)
|
||||
|
||||
## Improvements
|
||||
|
||||
- Save AI summary as description option for Cubox integration (#3478)
|
||||
- Delete your own RSSHub instance support (#2883)
|
||||
|
||||
## No longer broken
|
||||
|
||||
- Fixed TTS failed to play (#3522)
|
||||
- Fixed corner player can not navigate to correct entry (#1401)
|
||||
- Fixed unable to save to Readwise (#3497)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")
|
|||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
name: isStaging ? "Folo Staging" : "Folo",
|
||||
appCategoryType: "public.app-category.news",
|
||||
buildVersion: process.env.BUILD_VERSION || undefined,
|
||||
appBundleId: "is.follow",
|
||||
|
|
@ -196,7 +197,7 @@ const config: ForgeConfig = {
|
|||
}),
|
||||
new MakerPKG(
|
||||
{
|
||||
name: "Folo",
|
||||
name: "Folo Staging",
|
||||
keychain: process.env.KEYCHAIN_PATH,
|
||||
},
|
||||
["mas"],
|
||||
|
|
|
|||
|
|
@ -33,11 +33,12 @@
|
|||
"update:main-hash": "tsx plugins/vite/generate-main-hash.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-es": "2.0.0",
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-google-recaptcha-v3": "1.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "0.10.0",
|
||||
"@clack/prompts": "0.10.1",
|
||||
"@electron-forge/cli": "7.8.0",
|
||||
"@electron-forge/maker-appx": "7.8.0",
|
||||
"@electron-forge/maker-dmg": "7.8.0",
|
||||
|
|
@ -58,50 +59,30 @@
|
|||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/react-google-recaptcha": "2.1.9",
|
||||
"@vitejs/plugin-legacy": "6.0.2",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@vitejs/plugin-legacy": "6.1.0",
|
||||
"@vitejs/plugin-react": "4.4.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"bufferutil": "4.0.9",
|
||||
"cssnano": "7.0.6",
|
||||
"dotenv": "16.4.7",
|
||||
"drizzle-orm": "0.41.0",
|
||||
"electron": "34.5.0",
|
||||
"dotenv": "16.5.0",
|
||||
"drizzle-orm": "0.42.0",
|
||||
"electron": "35.1.5",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-packager-languages": "0.6.0",
|
||||
"electron-vite": "3.1.0",
|
||||
"fake-indexeddb": "6.0.0",
|
||||
"happy-dom": "17.4.4",
|
||||
"hono": "4.7.5",
|
||||
"hono": "4.7.6",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"nbump": "2.0.7",
|
||||
"nbump": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tar": "7.4.3",
|
||||
"tsup": "8.4.0",
|
||||
"unplugin-ast": "0.14.4",
|
||||
"unplugin-ast": "0.14.6",
|
||||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"productName": "Folo",
|
||||
"mainHash": "274b8ef4d0671d642c1350e1e4463fce6d677f81f77f59a666d6aa83f5866a22",
|
||||
"bump": {
|
||||
"before": [
|
||||
"git pull --rebase",
|
||||
"tsx scripts/apply-changelog.ts ${NEW_VERSION}",
|
||||
"git add changelog",
|
||||
"tsx plugins/vite/generate-main-hash.ts",
|
||||
"pnpm eslint --fix package.json",
|
||||
"pnpm prettier --ignore-unknown --write package.json",
|
||||
"git add package.json"
|
||||
],
|
||||
"after": [
|
||||
"gh pr create --title 'release(desktop): Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head dev"
|
||||
],
|
||||
"commit_message": "release(desktop): release v${NEW_VERSION}",
|
||||
"tag": false,
|
||||
"changelog": false,
|
||||
"allowed_branches": [
|
||||
"dev"
|
||||
]
|
||||
}
|
||||
"mainHash": "274b8ef4d0671d642c1350e1e4463fce6d677f81f77f59a666d6aa83f5866a22"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"@follow/shared": "workspace:*",
|
||||
"@follow/utils": "workspace:*",
|
||||
"@openpanel/web": "1.0.1",
|
||||
"@sentry/electron": "6.4.0",
|
||||
"@sentry/electron": "6.5.0",
|
||||
"builder-util-runtime": "9.3.1",
|
||||
"cookie-es": "2.0.0",
|
||||
"electron-context-menu": "4.0.5",
|
||||
|
|
@ -37,10 +37,10 @@
|
|||
"electron-squirrel-startup": "1.0.1",
|
||||
"electron-store": "10.0.1",
|
||||
"electron-updater": "6.6.2",
|
||||
"es-toolkit": "1.34.1",
|
||||
"es-toolkit": "1.35.0",
|
||||
"fast-folder-size": "2.4.0",
|
||||
"font-list": "1.5.1",
|
||||
"i18next": "24.2.3",
|
||||
"i18next": "25.0.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"linkedom": "0.18.9",
|
||||
"lowdb": "7.0.1",
|
||||
|
|
@ -55,9 +55,9 @@
|
|||
"@follow/models": "workspace:*",
|
||||
"@follow/types": "workspace:*",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "22.14.0",
|
||||
"electron": "34.5.0",
|
||||
"@types/node": "22.14.1",
|
||||
"electron": "35.1.5",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"hono": "4.7.5"
|
||||
"hono": "4.7.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ export const GITHUB_REPO = process.env.GITHUB_REPO || "follow"
|
|||
// https://github.com/electron/electron/issues/25081
|
||||
export const START_IN_TRAY_ARGS = "--start-in-tray"
|
||||
|
||||
export const BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN = "__Secure-better-auth.session_token"
|
||||
export const BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN = "better-auth.session_token"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import "./side-effects"
|
||||
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { APP_PROTOCOL, DEV } from "@follow/shared/constants"
|
||||
|
|
@ -15,7 +17,7 @@ import { updateProxy } from "./lib/proxy"
|
|||
import { handleUrlRouting } from "./lib/router"
|
||||
import { store } from "./lib/store"
|
||||
import { registerAppTray } from "./lib/tray"
|
||||
import { setBetterAuthSessionCookie, updateNotificationsToken } from "./lib/user"
|
||||
import { updateNotificationsToken } from "./lib/user"
|
||||
import { logger } from "./logger"
|
||||
import { registerUpdater } from "./updater"
|
||||
import { cleanupOldRender } from "./updater/hot-updater"
|
||||
|
|
@ -39,6 +41,9 @@ const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [
|
|||
env.VITE_OPENPANEL_API_URL || "",
|
||||
IMAGE_PROXY_URL,
|
||||
env.VITE_API_URL,
|
||||
// Fix unexpected CORS error when modify the origin header to request domain in the preflight request
|
||||
// Learn more https://github.com/RSSNext/Folo/issues/3312
|
||||
"https://readwise.io",
|
||||
])
|
||||
|
||||
function bootstrap() {
|
||||
|
|
@ -109,67 +114,10 @@ function bootstrap() {
|
|||
|
||||
mainWindow = createMainWindow()
|
||||
|
||||
// restore cookies
|
||||
const cookies = store.get("cookies")
|
||||
if (cookies) {
|
||||
Promise.all(
|
||||
cookies.map((cookie) => {
|
||||
const setCookieDetails: Electron.CookiesSetDetails = {
|
||||
url: apiURL,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
expirationDate: cookie.expirationDate,
|
||||
sameSite: cookie.sameSite as "unspecified" | "no_restriction" | "lax" | "strict",
|
||||
}
|
||||
|
||||
return mainWindow.webContents.session.cookies.set(setCookieDetails)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
updateProxy()
|
||||
registerUpdater()
|
||||
registerAppTray()
|
||||
|
||||
// handle session cookie when sign in with email in electron
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
{
|
||||
urls: [
|
||||
`${apiURL}/better-auth/sign-in/email`,
|
||||
`${apiURL}/better-auth/sign-in/email?*`,
|
||||
`${apiURL}/better-auth/two-factor/verify-totp`,
|
||||
`${apiURL}/better-auth/two-factor/verify-totp?*`,
|
||||
],
|
||||
},
|
||||
(detail, callback) => {
|
||||
const { responseHeaders } = detail
|
||||
if (responseHeaders?.["set-cookie"]) {
|
||||
const cookies = responseHeaders["set-cookie"] as string[]
|
||||
cookies.forEach((cookie) => {
|
||||
const cookieObj = parse(cookie, { decode: (value) => value })
|
||||
Object.keys(cookieObj).forEach((name) => {
|
||||
const value = cookieObj[name]
|
||||
mainWindow.webContents.session.cookies.set({
|
||||
url: apiURL,
|
||||
name,
|
||||
value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
domain: new URL(apiURL).hostname,
|
||||
sameSite: "no_restriction",
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
callback({ cancel: false, responseHeaders })
|
||||
},
|
||||
)
|
||||
|
||||
app.on("open-url", (_, url) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
|
|
@ -215,9 +163,6 @@ function bootstrap() {
|
|||
})
|
||||
await session.defaultSession.cookies.flushStore()
|
||||
|
||||
const cookies = await session.defaultSession.cookies.get({})
|
||||
store.set("cookies", cookies)
|
||||
|
||||
await cleanupOldRender()
|
||||
})
|
||||
|
||||
|
|
@ -227,29 +172,36 @@ function bootstrap() {
|
|||
const urlObj = new URL(url)
|
||||
|
||||
if (urlObj.hostname === "auth" || urlObj.pathname === "//auth") {
|
||||
const ck = urlObj.searchParams.get("ck")
|
||||
const userId = urlObj.searchParams.get("userId")
|
||||
const token = urlObj.searchParams.get("token")
|
||||
|
||||
if (ck && apiURL) {
|
||||
setBetterAuthSessionCookie(ck)
|
||||
const cookie = parse(atob(ck), { decode: (value) => value })
|
||||
Object.keys(cookie).forEach((name) => {
|
||||
const value = cookie[name]
|
||||
mainWindow.webContents.session.cookies.set({
|
||||
url: apiURL,
|
||||
name,
|
||||
value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
domain: new URL(apiURL).hostname,
|
||||
sameSite: "no_restriction",
|
||||
if (token) {
|
||||
await callWindowExpose(mainWindow).applyOneTimeToken(token)
|
||||
} else {
|
||||
// compatible with old version of ssr, should be removed in 0.4.4
|
||||
const ck = urlObj.searchParams.get("ck")
|
||||
const userId = urlObj.searchParams.get("userId")
|
||||
|
||||
if (ck && apiURL) {
|
||||
const cookie = parse(atob(ck), { decode: (value) => value })
|
||||
Object.keys(cookie).forEach(async (name) => {
|
||||
const value = cookie[name]
|
||||
await mainWindow.webContents.session.cookies.set({
|
||||
url: apiURL,
|
||||
name,
|
||||
value,
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
domain: new URL(apiURL).hostname,
|
||||
sameSite: "no_restriction",
|
||||
expirationDate: new Date().setDate(new Date().getDate() + 30),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
userId && (await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId))
|
||||
mainWindow.reload()
|
||||
userId && (await callWindowExpose(mainWindow).clearIfLoginOtherAccount(userId))
|
||||
mainWindow.reload()
|
||||
|
||||
updateNotificationsToken()
|
||||
updateNotificationsToken()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handleUrlRouting(url)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ if (process.argv.length === 3 && process.argv[2]!.startsWith("follow-dev:")) {
|
|||
* Mandatory and fast initializers for the app
|
||||
*/
|
||||
export function initializeAppStage0() {
|
||||
if (DEV) app.setPath("appData", path.join(app.getPath("appData"), "Follow (dev)"))
|
||||
initializeSentry()
|
||||
}
|
||||
export const initializeAppStage1 = () => {
|
||||
|
|
@ -154,8 +153,6 @@ const registerPushNotifications = async () => {
|
|||
const credentials = store.get(credentialsKey)
|
||||
const persistentIds = store.get(persistentIdsKey)
|
||||
|
||||
updateNotificationsToken()
|
||||
|
||||
const instance = new PushReceiver({
|
||||
debug: true,
|
||||
firebase: JSON.parse(env.VITE_FIREBASE_CONFIG),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import PKG from "@pkg"
|
|||
import { hc } from "hono/client"
|
||||
import { ofetch } from "ofetch"
|
||||
|
||||
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
|
||||
import { getMainWindow } from "~/window"
|
||||
|
||||
import { logger } from "../logger"
|
||||
import { getBetterAuthSessionCookie, getUser } from "./user"
|
||||
|
||||
const abortController = new AbortController()
|
||||
export const apiFetch = ofetch.create({
|
||||
|
|
@ -14,12 +16,6 @@ export const apiFetch = ofetch.create({
|
|||
signal: abortController.signal,
|
||||
retry: false,
|
||||
onRequest({ request }) {
|
||||
const betterAuthSessionCookie = getBetterAuthSessionCookie()
|
||||
if (!betterAuthSessionCookie) {
|
||||
abortController.abort()
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`API Request: ${request.toString()}`)
|
||||
},
|
||||
onRequestError(context) {
|
||||
|
|
@ -31,14 +27,21 @@ export const apiFetch = ofetch.create({
|
|||
|
||||
export const apiClient = hc<AppType>("", {
|
||||
fetch: async (input, options = {}) => apiFetch(input.toString(), options),
|
||||
headers() {
|
||||
const betterAuthSessionCookie = getBetterAuthSessionCookie()
|
||||
const user = getUser()
|
||||
async headers() {
|
||||
const window = getMainWindow()
|
||||
const cookies = await window?.webContents.session.cookies.get({
|
||||
domain: new URL(env.VITE_API_URL).hostname,
|
||||
})
|
||||
const sessionCookie = cookies?.find((cookie) =>
|
||||
cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),
|
||||
)
|
||||
const headerCookie = sessionCookie ? `${sessionCookie.name}=${sessionCookie.value}` : ""
|
||||
|
||||
return {
|
||||
"X-App-Version": PKG.version,
|
||||
"X-App-Dev": process.env.NODE_ENV === "development" ? "1" : "0",
|
||||
Cookie: betterAuthSessionCookie ? atob(betterAuthSessionCookie) : "",
|
||||
"User-Agent": `Folo/${PKG.version}${user?.id ? ` uid: ${user.id}` : ""}`,
|
||||
Cookie: headerCookie,
|
||||
"User-Agent": `Folo/${PKG.version}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,3 +11,12 @@ export const setDockCount = (input: number) => {
|
|||
app.setBadgeCount(input)
|
||||
}
|
||||
}
|
||||
|
||||
export const getDockCount = () => {
|
||||
if (isWindows) return null
|
||||
if (app.dock) {
|
||||
return app.dock.getBadge()
|
||||
} else {
|
||||
return app.getBadgeCount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Credentials } from "@eneris/push-receiver/dist/types"
|
||||
import type { Cookie } from "electron"
|
||||
import Store from "electron-store"
|
||||
|
||||
// @keep-sorted
|
||||
|
|
@ -7,9 +6,7 @@ type StoreData = {
|
|||
"notifications-credentials"?: Credentials | null
|
||||
"notifications-persistent-ids"?: string[] | null
|
||||
appearance?: "light" | "dark" | "system" | null
|
||||
betterAuthSessionCookie?: string | null
|
||||
cacheSizeLimit?: number | null
|
||||
cookies?: Cookie[] | null
|
||||
minimizeToTray?: boolean | null
|
||||
proxy?: string | null
|
||||
user?: string | null
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { name } from "@pkg"
|
||||
import { app, Menu, nativeImage, Tray } from "electron"
|
||||
|
||||
import { isMacOS } from "~/env"
|
||||
import { isMacOS, isWindows } from "~/env"
|
||||
import { getTrayIconPath } from "~/helper"
|
||||
import { logger, revealLogFile } from "~/logger"
|
||||
import { checkForAppUpdates } from "~/updater"
|
||||
|
||||
import { getMainWindowOrCreate } from "../window"
|
||||
import { getDockCount } from "./dock"
|
||||
import { t } from "./i18n"
|
||||
import { store } from "./store"
|
||||
|
||||
|
|
@ -14,19 +15,17 @@ import { store } from "./store"
|
|||
|
||||
let tray: Tray | null = null
|
||||
|
||||
export const registerAppTray = () => {
|
||||
if (!getTrayConfig()) return
|
||||
if (tray) {
|
||||
destroyAppTray()
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(getTrayIconPath())
|
||||
// See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326
|
||||
const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon
|
||||
trayIcon.setTemplateImage(true)
|
||||
tray = new Tray(trayIcon)
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
const getTrayContextMenu = () => {
|
||||
const count = getDockCount()
|
||||
return Menu.buildFromTemplate([
|
||||
...(count
|
||||
? [
|
||||
{
|
||||
label: `${t("menu.unread")} ${count}`,
|
||||
enabled: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t("menu.open", { name }),
|
||||
click: showWindow,
|
||||
|
|
@ -71,9 +70,27 @@ export const registerAppTray = () => {
|
|||
},
|
||||
},
|
||||
])
|
||||
tray.setContextMenu(contextMenu)
|
||||
}
|
||||
export const registerAppTray = () => {
|
||||
if (!getTrayConfig()) return
|
||||
if (tray) {
|
||||
destroyAppTray()
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(getTrayIconPath())
|
||||
// See https://stackoverflow.com/questions/41664208/electron-tray-icon-change-depending-on-dark-theme/41998326#41998326
|
||||
const trayIcon = isMacOS ? icon.resize({ width: 16 }) : icon
|
||||
trayIcon.setTemplateImage(true)
|
||||
tray = new Tray(trayIcon)
|
||||
|
||||
tray.setContextMenu(getTrayContextMenu())
|
||||
tray.setToolTip(app.getName())
|
||||
tray.on("click", showWindow)
|
||||
tray.on("mouse-enter", () => {
|
||||
tray?.setContextMenu(getTrayContextMenu())
|
||||
})
|
||||
if (isWindows) {
|
||||
tray.on("click", showWindow)
|
||||
}
|
||||
}
|
||||
|
||||
const showWindow = () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Credentials } from "@eneris/push-receiver/dist/types"
|
||||
import type { UserModel } from "@follow/models"
|
||||
|
||||
import { isLinux, isMacOS, isWindows } from "~/env"
|
||||
import { logger } from "~/logger"
|
||||
|
|
@ -7,19 +6,13 @@ import { logger } from "~/logger"
|
|||
import { apiClient } from "./api-client"
|
||||
import { store } from "./store"
|
||||
|
||||
const BetterAuthKey = "betterAuthSessionCookie"
|
||||
export const setBetterAuthSessionCookie = (cookie: string) => store.set(BetterAuthKey, cookie)
|
||||
export const getBetterAuthSessionCookie = () => store.get(BetterAuthKey)
|
||||
export const cleanBetterAuthSessionCookie = () => store.set(BetterAuthKey, null)
|
||||
|
||||
const UserKey = "user"
|
||||
export const setUser = (user: UserModel) => store.set(UserKey, JSON.stringify(user))
|
||||
export const getUser = (): UserModel | null => {
|
||||
const user = store.get(UserKey)
|
||||
return user ? JSON.parse(user) : null
|
||||
}
|
||||
|
||||
export const cleanUser = () => store.set(UserKey, null)
|
||||
const notificationChannel = isMacOS
|
||||
? "macos"
|
||||
: isWindows
|
||||
? "windows"
|
||||
: isLinux
|
||||
? "linux"
|
||||
: "desktop"
|
||||
|
||||
export const updateNotificationsToken = async (newCredentials?: Credentials) => {
|
||||
if (newCredentials) {
|
||||
|
|
@ -31,7 +24,7 @@ export const updateNotificationsToken = async (newCredentials?: Credentials) =>
|
|||
await apiClient.messaging.$post({
|
||||
json: {
|
||||
token: credentials.fcm.token,
|
||||
channel: isMacOS ? "macos" : isWindows ? "windows" : isLinux ? "linux" : "desktop",
|
||||
channel: notificationChannel,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -39,3 +32,11 @@ export const updateNotificationsToken = async (newCredentials?: Credentials) =>
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteNotificationsToken = async () => {
|
||||
await apiClient.messaging.$delete({
|
||||
json: {
|
||||
channel: notificationChannel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import path from "node:path"
|
||||
|
||||
import { DEV } from "@follow/shared"
|
||||
import { app } from "electron"
|
||||
|
||||
if (DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
|
||||
|
|
@ -6,10 +6,8 @@ import { fileURLToPath } from "node:url"
|
|||
import { getRendererHandlers } from "@egoist/tipc/main"
|
||||
import { callWindowExpose } from "@follow/shared/bridge"
|
||||
import { DEV } from "@follow/shared/constants"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { app, BrowserWindow, clipboard, dialog, shell } from "electron"
|
||||
|
||||
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
|
||||
import { filePathToAppUrl } from "~/helper"
|
||||
import { registerMenuAndContextMenu } from "~/init"
|
||||
import { clearAllData, getCacheSize } from "~/lib/cleaner"
|
||||
|
|
@ -20,7 +18,6 @@ import { cleanupOldRender, loadDynamicRenderEntry } from "~/updater/hot-updater"
|
|||
|
||||
import { downloadFile } from "../lib/download"
|
||||
import { i18n } from "../lib/i18n"
|
||||
import { cleanBetterAuthSessionCookie, cleanUser } from "../lib/user"
|
||||
import type { RendererHandlers } from "../renderer-handlers"
|
||||
import { quitAndInstall } from "../updater"
|
||||
import { getMainWindow } from "../window"
|
||||
|
|
@ -151,16 +148,6 @@ export const appRoute = {
|
|||
quitAndInstall()
|
||||
}),
|
||||
|
||||
cleanBetterAuthSessionCookie: t.procedure.action(async () => {
|
||||
cleanBetterAuthSessionCookie()
|
||||
cleanUser()
|
||||
|
||||
const mainWindow = getMainWindow()
|
||||
await mainWindow?.webContents.session.cookies.remove(
|
||||
env.VITE_API_URL,
|
||||
BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
|
||||
)
|
||||
}),
|
||||
/// clipboard
|
||||
|
||||
readClipboard: t.procedure.action(async () => clipboard.readText()),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
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,19 +1,19 @@
|
|||
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"
|
||||
import { trackerRoute } from "./tracker"
|
||||
|
||||
export const router = {
|
||||
...debugRoute,
|
||||
...menuRoute,
|
||||
...settingRoute,
|
||||
...appRoute,
|
||||
...trackerRoute,
|
||||
...dockRoute,
|
||||
...readerRoute,
|
||||
...authRoute,
|
||||
}
|
||||
|
||||
export type Router = typeof router
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ export const readerRoute = {
|
|||
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.WEBM_24KHZ_16BIT_MONO_OPUS)
|
||||
.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to set voice", error)
|
||||
if (error instanceof Error) {
|
||||
|
|
@ -55,12 +56,17 @@ export const readerRoute = {
|
|||
})
|
||||
})
|
||||
|
||||
const filePath = path.join(app.getPath("userData"), `${id}.webm`)
|
||||
if (fs.existsSync(filePath)) {
|
||||
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 {
|
||||
await tts.toFile(filePath, text)
|
||||
return filePath
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
const { audioFilePath } = await tts.toFile(dirPath, text)
|
||||
return audioFilePath
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import type { UserModel } from "@follow/models"
|
||||
|
||||
import { setUser } from "../lib/user"
|
||||
import { t } from "./_instance"
|
||||
|
||||
export const trackerRoute = {
|
||||
trackerIdentify: t.procedure
|
||||
.input<{
|
||||
user: UserModel
|
||||
}>()
|
||||
.action(async ({ input }) => {
|
||||
const { user } = input
|
||||
|
||||
setUser(user)
|
||||
}),
|
||||
}
|
||||
|
|
@ -20,25 +20,25 @@
|
|||
"@follow/shared": "workspace:*",
|
||||
"@follow/tracker": "workspace:*",
|
||||
"@fontsource/sn-pro": "5.2.5",
|
||||
"@headlessui/react": "2.2.1",
|
||||
"@headlessui/react": "2.2.2",
|
||||
"@hookform/resolvers": "4.1.3",
|
||||
"@lottiefiles/dotlottie-react": "0.13.3",
|
||||
"@openpanel/web": "1.0.1",
|
||||
"@radix-ui/react-avatar": "1.1.3",
|
||||
"@radix-ui/react-context-menu": "2.2.6",
|
||||
"@radix-ui/react-dialog": "1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.6",
|
||||
"@radix-ui/react-hover-card": "1.1.6",
|
||||
"@radix-ui/react-label": "2.1.2",
|
||||
"@radix-ui/react-popover": "1.1.6",
|
||||
"@radix-ui/react-slider": "1.2.3",
|
||||
"@radix-ui/react-slot": "1.1.2",
|
||||
"@sentry/react": "9.11.0",
|
||||
"@shikijs/transformers": "3.2.1",
|
||||
"@tanstack/query-sync-storage-persister": "5.71.10",
|
||||
"@tanstack/react-query": "5.71.10",
|
||||
"@tanstack/react-query-devtools": "5.71.10",
|
||||
"@tanstack/react-query-persist-client": "5.71.10",
|
||||
"@radix-ui/react-avatar": "1.1.4",
|
||||
"@radix-ui/react-context-menu": "2.2.7",
|
||||
"@radix-ui/react-dialog": "1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.7",
|
||||
"@radix-ui/react-hover-card": "1.1.7",
|
||||
"@radix-ui/react-label": "2.1.3",
|
||||
"@radix-ui/react-popover": "1.1.7",
|
||||
"@radix-ui/react-slider": "1.2.4",
|
||||
"@radix-ui/react-slot": "1.2.0",
|
||||
"@sentry/react": "9.13.0",
|
||||
"@shikijs/transformers": "3.2.2",
|
||||
"@tanstack/query-sync-storage-persister": "5.74.4",
|
||||
"@tanstack/react-query": "5.74.4",
|
||||
"@tanstack/react-query-devtools": "5.74.4",
|
||||
"@tanstack/react-query-persist-client": "5.74.4",
|
||||
"@tanstack/react-virtual": "3.13.6",
|
||||
"@use-gesture/react": "10.3.1",
|
||||
"@welldone-software/why-did-you-render": "10.0.1",
|
||||
|
|
@ -53,23 +53,23 @@
|
|||
"dnum": "2.14.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"embla-carousel-wheel-gestures": "8.0.2",
|
||||
"es-toolkit": "1.34.1",
|
||||
"es-toolkit": "1.35.0",
|
||||
"firebase": "11.6.0",
|
||||
"foxact": "0.2.45",
|
||||
"framer-motion": "12.6.3",
|
||||
"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": "24.2.3",
|
||||
"i18next": "25.0.0",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"idb-keyval": "6.2.1",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.12.2",
|
||||
"jotai": "2.12.3",
|
||||
"lethargy": "1.0.9",
|
||||
"masonic": "4.0.1",
|
||||
"mdast-util-gfm-table": "2.0.0",
|
||||
"mdast-util-to-markdown": "2.1.2",
|
||||
"motion": "12.7.4",
|
||||
"nanoid": "5.1.5",
|
||||
"ofetch": "1.4.1",
|
||||
"plain-shiki": "0.2.0",
|
||||
|
|
@ -77,23 +77,23 @@
|
|||
"react-blurhash": "0.3.0",
|
||||
"react-fast-marquee": "1.6.5",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-hotkeys-hook": "4.6.1",
|
||||
"react-hotkeys-hook": "5.0.1",
|
||||
"react-i18next": "15.4.1",
|
||||
"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.0",
|
||||
"react-router": "7.5.1",
|
||||
"react-selecto": "1.26.3",
|
||||
"react-shadow": "20.6.0",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"shiki": "3.2.1",
|
||||
"shiki": "3.2.2",
|
||||
"sonner": "2.0.3",
|
||||
"tldts": "6.1.85",
|
||||
"tldts": "7.0.0",
|
||||
"use-context-selector": "2.0.0",
|
||||
"use-sync-external-store": "1.5.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "3.24.2",
|
||||
"zod": "3.24.3",
|
||||
"zustand": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
"@follow/models": "workspace:*",
|
||||
"@follow/types": "workspace:*",
|
||||
"@follow/utils": "workspace:*",
|
||||
"@types/node": "22.14.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@vite-pwa/assets-generator": "1.0.0",
|
||||
"fake-indexeddb": "6.0.0",
|
||||
"happy-dom": "17.4.4",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { getStorageNS } from "@follow/utils/ns"
|
||||
import { atom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
import { createAtomHooks } from "~/lib/jotai"
|
||||
|
||||
|
|
@ -28,7 +30,11 @@ export const [
|
|||
getReadabilityContent,
|
||||
__setReadabilityContent,
|
||||
useReadabilityContentSelector,
|
||||
] = createAtomHooks(atom<Record<string, Readability>>({}))
|
||||
] = createAtomHooks(
|
||||
atomWithStorage<Record<string, Readability>>(getStorageNS("readability-content"), {}, undefined, {
|
||||
getOnInit: true,
|
||||
}),
|
||||
)
|
||||
export const setReadabilityContent = mergeObjectSetter(
|
||||
__setReadabilityContent,
|
||||
getReadabilityContent,
|
||||
|
|
@ -57,6 +63,12 @@ export const useEntryIsInReadability = (entryId?: string) =>
|
|||
[entryId],
|
||||
)
|
||||
|
||||
export const useEntryIsInReadabilitySuccess = (entryId?: string) =>
|
||||
useReadabilityStatusSelector(
|
||||
(map) => (entryId ? map[entryId] === ReadabilityStatus.SUCCESS : false),
|
||||
[entryId],
|
||||
)
|
||||
|
||||
export const useEntryInReadabilityStatus = (entryId?: string) =>
|
||||
useReadabilityStatusSelector(
|
||||
(map) => (entryId ? map[entryId] || ReadabilityStatus.INITIAL : ReadabilityStatus.INITIAL),
|
||||
|
|
|
|||
|
|
@ -178,6 +178,13 @@ export function useActionLanguage() {
|
|||
) as SupportedLanguages
|
||||
}
|
||||
|
||||
export function getActionLanguage() {
|
||||
const { actionLanguage, language } = getGeneralSettingsInternal()
|
||||
return (
|
||||
actionLanguage === DEFAULT_ACTION_LANGUAGE ? language : actionLanguage
|
||||
) as SupportedLanguages
|
||||
}
|
||||
|
||||
export const subscribeShouldUseIndexedDB = (callback: (value: boolean) => void) =>
|
||||
jotaiStore.sub(__generalSettingAtom, () => callback(getGeneralSettingsInternal().dataPersist))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { MotionProps, TargetAndTransition } from "framer-motion"
|
||||
import { m as M } from "framer-motion"
|
||||
import type { MotionProps, TargetAndTransition } from "motion/react"
|
||||
import { m as M } from "motion/react"
|
||||
import { createElement, forwardRef } from "react"
|
||||
|
||||
import { useReduceMotion } from "~/hooks/biz/useReduceMotion"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useCorrectZIndex } from "@follow/components/ui/z-index/ctx.js"
|
|||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import Fuse from "fuse.js"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { forwardRef, Fragment, useCallback, useEffect, useState } from "react"
|
||||
|
||||
export type Suggestion = {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { AnimatedCommandButton } from "./base"
|
|||
export const CopyButton: Component<{
|
||||
value: string
|
||||
style?: React.CSSProperties
|
||||
variant?: "solid" | "outline"
|
||||
variant?: "solid" | "outline" | "ghost"
|
||||
}> = ({ value, className, style, variant = "solid" }) => {
|
||||
const copiedTimerRef = useRef<any>()
|
||||
const handleCopy = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { HTMLMotionProps, Variants } from "framer-motion"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import type { HTMLMotionProps, Variants } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { cloneElement, useRef, useState } from "react"
|
||||
|
|
@ -44,7 +44,7 @@ export const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionPr
|
|||
"center pointer-events-auto flex text-xs",
|
||||
"rounded-md p-1.5 duration-200",
|
||||
variant === "solid" || variant === "ghost"
|
||||
? "border-accent/5 bg-accent/80 border text-white backdrop-blur"
|
||||
? "border-accent/5 bg-accent/80 text-accent border backdrop-blur"
|
||||
: "text-accent bg-slate-50/50 dark:bg-slate-900/50",
|
||||
variant === "ghost" && "bg-theme-item-active hover:bg-theme-item-hover",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -15,44 +15,7 @@ const LanguageAlias = {
|
|||
jsx: "javascriptreact",
|
||||
md: "markdown",
|
||||
}
|
||||
const LanguageToColorMap = {
|
||||
"c#": "#178600",
|
||||
"c++": "#f34b7d",
|
||||
"objective-c": "#438eff",
|
||||
"objective-c++": "#6866fb",
|
||||
c: "#555555",
|
||||
cpp: "#f34b7d",
|
||||
csharp: "#178600",
|
||||
css: "#563d7c",
|
||||
dart: "#00B4AB",
|
||||
dockerfile: "#384d54",
|
||||
go: "#00add8",
|
||||
html: "#e34c26",
|
||||
java: "#b07219",
|
||||
javascript: "#f1e05a",
|
||||
javascriptreact: "#f1e05a",
|
||||
json: "#292929",
|
||||
kotlin: "#F18E33",
|
||||
lisp: "#3fb68b",
|
||||
lua: "#000080",
|
||||
markdown: "#083fa1",
|
||||
matlab: "#e16737",
|
||||
perl: "#0298c3",
|
||||
php: "#4f5d95",
|
||||
python: "#3572a5",
|
||||
r: "#198ce7",
|
||||
ruby: "#701516",
|
||||
rust: "#dea584",
|
||||
scala: "#c22d40",
|
||||
shell: "#89e051",
|
||||
sql: "#e38c00",
|
||||
swift: "#ffac45",
|
||||
typescript: "#2b7489",
|
||||
typescriptreact: "#2b7489",
|
||||
vimscript: "#199f4b",
|
||||
vue: "#2c3e50",
|
||||
yaml: "#cb171e",
|
||||
}
|
||||
|
||||
const languageToIconMap = {
|
||||
javascriptreact: <UilReact />,
|
||||
typescriptreact: <UilReact />,
|
||||
|
|
@ -62,15 +25,6 @@ const languageToIconMap = {
|
|||
css: <MdiLanguageCss3 />,
|
||||
markdown: <RiMarkdownFill />,
|
||||
}
|
||||
export const getLanguageColor = (language?: string) => {
|
||||
if (!language) return
|
||||
|
||||
const alias = LanguageAlias[language]
|
||||
if (alias) {
|
||||
return LanguageToColorMap[alias]
|
||||
}
|
||||
return LanguageToColorMap[language]
|
||||
}
|
||||
|
||||
export const getLanguageIcon = (language?: string) => {
|
||||
if (!language) return null
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useIsDark } from "@follow/hooks"
|
||||
import { ELECTRON_BUILD } from "@follow/shared/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
|
||||
|
|
@ -11,11 +10,12 @@ import type {
|
|||
DynamicImportThemeRegistration,
|
||||
} from "shiki"
|
||||
|
||||
import { useUISettingKey, useUISettingSelector } from "~/atoms/settings/ui"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
|
||||
import { CopyButton } from "../../button/CopyButton"
|
||||
import { getLanguageColor, getLanguageIcon } from "../constants"
|
||||
import { getLanguageIcon } from "../constants"
|
||||
import { useShikiDefaultTheme } from "./hooks"
|
||||
import { shiki, shikiTransformers } from "./shared"
|
||||
import styles from "./shiki.module.css"
|
||||
|
||||
|
|
@ -72,10 +72,7 @@ export const ShikiHighLighter: FC<ShikiProps> = (props) => {
|
|||
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const isDark = useIsDark()
|
||||
const codeThemeLight = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeLight)
|
||||
const codeThemeDark = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeDark)
|
||||
const codeTheme = isDark ? codeThemeDark : codeThemeLight
|
||||
const codeTheme = useShikiDefaultTheme(overrideTheme)
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
let isMounted = true
|
||||
|
|
@ -188,27 +185,22 @@ const ShikiCode: FC<
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative my-4",
|
||||
"my-4 border",
|
||||
styles["shiki-wrapper"],
|
||||
transparent ? styles["transparent"] : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-2">
|
||||
{language !== "plaintext" && (
|
||||
<span className="center flex gap-1 text-xs uppercase opacity-80 dark:text-white">
|
||||
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
|
||||
<span>{language}</span>
|
||||
</span>
|
||||
)}
|
||||
<CopyButton variant="outline" value={code} />
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: rendered }} data-language={language} />
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
value={code}
|
||||
style={{
|
||||
color: getLanguageColor(language),
|
||||
}}
|
||||
className={"absolute right-2 top-2 opacity-0 duration-200 group-hover:opacity-100"}
|
||||
/>
|
||||
{language !== "plaintext" && (
|
||||
<span className="center absolute bottom-2 right-2 flex gap-1 text-xs uppercase opacity-80 dark:text-white">
|
||||
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
|
||||
<span>{language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useIsDark } from "@follow/hooks"
|
||||
|
||||
export const useShikiDefaultTheme = () => {
|
||||
const isDark = useIsDark()
|
||||
import { useUISettingSelector } from "~/atoms/settings/ui"
|
||||
|
||||
return isDark ? "github-dark" : "github-light"
|
||||
export const useShikiDefaultTheme = (overrideTheme?: string) => {
|
||||
const isDark = useIsDark()
|
||||
const codeThemeLight = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeLight)
|
||||
const codeThemeDark = useUISettingSelector((s) => overrideTheme || s.codeHighlightThemeDark)
|
||||
|
||||
return isDark ? codeThemeDark : codeThemeLight
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { Variants } from "framer-motion"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { atom, useAtom, useStore } from "jotai"
|
||||
import type { Variants } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Spring } from "framer-motion"
|
||||
import type { Spring } from "motion/react"
|
||||
|
||||
export const reboundPreset: Spring = {
|
||||
type: "spring",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { RootPortal } from "@follow/components/ui/portal/index.jsx"
|
|||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { typescriptHappyForwardRef } from "foxact/typescript-happy-forward-ref"
|
||||
import type { HTMLMotionProps } from "framer-motion"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import type { HTMLMotionProps } from "motion/react"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import type * as React from "react"
|
||||
import type { JSX, PropsWithChildren, ReactNode } from "react"
|
||||
import { useId } from "react"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useViewport } from "@follow/components/hooks/useViewport.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import * as HoverCard from "@radix-ui/react-hover-card"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useRealInWideMode } from "~/atoms/settings/ui"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { nextFrame } from "@follow/utils/dom"
|
|||
import { getImageProxyUrl } from "@follow/utils/img-proxy"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { omit } from "es-toolkit/compat"
|
||||
import { useForceUpdate } from "framer-motion"
|
||||
import { useForceUpdate } from "motion/react"
|
||||
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
|
||||
import { createContext, memo, useContext, useMemo, useState } from "react"
|
||||
import { Blurhash, BlurhashCanvas } from "react-blurhash"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useRefValue, useVideo } from "@follow/hooks"
|
|||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import * as Slider from "@radix-ui/react-slider"
|
||||
import { m, useDragControls, useSpring } from "framer-motion"
|
||||
import { m, useDragControls, useSpring } from "motion/react"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Button } from "@follow/components/ui/button/index.js"
|
|||
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { MotionProps, Target } from "framer-motion"
|
||||
import type { MotionProps, Target } from "motion/react"
|
||||
|
||||
import { microReboundPreset } from "../../constants/spring"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { m, useAnimationControls } from "framer-motion"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import type { FC, ReactNode } from "react"
|
||||
import { useId, useMemo } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import type { DragControls } from "framer-motion"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import type { DragControls } from "motion/react"
|
||||
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
|
||||
import { useContext, useId, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { useAnimationControls } from "framer-motion"
|
||||
import { useAnimationControls } from "motion/react"
|
||||
import { useCallback, useEffect, useLayoutEffect } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useDragControls } from "framer-motion"
|
||||
import { useDragControls } from "motion/react"
|
||||
import type { PointerEventHandler, RefObject } from "react"
|
||||
import { useCallback } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AnimatePresence } from "framer-motion"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { useModalStack } from "./hooks"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { sheetStackAtom } from "@follow/components/ui/sheet/context.js"
|
|||
import type { SheetRef } from "@follow/components/ui/sheet/Sheet.js"
|
||||
import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js"
|
||||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { produce } from "immer"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { createElement, useMemo, useRef } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { ELECTRON_BUILD } from "@follow/shared/constants"
|
|||
import { nextFrame, preventDefault, stopPropagation } from "@follow/utils/dom"
|
||||
import { cn, getOS } from "@follow/utils/utils"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import type { BoundingBox } from "framer-motion"
|
||||
import { produce } from "immer"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { selectAtom } from "jotai/utils"
|
||||
import type { BoundingBox } from "motion/react"
|
||||
import { Resizable } from "re-resizable"
|
||||
import type { FC, PropsWithChildren, SyntheticEvent } from "react"
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import type { ForwardedRef } from "react"
|
||||
import { forwardRef } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import type { Spring, Target } from "framer-motion"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import type { Spring, Target } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { cloneElement, useEffect, useState } from "react"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType, UserRole } from "@follow/constants"
|
||||
import { FeedViewType, UserRole, views } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
|
||||
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
|
||||
import {
|
||||
getReadabilityContent,
|
||||
getReadabilityStatus,
|
||||
ReadabilityStatus,
|
||||
setReadabilityContent,
|
||||
setReadabilityStatus,
|
||||
useEntryIsInReadability,
|
||||
} from "~/atoms/readability"
|
||||
import { useShowSourceContent } from "~/atoms/source-content"
|
||||
import { useUserRole, whoami } from "~/atoms/user"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { useRunCommandFn } from "~/modules/command/hooks/use-command"
|
||||
import type { FollowCommandId } from "~/modules/command/types"
|
||||
|
|
@ -25,41 +27,45 @@ import { useInboxById } from "~/store/inbox"
|
|||
|
||||
import { useRouteParamsSelector } from "./useRouteParams"
|
||||
|
||||
export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string }) =>
|
||||
useCallback(async () => {
|
||||
const status = getReadabilityStatus()[id]
|
||||
const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status
|
||||
export const toggleEntryReadability = async ({ id, url }: { id: string; url: string }) => {
|
||||
const status = getReadabilityStatus()[id]
|
||||
const isTurnOn = status !== ReadabilityStatus.INITIAL && !!status
|
||||
|
||||
if (!isTurnOn && url) {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.WAITING,
|
||||
})
|
||||
const result = await tipcClient
|
||||
?.readability({
|
||||
url,
|
||||
})
|
||||
.catch(() => {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.FAILURE,
|
||||
})
|
||||
})
|
||||
if (!isTurnOn && url) {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.WAITING,
|
||||
})
|
||||
try {
|
||||
let data = getReadabilityContent()[id]
|
||||
|
||||
if (result) {
|
||||
if (!data) {
|
||||
const result = await apiClient.entries.readability.$get({ query: { id } })
|
||||
if (result.data) {
|
||||
data = result.data
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const status = getReadabilityStatus()[id]
|
||||
if (status !== ReadabilityStatus.WAITING) return
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.SUCCESS,
|
||||
})
|
||||
setReadabilityContent({
|
||||
[id]: result,
|
||||
[id]: data,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
} catch {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.INITIAL,
|
||||
[id]: ReadabilityStatus.FAILURE,
|
||||
})
|
||||
}
|
||||
}, [id, url])
|
||||
} else {
|
||||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.INITIAL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type EntryActionItem = {
|
||||
id: FollowCommandId
|
||||
|
|
@ -68,10 +74,25 @@ export type EntryActionItem = {
|
|||
shortcut?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
notice?: boolean
|
||||
entryId: string
|
||||
}
|
||||
|
||||
export const useEntryActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
|
||||
function hasHTMLTags(text?: string | null): boolean {
|
||||
return /<[^>]+>/.test(text || "")
|
||||
}
|
||||
|
||||
export const useEntryActions = ({
|
||||
entryId,
|
||||
view,
|
||||
compact,
|
||||
}: {
|
||||
entryId: string
|
||||
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 feed = useFeedById(entry?.feedId, (feed) => {
|
||||
return {
|
||||
|
|
@ -84,6 +105,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
|
|||
const inList = !!listId
|
||||
const inbox = useInboxById(entry?.inboxId)
|
||||
const isInbox = !!inbox
|
||||
const isContentContainsHTMLTags = hasHTMLTags(entry?.entries.content)
|
||||
|
||||
const isShowSourceContent = useShowSourceContent()
|
||||
const isShowAISummaryAuto = useShowAISummaryAuto(entry)
|
||||
|
|
@ -208,17 +230,49 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
|
|||
active: !!entry?.read,
|
||||
shortcut: shortcuts.entry.toggleRead.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.tts,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.tts, [
|
||||
{ entryId, entryContent: entry?.entries.content },
|
||||
]),
|
||||
hide: !IN_ELECTRON || compact || !entry?.entries.content,
|
||||
shortcut: shortcuts.entry.tts.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.readability,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.readability, [
|
||||
{ entryId, entryUrl: entry?.entries.url },
|
||||
]),
|
||||
hide:
|
||||
!!entry.settings?.readability ||
|
||||
compact ||
|
||||
(view && views[view]!.wideMode) ||
|
||||
!entry?.entries.url,
|
||||
active: isEntryInReadability,
|
||||
notice: !isContentContainsHTMLTags && !isEntryInReadability,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.settings.customizeToolbar,
|
||||
onClick: runCmdFn(COMMAND_ID.settings.customizeToolbar, []),
|
||||
},
|
||||
].filter((config) => !config.hide)
|
||||
]
|
||||
.filter((config) => !config.hide)
|
||||
.map((config) => {
|
||||
return {
|
||||
...config,
|
||||
entryId,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
compact,
|
||||
entry?.collections,
|
||||
entry?.entries.content,
|
||||
entry?.entries.url,
|
||||
entry?.read,
|
||||
entry?.settings?.readability,
|
||||
entry?.view,
|
||||
entryId,
|
||||
isEntryInReadability,
|
||||
feed?.id,
|
||||
feed?.ownerUserId,
|
||||
hasEntry,
|
||||
|
|
@ -230,6 +284,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
|
|||
isShowAITranslationAuto,
|
||||
isShowAITranslationOnce,
|
||||
isShowSourceContent,
|
||||
isContentContainsHTMLTags,
|
||||
runCmdFn,
|
||||
userRole,
|
||||
view,
|
||||
|
|
@ -241,11 +296,13 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
|
|||
export const useSortedEntryActions = ({
|
||||
entryId,
|
||||
view,
|
||||
compact,
|
||||
}: {
|
||||
entryId: string
|
||||
view?: FeedViewType
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const entryActions = useEntryActions({ entryId, view })
|
||||
const entryActions = useEntryActions({ entryId, view, compact })
|
||||
const orderMap = useToolbarOrderMap()
|
||||
const mainAction = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -359,6 +359,16 @@ export const useFeedActions = ({
|
|||
navigator.clipboard.writeText(feedId)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
label: t("sidebar.feed_actions.copy_feed_badge"),
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(
|
||||
`https://badge.follow.is/feed/${feedId}?color=FF5C00&labelColor=black&style=flat-square`,
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return items.filter(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useReducedMotion } from "framer-motion"
|
||||
import { useReducedMotion } from "motion/react"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { clearStorage } from "@follow/utils/ns"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { setWhoami } from "~/atoms/user"
|
||||
import { QUERY_PERSIST_KEY } from "~/constants"
|
||||
import { signOut } from "~/lib/auth"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { clearLocalPersistStoreData } from "~/store/utils/clear"
|
||||
|
||||
export const useSignOut = () =>
|
||||
useCallback(async () => {
|
||||
if (window.__RN__) {
|
||||
window.ReactNativeWebView?.postMessage("sign-out")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear query cache
|
||||
localStorage.removeItem(QUERY_PERSIST_KEY)
|
||||
|
||||
// setLoginModalShow(true)
|
||||
setWhoami(null)
|
||||
|
||||
// Clear local storage
|
||||
clearStorage()
|
||||
|
||||
// clear local store data
|
||||
await Promise.allSettled([
|
||||
clearLocalPersistStoreData(),
|
||||
tipcClient?.cleanBetterAuthSessionCookie(),
|
||||
])
|
||||
// Sign out
|
||||
await signOut().then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}, [])
|
||||
|
|
@ -26,8 +26,8 @@ export const useSwitchHotKeyScope = () => {
|
|||
)
|
||||
}
|
||||
export const useHotkeyScopeFn = (scope: keyof typeof HotKeyScopeMap) => {
|
||||
const { enableScope, disableScope, enabledScopes } = useHotkeysContext()
|
||||
const currentScopeRef = useRef(enabledScopes)
|
||||
const { enableScope, disableScope, activeScopes } = useHotkeysContext()
|
||||
const currentScopeRef = useRef(activeScopes)
|
||||
|
||||
return useCallback(() => {
|
||||
const currentScope = currentScopeRef.current
|
||||
|
|
|
|||
|
|
@ -1,52 +1,22 @@
|
|||
import { IN_ELECTRON } from "@follow/shared"
|
||||
import { Auth } from "@follow/shared/auth"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import type { authPlugins } from "@follow/shared/hono"
|
||||
import type { BetterAuthClientPlugin } from "better-auth/client"
|
||||
import { inferAdditionalFields, twoFactorClient } from "better-auth/client/plugins"
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
|
||||
import { WEB_URL } from "~/constants/env"
|
||||
|
||||
type AuthPlugin = (typeof authPlugins)[number]
|
||||
const serverPlugins = [
|
||||
{
|
||||
id: "customGetProviders",
|
||||
$InferServerPlugin: {} as Extract<AuthPlugin, { id: "customGetProviders" }>,
|
||||
},
|
||||
{
|
||||
id: "customCreateSession",
|
||||
$InferServerPlugin: {} as Extract<AuthPlugin, { id: "customCreateSession" }>,
|
||||
},
|
||||
{
|
||||
id: "getAccountInfo",
|
||||
$InferServerPlugin: {} as Extract<AuthPlugin, { id: "getAccountInfo" }>,
|
||||
},
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
handle: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
] satisfies BetterAuthClientPlugin[]
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: `${env.VITE_API_URL}/better-auth`,
|
||||
plugins: [...serverPlugins, twoFactorClient()],
|
||||
const auth = new Auth({
|
||||
apiURL: env.VITE_API_URL,
|
||||
webURL: env.VITE_WEB_URL,
|
||||
})
|
||||
|
||||
// @keep-sorted
|
||||
export const {
|
||||
changeEmail,
|
||||
changePassword,
|
||||
createSession,
|
||||
forgetPassword,
|
||||
getAccountInfo,
|
||||
getProviders,
|
||||
getSession,
|
||||
linkSocial,
|
||||
listAccounts,
|
||||
oneTimeToken,
|
||||
resetPassword,
|
||||
sendVerificationEmail,
|
||||
signIn,
|
||||
|
|
@ -55,33 +25,6 @@ export const {
|
|||
twoFactor,
|
||||
unlinkAccount,
|
||||
updateUser,
|
||||
} = authClient
|
||||
} = auth.authClient
|
||||
|
||||
export type LoginRuntime = "browser" | "app"
|
||||
export const loginHandler = async (
|
||||
provider: string,
|
||||
runtime?: LoginRuntime,
|
||||
args?: {
|
||||
email?: string
|
||||
password?: string
|
||||
headers?: Record<string, string>
|
||||
},
|
||||
) => {
|
||||
const { email, password, headers } = args ?? {}
|
||||
if (IN_ELECTRON && provider !== "credential") {
|
||||
window.open(`${WEB_URL}/login?provider=${provider}`)
|
||||
} else {
|
||||
if (provider === "credential") {
|
||||
if (!email || !password) {
|
||||
window.location.href = "/login"
|
||||
return
|
||||
}
|
||||
return signIn.email({ email, password }, { headers })
|
||||
}
|
||||
|
||||
signIn.social({
|
||||
provider: provider as "google" | "github" | "apple",
|
||||
callbackURL: runtime === "app" ? `${WEB_URL}/login` : WEB_URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const { loginHandler } = auth
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export const loadLanguageAndApply = async (lang: string) => {
|
|||
const loaded = loadedLangs.has(lang)
|
||||
|
||||
if (loaded) {
|
||||
if (import.meta.env.DEV) {
|
||||
EventBus.dispatch("I18N_UPDATE", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { SupportedActionLanguage } from "@follow/shared"
|
|||
import { ACTION_LANGUAGE_MAP } from "@follow/shared"
|
||||
import { franc } from "franc-min"
|
||||
|
||||
import { getReadabilityContent } from "~/atoms/readability"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
|
||||
import { apiClient } from "./api-fetch"
|
||||
|
|
@ -57,6 +58,16 @@ export async function translate({
|
|||
}
|
||||
|
||||
fields = fields.filter((field) => {
|
||||
if (language && field === "readabilityContent") {
|
||||
const content = getReadabilityContent()[entry.entries.id]?.content
|
||||
if (!content) return false
|
||||
const isLanguageMatch = checkLanguage({
|
||||
content,
|
||||
language,
|
||||
})
|
||||
return !isLanguageMatch
|
||||
}
|
||||
|
||||
if (language && entry.entries[field]) {
|
||||
const isLanguageMatch = checkLanguage({
|
||||
content: entry.entries[field],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { actionActions, useActionByIndex } from "~/store/action"
|
|||
|
||||
type Action = {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
config?: () => React.ReactNode
|
||||
configInline?: boolean
|
||||
enabled: boolean
|
||||
|
|
@ -73,6 +74,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
() => [
|
||||
{
|
||||
title: t("actions.action_card.generate_summary"),
|
||||
icon: <i className="i-mgc-ai-cute-re" />,
|
||||
enabled: !!summary,
|
||||
onInit: (data) => {
|
||||
data.result.summary = true
|
||||
|
|
@ -83,6 +85,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.translate_into"),
|
||||
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
|
||||
enabled: !!translation,
|
||||
onInit: (data) => {
|
||||
data.result.translation = true
|
||||
|
|
@ -93,6 +96,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.enable_readability"),
|
||||
icon: <i className="i-mgc-docment-cute-re" />,
|
||||
enabled: !!readability,
|
||||
onInit: (data) => {
|
||||
data.result.readability = true
|
||||
|
|
@ -103,6 +107,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.source_content"),
|
||||
icon: <i className="i-mgc-web-cute-re" />,
|
||||
enabled: !!sourceContent,
|
||||
onInit: (data) => {
|
||||
data.result.sourceContent = true
|
||||
|
|
@ -113,6 +118,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.new_entry_notification"),
|
||||
icon: <i className="i-mgc-bell-ringing-cute-re" />,
|
||||
enabled: !!newEntryNotification,
|
||||
onInit: (data) => {
|
||||
data.result.newEntryNotification = true
|
||||
|
|
@ -123,6 +129,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.silence"),
|
||||
icon: <i className="i-mgc-volume-mute-cute-re" />,
|
||||
enabled: !!silence,
|
||||
onInit: (data) => {
|
||||
data.result.silence = true
|
||||
|
|
@ -133,6 +140,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.block"),
|
||||
icon: <i className="i-mgc-delete-2-cute-re" />,
|
||||
enabled: !!block,
|
||||
onInit: (data) => {
|
||||
data.result.block = true
|
||||
|
|
@ -143,6 +151,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.rewrite_rules"),
|
||||
icon: <i className="i-mgc-quill-pen-cute-re" />,
|
||||
enabled: !!rewriteRules,
|
||||
onInit: (data) => {
|
||||
data.result.rewriteRules = [
|
||||
|
|
@ -226,6 +235,7 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
},
|
||||
{
|
||||
title: t("actions.action_card.webhooks"),
|
||||
icon: <i className="i-mgc-webhook-cute-re" />,
|
||||
enabled: !!webhooks,
|
||||
onInit: (data) => {
|
||||
data.result.webhooks = [""]
|
||||
|
|
@ -324,7 +334,10 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
})
|
||||
}}
|
||||
>
|
||||
{action.title}
|
||||
<div className="flex items-center gap-2">
|
||||
{action.icon}
|
||||
{action.title}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
|
|
@ -338,7 +351,10 @@ export const TargetActionList = ({ index }: { index: number }) => {
|
|||
return (
|
||||
<Fragment key={action.title}>
|
||||
<div className="group/action mt-3 flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">{action.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{action.icon}
|
||||
<span className="shrink grow truncate">{action.title}</span>
|
||||
</div>
|
||||
{action.configInline && action.config && action.config()}
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import {
|
|||
} from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { cn, isBizId } from "@follow/utils/utils"
|
||||
import type { Variant } from "framer-motion"
|
||||
import { m, useAnimationControls } from "framer-motion"
|
||||
import type { Components } from "hast-util-to-jsx-runtime"
|
||||
import type { Variant } from "motion/react"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
|
@ -94,7 +94,7 @@ export const DailyReportTitle = ({
|
|||
className="flex items-center justify-center gap-2 text-base"
|
||||
layoutId={`daily-report-title-${title}`}
|
||||
>
|
||||
<i className="i-mgc-magic-2-cute-re" />
|
||||
<i className="i-mgc-ai-cute-re" />
|
||||
<div className="font-medium">{t("ai_daily.title", { title })}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, LayoutGroup, m } from "framer-motion"
|
||||
import { atom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { AnimatePresence, LayoutGroup, m } from "motion/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { CollapseGroup } from "~/components/ui/collapse"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useWheel } from "@use-gesture/react"
|
||||
import { easeOut } from "framer-motion"
|
||||
import { easeOut } from "motion/react"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { DividerVertical } from "@follow/components/ui/divider/Divider.js"
|
|||
import { views } from "@follow/constants"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { clsx } from "@follow/utils/utils"
|
||||
import { m, useAnimationControls } from "framer-motion"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useAudioPlayerAtomSelector } from "~/atoms/player"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { easeOut, m, useScroll, useTransform } from "framer-motion"
|
||||
import { easeOut, m, useScroll, useTransform } from "motion/react"
|
||||
import { Outlet } from "react-router"
|
||||
|
||||
import { HeaderTopReturnBackButton } from "~/components/mobile/button"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormMessage,
|
||||
} from "@follow/components/ui/form/index.js"
|
||||
import { Input } from "@follow/components/ui/input/Input.js"
|
||||
import type { LoginRuntime } from "@follow/shared/auth"
|
||||
import { env } from "@follow/shared/env.desktop"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRef } from "react"
|
||||
|
|
@ -18,8 +19,8 @@ import { toast } from "sonner"
|
|||
import { z } from "zod"
|
||||
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import type { LoginRuntime } from "~/lib/auth"
|
||||
import { loginHandler, signUp, twoFactor } from "~/lib/auth"
|
||||
import { handleSessionChanges } from "~/queries/auth"
|
||||
|
||||
import { TOTPForm } from "../profile/two-factor"
|
||||
|
||||
|
|
@ -71,14 +72,14 @@ export function LoginWithPassword({ runtime }: { runtime: LoginRuntime }) {
|
|||
}
|
||||
}}
|
||||
onSuccess={() => {
|
||||
window.location.reload()
|
||||
handleSessionChanges()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
window.location.reload()
|
||||
handleSessionChanges()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +189,7 @@ function RegisterForm() {
|
|||
callbackURL: "/",
|
||||
fetchOptions: {
|
||||
onSuccess() {
|
||||
window.location.reload()
|
||||
handleSessionChanges()
|
||||
},
|
||||
onError(context) {
|
||||
toast.error(context.error.message)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import {
|
|||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.js"
|
||||
import type { LoginRuntime } from "@follow/shared/auth"
|
||||
import { clsx } from "@follow/utils/utils"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
|
||||
import type { LoginRuntime } from "~/lib/auth"
|
||||
import { loginHandler } from "~/lib/auth"
|
||||
import { useAuthProviders } from "~/queries/users"
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const benefits = [
|
|||
level: 4,
|
||||
benefits: [
|
||||
{ icon: "i-mgc-eye-2-cute-re", text: "More developer attention" },
|
||||
{ icon: "i-mgc-magic-2-cute-re", text: "AI summary for feed", comingSoon: true },
|
||||
{ icon: "i-mgc-ai-cute-re", text: "AI summary for feed", comingSoon: true },
|
||||
{ icon: "i-mgc-rocket-cute-re", text: "Faster feed refresh time" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { from } from "dnum"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import { softSpringPreset } from "~/components/ui/constants/spring"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { RadioCard } from "@follow/components/ui/radio-group/RadioCard.js"
|
||||
import { RadioGroup } from "@follow/components/ui/radio-group/RadioGroup.js"
|
||||
import dayjs from "dayjs"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
|
||||
import { softSpringPreset } from "~/components/ui/constants/spring"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
import { FeedViewType, UserRole } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { cn, getOS } from "@follow/utils/utils"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { toggleShowAISummaryOnce } from "~/atoms/ai-summary"
|
||||
import { toggleShowAITranslationOnce } from "~/atoms/ai-translation"
|
||||
import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player"
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import {
|
||||
getShowSourceContent,
|
||||
toggleShowSourceContent,
|
||||
useSourceContentModal,
|
||||
} from "~/atoms/source-content"
|
||||
import { useUserRole } from "~/atoms/user"
|
||||
import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
|
||||
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import { useActivationModal } from "~/modules/activation"
|
||||
import { useGalleryModal } from "~/modules/entry-content/hooks"
|
||||
import { useTipModal } from "~/modules/wallet/hooks"
|
||||
|
|
@ -100,6 +104,8 @@ export const useRegisterEntryCommands = () => {
|
|||
const role = useUserRole()
|
||||
const presentActivationModal = useActivationModal()
|
||||
|
||||
const voice = useGeneralSettingKey("voice")
|
||||
|
||||
useRegisterFollowCommand([
|
||||
{
|
||||
id: COMMAND_ID.entry.tip,
|
||||
|
|
@ -259,12 +265,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.share,
|
||||
label: t("entry_actions.share"),
|
||||
icon:
|
||||
getOS() === "macOS" ? (
|
||||
<i className="i-mgc-share-3-cute-re" />
|
||||
) : (
|
||||
<i className="i-mgc-share-forward-cute-re" />
|
||||
),
|
||||
icon: <i className="i-mgc-share-forward-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry || !entry.entries.url) {
|
||||
|
|
@ -313,6 +314,43 @@ export const useRegisterEntryCommands = () => {
|
|||
openGalleryModal(entryId)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.tts,
|
||||
label: t("entry_content.header.play_tts"),
|
||||
icon: <i className="i-mgc-voice-cute-re" />,
|
||||
run: async ({ entryId, entryContent }) => {
|
||||
if (getAudioPlayerAtomValue().entryId === entryId) {
|
||||
AudioPlayer.togglePlayAndPause()
|
||||
} else {
|
||||
const filePath = await tipcClient?.tts({
|
||||
id: entryId,
|
||||
text: parseHtml(entryContent).toText(),
|
||||
voice,
|
||||
})
|
||||
if (filePath) {
|
||||
AudioPlayer.mount({
|
||||
type: "audio",
|
||||
entryId,
|
||||
src: `file://${filePath}`,
|
||||
currentTime: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.readability,
|
||||
label: t("entry_content.header.readability"),
|
||||
icon: (props) => (
|
||||
<i className={props?.isActive ? "i-mgc-docment-cute-fi" : "i-mgc-docment-cute-re"} />
|
||||
),
|
||||
run: async ({ entryId, entryUrl }) => {
|
||||
return toggleEntryReadability({
|
||||
id: entryId,
|
||||
url: entryUrl,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
useRegisterFollowCommand(
|
||||
|
|
@ -320,7 +358,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.toggleAISummary,
|
||||
label: t("entry_actions.toggle_ai_summary"),
|
||||
icon: <i className="i-mgc-magic-2-cute-re" />,
|
||||
icon: <i className="i-mgc-ai-cute-re" />,
|
||||
run: () => {
|
||||
if (role === UserRole.Trial) {
|
||||
presentActivationModal()
|
||||
|
|
@ -332,7 +370,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.toggleAITranslation,
|
||||
label: t("entry_actions.toggle_ai_translation"),
|
||||
icon: <i className="i-mgc-translate-2-cute-re" />,
|
||||
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
|
||||
run: () => {
|
||||
if (role === UserRole.Trial) {
|
||||
presentActivationModal()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const COMMAND_ID = {
|
|||
toggleAITranslation: "entry:toggle-ai-translation",
|
||||
exportAsPDF: "entry:export-as-pdf",
|
||||
imageGallery: "entry:image-gallery",
|
||||
tts: "entry:tts",
|
||||
readability: "entry:ability",
|
||||
},
|
||||
integration: {
|
||||
saveToEagle: "integration:save-to-eagle",
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ import { useTranslation } from "react-i18next"
|
|||
import { toast } from "sonner"
|
||||
|
||||
import { getReadabilityContent, getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability"
|
||||
import { useIntegrationSettingKey } from "~/atoms/settings/integration"
|
||||
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 { parseHtml } from "~/lib/parse-html"
|
||||
import { queryClient } from "~/lib/query-client"
|
||||
import { Queries } from "~/queries"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { useEntryStore } from "~/store/entry"
|
||||
|
||||
|
|
@ -464,29 +467,6 @@ const useRegisterCuboxCommands = () => {
|
|||
const enableCuboxAutoMemo = useIntegrationSettingKey("enableCuboxAutoMemo")
|
||||
const cuboxAvailable = enableCubox && !!cuboxToken
|
||||
|
||||
const buildUrlRequestBody = (entry: FlatEntryModel) => {
|
||||
return {
|
||||
type: "url",
|
||||
content: entry.entries.url || "",
|
||||
title: entry.entries.title || "",
|
||||
description: entry.entries.description || "",
|
||||
tags: [],
|
||||
folder: "",
|
||||
}
|
||||
}
|
||||
|
||||
const buildMemoRequestBody = (entry: FlatEntryModel, selectedText: string) => {
|
||||
return {
|
||||
type: "memo",
|
||||
content: selectedText,
|
||||
title: entry.entries.title || "",
|
||||
description: entry.entries.description || "",
|
||||
tags: [],
|
||||
folder: "",
|
||||
source_url: entry.entries.url,
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!cuboxAvailable
|
||||
? []
|
||||
|
|
@ -539,3 +519,42 @@ const useRegisterCuboxCommands = () => {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
const getDescription = (entry: FlatEntryModel) => {
|
||||
const actionLanguage = getActionLanguage()
|
||||
const { saveSummaryAsDescription } = getIntegrationSettings()
|
||||
|
||||
if (!saveSummaryAsDescription) {
|
||||
return entry.entries.description || ""
|
||||
}
|
||||
const summary = queryClient
|
||||
.getQueriesData({
|
||||
queryKey: Queries.ai.summary({ entryId: entry.entries.id, language: actionLanguage }).key,
|
||||
})
|
||||
.at(0)
|
||||
?.at(1) as string | undefined
|
||||
return summary || entry.entries.description || ""
|
||||
}
|
||||
|
||||
const buildUrlRequestBody = (entry: FlatEntryModel) => {
|
||||
return {
|
||||
type: "url",
|
||||
content: entry.entries.url || "",
|
||||
title: entry.entries.title || "",
|
||||
description: getDescription(entry),
|
||||
tags: [],
|
||||
folder: "",
|
||||
}
|
||||
}
|
||||
|
||||
const buildMemoRequestBody = (entry: FlatEntryModel, selectedText: string) => {
|
||||
return {
|
||||
type: "memo",
|
||||
content: selectedText,
|
||||
title: entry.entries.title || "",
|
||||
description: getDescription(entry),
|
||||
tags: [],
|
||||
folder: "",
|
||||
source_url: entry.entries.url,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,16 @@ export type ImageGalleryCommand = Command<{
|
|||
fn: ({ entryId }) => void
|
||||
}>
|
||||
|
||||
export type TTSCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.tts
|
||||
fn: ({ entryId, entryContent }) => void
|
||||
}>
|
||||
|
||||
export type ReadabilityCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.readability
|
||||
fn: ({ entryId, entryUrl }) => void
|
||||
}>
|
||||
|
||||
export type EntryCommand =
|
||||
| TipCommand
|
||||
| StarCommand
|
||||
|
|
@ -84,6 +94,8 @@ export type EntryCommand =
|
|||
| ToggleAISummaryCommand
|
||||
| ToggleAITranslationCommand
|
||||
| ImageGalleryCommand
|
||||
| TTSCommand
|
||||
| ReadabilityCommand
|
||||
|
||||
// Settings commands
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export const useRegisterCommandEffect = (
|
|||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(options)) {
|
||||
return registerCommand(options)
|
||||
const unsubscribe = registerCommand(options)
|
||||
return () => unsubscribe()
|
||||
}
|
||||
|
||||
const unsubscribes = options.map((option) => registerCommand(option))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const entryItemInMore = new Set<string>([
|
|||
COMMAND_ID.entry.openInBrowser,
|
||||
COMMAND_ID.entry.exportAsPDF,
|
||||
COMMAND_ID.entry.read,
|
||||
COMMAND_ID.entry.tts,
|
||||
])
|
||||
|
||||
export const DEFAULT_ACTION_ORDER: ToolbarActionOrder = {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import { getBackgroundGradient } from "@follow/utils/color"
|
|||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { repository } from "@pkg"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { m } from "framer-motion"
|
||||
import { produce } from "immer"
|
||||
import { atom, useAtomValue, useStore } from "jotai"
|
||||
import { m } from "motion/react"
|
||||
import type { ChangeEvent, FC } from "react"
|
||||
import { memo, useCallback, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useRefValue } from "@follow/hooks"
|
|||
import { clsx } from "@follow/utils/utils"
|
||||
import type { RenderComponentProps } from "masonic"
|
||||
import { useInfiniteLoader } from "masonic"
|
||||
import type { FC } from "react"
|
||||
import type { FC, ReactNode } from "react"
|
||||
import {
|
||||
createContext,
|
||||
startTransition,
|
||||
|
|
@ -228,6 +228,15 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
|
|||
onRender={handleRender}
|
||||
itemKey={itemKey}
|
||||
/>
|
||||
{props.Footer ? (
|
||||
typeof props.Footer === "function" ? (
|
||||
<div className="mb-4">
|
||||
<props.Footer />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">{props.Footer}</div>
|
||||
)
|
||||
) : null}
|
||||
</FirstScreenReadyContext.Provider>
|
||||
</MediaContainerWidthProvider>
|
||||
</MasonryIntersectionContext.Provider>
|
||||
|
|
@ -269,6 +278,7 @@ interface MasonryProps {
|
|||
data: string[]
|
||||
endReached: () => any
|
||||
hasNextPage: boolean
|
||||
Footer?: FC | ReactNode
|
||||
}
|
||||
|
||||
const LoadingSkeletonItem = () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ActionButton } from "@follow/components/ui/button/index.js"
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { cloneElement, memo, useCallback, useId, useMemo, useRef, useState } from "react"
|
||||
import { Trans } from "react-i18next"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export const EntryColumnGrid: FC<EntryListProps> = (props) => {
|
|||
hasNextPage={hasNextPage}
|
||||
endReached={fetchNextPage}
|
||||
data={entriesIds}
|
||||
Footer={props.Footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const DailyReportButton: FC = () => {
|
|||
}}
|
||||
tooltip={t("entry_list_header.daily_report")}
|
||||
>
|
||||
<i className="i-mgc-magic-2-cute-re" />
|
||||
<i className="i-mgc-ai-cute-re" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LRUCache } from "@follow/utils/lru-cache"
|
|||
import { clsx } from "@follow/utils/utils"
|
||||
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { HTMLMotionProps } from "framer-motion"
|
||||
import type { HTMLMotionProps } from "motion/react"
|
||||
import type { FC, MutableRefObject, ReactNode } from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function ListItem({
|
|||
title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "",
|
||||
description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "",
|
||||
}
|
||||
}, [settingWideMode])
|
||||
}, [settingWideMode, simple, translation?.description, translation?.title])
|
||||
|
||||
const audioAttachment = useMemo(() => {
|
||||
return entry?.entries?.attachments?.find((a) => a.mime_type?.startsWith("audio") && a.url)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export const EntryTranslation: Component<{
|
|||
source?: string | null
|
||||
target?: string
|
||||
isHTML?: boolean
|
||||
}> = ({ source, target, className, isHTML }) => {
|
||||
inline?: boolean
|
||||
}> = ({ source, target, className, isHTML, inline = true }) => {
|
||||
const nextTarget = useMemo(() => {
|
||||
if (!target || source === target) {
|
||||
return ""
|
||||
|
|
@ -19,35 +20,24 @@ export const EntryTranslation: Component<{
|
|||
return null
|
||||
}
|
||||
|
||||
const SourceTag = inline ? "span" : "p"
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isHTML ? (
|
||||
<>
|
||||
{nextTarget && (
|
||||
<>
|
||||
<HTML
|
||||
as="div"
|
||||
className={cn("prose dark:prose-invert align-middle", className)}
|
||||
noMedia
|
||||
>
|
||||
{nextTarget}
|
||||
</HTML>
|
||||
<i className="i-mgc-translate-2-cute-re mb-4 mt-1 align-middle" />
|
||||
</>
|
||||
)}
|
||||
<HTML as="div" className={cn("prose dark:prose-invert align-middle", className)} noMedia>
|
||||
{source}
|
||||
</HTML>
|
||||
</>
|
||||
<HTML as="div" className={cn("prose dark:prose-invert", className)} noMedia>
|
||||
{nextTarget || source}
|
||||
</HTML>
|
||||
) : (
|
||||
<div className={cn("inline align-middle", className)}>
|
||||
{nextTarget && (
|
||||
<div className={cn(inline && "inline align-middle", className)}>
|
||||
{nextTarget && inline && (
|
||||
<>
|
||||
<span className="align-middle">{nextTarget}</span>
|
||||
<i className="i-mgc-translate-2-cute-re mr-2 align-middle" />
|
||||
<i className="i-mgc-translate-2-ai-cute-re mr-2 align-middle" />
|
||||
</>
|
||||
)}
|
||||
<span className="align-middle">{source}</span>
|
||||
<SourceTag className={cn(inline && "align-middle")}>{source}</SourceTag>
|
||||
{nextTarget && !inline && <p>{nextTarget}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cn } from "@follow/utils/utils"
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useShowAISummary } from "~/atoms/ai-summary"
|
||||
import { useEntryIsInReadabilitySuccess } from "~/atoms/readability"
|
||||
import { useActionLanguage } from "~/atoms/settings/general"
|
||||
import { CopyButton } from "~/components/ui/button/CopyButton"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
|
|
@ -12,12 +13,14 @@ import { useEntry } from "~/store/entry"
|
|||
export function AISummary({ entryId }: { entryId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const entry = useEntry(entryId)
|
||||
const isInReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
|
||||
const showAISummary = useShowAISummary(entry)
|
||||
const actionLanguage = useActionLanguage()
|
||||
const summary = useAuthQuery(
|
||||
Queries.ai.summary({
|
||||
entryId,
|
||||
language: actionLanguage,
|
||||
target: isInReadabilitySuccess ? "readabilityContent" : "content",
|
||||
}),
|
||||
{
|
||||
enabled: showAISummary,
|
||||
|
|
@ -60,7 +63,7 @@ export function AISummary({ entryId }: { entryId: string }) {
|
|||
<div className="center relative">
|
||||
<i
|
||||
className={cn(
|
||||
"i-mgc-magic-2-cute-re text-lg",
|
||||
"i-mgc-ai-cute-re text-lg",
|
||||
summary.isLoading
|
||||
? "text-purple-500/70 dark:text-purple-400/70"
|
||||
: "text-purple-600 dark:text-purple-400",
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/index.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import type { CombinedEntryModel } from "@follow/models/types"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { noop } from "foxact/noop"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player"
|
||||
import {
|
||||
isInReadability,
|
||||
ReadabilityStatus,
|
||||
useEntryInReadabilityStatus,
|
||||
} from "~/atoms/readability"
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
|
||||
export const ElectronAdditionActions = IN_ELECTRON
|
||||
? ({
|
||||
view = FeedViewType.Articles,
|
||||
entry,
|
||||
}: {
|
||||
view: FeedViewType
|
||||
entry?: FlatEntryModel | null
|
||||
}) => {
|
||||
const entryReadabilityStatus = useEntryInReadabilityStatus(entry?.entries.id)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const feed = useFeedById(entry?.feedId)
|
||||
|
||||
const populatedEntry = useMemo(() => {
|
||||
if (!entry) return null
|
||||
if (!feed) return null
|
||||
return {
|
||||
...entry,
|
||||
feeds: feed!,
|
||||
} as CombinedEntryModel
|
||||
}, [entry, feed])
|
||||
|
||||
const readabilityToggle = useEntryReadabilityToggle({
|
||||
id: populatedEntry?.entries.id ?? "",
|
||||
url: populatedEntry?.entries.url ?? "",
|
||||
})
|
||||
|
||||
const [ttsLoading, setTtsLoading] = useState(false)
|
||||
const voice = useGeneralSettingKey("voice")
|
||||
|
||||
if (!populatedEntry) return null
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "tts",
|
||||
name: t("entry_content.header.play_tts"),
|
||||
shortcut: shortcuts.entry.tts.key,
|
||||
className: ttsLoading ? "i-mgc-loading-3-cute-re animate-spin" : "i-mgc-voice-cute-re",
|
||||
|
||||
disabled: !populatedEntry.entries.content,
|
||||
onClick: async () => {
|
||||
if (ttsLoading) return
|
||||
if (!populatedEntry.entries.content) return
|
||||
setTtsLoading(true)
|
||||
if (getAudioPlayerAtomValue().entryId === populatedEntry.entries.id) {
|
||||
AudioPlayer.togglePlayAndPause()
|
||||
} else {
|
||||
const filePath = await tipcClient?.tts({
|
||||
id: populatedEntry.entries.id,
|
||||
text: parseHtml(populatedEntry.entries.content).toText(),
|
||||
voice,
|
||||
})
|
||||
if (filePath) {
|
||||
AudioPlayer.mount({
|
||||
type: "audio",
|
||||
entryId: populatedEntry.entries.id,
|
||||
src: `file://${filePath}`,
|
||||
currentTime: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
setTtsLoading(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("entry_content.header.readability"),
|
||||
className: cn(
|
||||
isInReadability(entryReadabilityStatus)
|
||||
? `i-mgc-docment-cute-fi`
|
||||
: `i-mgc-docment-cute-re`,
|
||||
entryReadabilityStatus === ReadabilityStatus.WAITING ? `animate-pulse` : "",
|
||||
),
|
||||
key: "readability",
|
||||
hide: views[view]!.wideMode || !populatedEntry.entries.url,
|
||||
active: isInReadability(entryReadabilityStatus),
|
||||
onClick: readabilityToggle,
|
||||
},
|
||||
]
|
||||
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => (
|
||||
<ActionButton
|
||||
disabled={item.disabled}
|
||||
icon={<i className={item.className} />}
|
||||
active={item.active}
|
||||
shortcut={item.shortcut}
|
||||
onClick={item.onClick}
|
||||
tooltip={item.name}
|
||||
key={item.name}
|
||||
/>
|
||||
))}
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
: noop
|
||||
|
|
@ -8,8 +8,16 @@ import { COMMAND_ID } from "~/modules/command/commands/id"
|
|||
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
|
||||
import { useEntry } from "~/store/entry/hooks"
|
||||
|
||||
export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
|
||||
const { mainAction: actionConfigs } = useSortedEntryActions({ entryId, view })
|
||||
export const EntryHeaderActions = ({
|
||||
entryId,
|
||||
view,
|
||||
compact,
|
||||
}: {
|
||||
entryId: string
|
||||
view?: FeedViewType
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const { mainAction: actionConfigs } = useSortedEntryActions({ entryId, view, compact })
|
||||
const entry = useEntry(entryId)
|
||||
|
||||
const hasModal = useHasModal()
|
||||
|
|
@ -31,6 +39,8 @@ export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?:
|
|||
onClick={config.onClick}
|
||||
shortcut={config.shortcut}
|
||||
clickableDisabled={config.disabled}
|
||||
tooltipDefaultOpen={config.notice}
|
||||
id={`${config.entryId}/${config.id}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { getNameInitials } from "@follow/utils/cjk"
|
||||
import { m } from "framer-motion"
|
||||
import { m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { Target, TargetAndTransition } from "framer-motion"
|
||||
import { m } from "framer-motion"
|
||||
import type { Target, TargetAndTransition } from "motion/react"
|
||||
import { m } from "motion/react"
|
||||
|
||||
import { useIsZenMode } from "~/atoms/settings/ui"
|
||||
import { useAsRead } from "~/hooks/biz/useAsRead"
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
|||
source={entry.entries.title}
|
||||
target={translation.data?.title}
|
||||
className="inline-block select-text hyphens-auto duration-200"
|
||||
inline={false}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useShowSourceContent } from "~/atoms/source-content"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useEntry } from "~/store/entry/hooks"
|
||||
|
||||
import { ElectronAdditionActions } from "./actions/electron-actions"
|
||||
import { EntryHeaderActions } from "./actions/header-actions"
|
||||
import { MoreActions } from "./actions/more-actions"
|
||||
import { useEntryContentScrollToTop, useEntryTitleMeta } from "./atoms"
|
||||
|
|
@ -71,9 +70,7 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
|
|||
</div>
|
||||
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-2">
|
||||
{!compact && <ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />}
|
||||
|
||||
<EntryHeaderActions entryId={entry.entries.id} view={view} />
|
||||
<EntryHeaderActions entryId={entry.entries.id} view={view} compact={compact} />
|
||||
<MoreActions entryId={entry.entries.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { RootPortal } from "@follow/components/ui/portal/index.js"
|
|||
import { findElementInShadowDOM } from "@follow/utils/dom"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { DismissableLayer } from "@radix-ui/react-dismissable-layer"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { RemoveScroll } from "react-remove-scroll"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDanger
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
import type { FeedModel, InboxModel } from "@follow/models/types"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { ErrorBoundary } from "@sentry/react"
|
||||
import * as React from "react"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
|
||||
import { useEntryIsInReadability } from "~/atoms/readability"
|
||||
import {
|
||||
useEntryIsInReadability,
|
||||
useEntryIsInReadabilitySuccess,
|
||||
useEntryReadabilityContent,
|
||||
} from "~/atoms/readability"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { ShadowDOM } from "~/components/common/ShadowDOM"
|
||||
import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal"
|
||||
|
|
@ -35,7 +38,7 @@ import {
|
|||
ContainerToc,
|
||||
NoContent,
|
||||
ReadabilityAutoToggleEffect,
|
||||
ReadabilityContent,
|
||||
ReadabilityNotice,
|
||||
RenderError,
|
||||
TitleMetaHandler,
|
||||
ViewSourceContentAutoToggleEffect,
|
||||
|
|
@ -62,11 +65,13 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
staleTime: 300_000,
|
||||
},
|
||||
)
|
||||
const readabilityContent = useEntryReadabilityContent(entryId)
|
||||
|
||||
const readerFontFamily = useUISettingKey("readerFontFamily")
|
||||
const view = useRouteParamsSelector((route) => route.view)
|
||||
|
||||
const isInReadabilityMode = useEntryIsInReadability(entryId)
|
||||
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
scrollerRef.current?.scrollTo(0, 0)
|
||||
|
|
@ -114,14 +119,21 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
)
|
||||
const customCSS = useUISettingKey("customCSS")
|
||||
|
||||
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
|
||||
const contentTranslated = useEntryTranslation({
|
||||
entry,
|
||||
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
|
||||
})
|
||||
|
||||
const isInPeekModal = useInPeekModal()
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const entryContent = entry?.entries.content ?? data?.entries.content
|
||||
const translatedContent = contentTranslated.data?.content
|
||||
const entryContent = isInReadabilityMode
|
||||
? readabilityContent?.content
|
||||
: (entry?.entries.content ?? data?.entries.content)
|
||||
const translatedContent = isInReadabilityMode
|
||||
? contentTranslated.data?.readabilityContent
|
||||
: contentTranslated.data?.content
|
||||
const content = translatedContent || entryContent
|
||||
|
||||
const isInbox = !!inbox
|
||||
|
|
@ -166,34 +178,31 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
<TitleMetaHandler entryId={entry.entries.id} />
|
||||
<AISummary entryId={entry.entries.id} />
|
||||
<ErrorBoundary fallback={RenderError}>
|
||||
{!isInReadabilityMode ? (
|
||||
<ShadowDOM injectHostStyles={!isInbox}>
|
||||
{!!customCSS && (
|
||||
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
|
||||
)}
|
||||
<EntryContentHTMLRenderer
|
||||
view={view}
|
||||
feedId={feed?.id}
|
||||
entryId={entryId}
|
||||
mediaInfo={mediaInfo}
|
||||
noMedia={noMedia}
|
||||
accessory={contentAccessories}
|
||||
as="article"
|
||||
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
|
||||
style={stableRenderStyle}
|
||||
renderInlineStyle={readerRenderInlineStyle}
|
||||
>
|
||||
{content}
|
||||
</EntryContentHTMLRenderer>
|
||||
</ShadowDOM>
|
||||
) : (
|
||||
<ReadabilityContent entryId={entryId} feedId={feed.id} />
|
||||
)}
|
||||
<ReadabilityNotice entryId={entryId} />
|
||||
<ShadowDOM injectHostStyles={!isInbox}>
|
||||
{!!customCSS && (
|
||||
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
|
||||
)}
|
||||
<EntryContentHTMLRenderer
|
||||
view={view}
|
||||
feedId={feed?.id}
|
||||
entryId={entryId}
|
||||
mediaInfo={mediaInfo}
|
||||
noMedia={noMedia}
|
||||
accessory={contentAccessories}
|
||||
as="article"
|
||||
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
|
||||
style={stableRenderStyle}
|
||||
renderInlineStyle={readerRenderInlineStyle}
|
||||
>
|
||||
{content}
|
||||
</EntryContentHTMLRenderer>
|
||||
</ShadowDOM>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</WrappedElementProvider>
|
||||
|
||||
{entry.settings?.readability && IN_ELECTRON && (
|
||||
{entry.settings?.readability && (
|
||||
<ReadabilityAutoToggleEffect id={entry.entries.id} url={entry.entries.url ?? ""} />
|
||||
)}
|
||||
{entry.settings?.sourceContent && <ViewSourceContentAutoToggleEffect />}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js"
|
|||
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
|
||||
import { WEB_BUILD } from "@follow/shared/constants"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
|
|
@ -17,12 +17,12 @@ import {
|
|||
ReadabilityStatus,
|
||||
setReadabilityStatus,
|
||||
useEntryInReadabilityStatus,
|
||||
useEntryIsInReadability,
|
||||
useEntryReadabilityContent,
|
||||
} from "~/atoms/readability"
|
||||
import { enableShowSourceContent } from "~/atoms/source-content"
|
||||
import { Toc } from "~/components/ui/markdown/components/Toc"
|
||||
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
|
||||
import { getNewIssueUrl } from "~/lib/issues"
|
||||
import {
|
||||
useIsSoFWrappedElement,
|
||||
|
|
@ -33,7 +33,6 @@ import { useEntry } from "~/store/entry"
|
|||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
import { EntryContentHTMLRenderer } from "../renderer/html"
|
||||
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
|
||||
|
||||
export interface EntryContentProps {
|
||||
|
|
@ -85,10 +84,13 @@ export const TitleMetaHandler: Component<{
|
|||
return null
|
||||
}
|
||||
|
||||
export const ReadabilityContent = ({ entryId, feedId }: { entryId: string; feedId: string }) => {
|
||||
export const ReadabilityNotice = ({ entryId }: { entryId: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const result = useEntryReadabilityContent(entryId)
|
||||
const view = useRouteParamsSelector((route) => route.view)
|
||||
const isInReadability = useEntryIsInReadability(entryId)
|
||||
if (!isInReadability) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grow">
|
||||
|
|
@ -103,16 +105,6 @@ export const ReadabilityContent = ({ entryId, feedId }: { entryId: string; feedI
|
|||
<span className="text-sm">{t("entry_content.fetching_content")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EntryContentHTMLRenderer
|
||||
view={view}
|
||||
feedId={feedId}
|
||||
entryId={entryId}
|
||||
as="article"
|
||||
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold hyphens-auto"
|
||||
>
|
||||
{result?.content ?? ""}
|
||||
</EntryContentHTMLRenderer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -134,12 +126,7 @@ export const NoContent: FC<{
|
|||
{(WEB_BUILD || status === ReadabilityStatus.FAILURE) && (
|
||||
<span>{t("entry_content.no_content")}</span>
|
||||
)}
|
||||
{WEB_BUILD && (
|
||||
<div>
|
||||
<span>{t("entry_content.web_app_notice")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!sourceContent && url && IN_ELECTRON && <ReadabilityAutoToggleEffect url={url} id={id} />}
|
||||
{!sourceContent && url && <ReadabilityAutoToggleEffect url={url} id={id} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -159,10 +146,6 @@ export const ViewSourceContentAutoToggleEffect = () => {
|
|||
}
|
||||
|
||||
export const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => {
|
||||
const toggle = useEntryReadabilityToggle({
|
||||
id,
|
||||
url,
|
||||
})
|
||||
const onceRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -171,9 +154,9 @@ export const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: stri
|
|||
setReadabilityStatus({
|
||||
[id]: ReadabilityStatus.INITIAL,
|
||||
})
|
||||
toggle()
|
||||
toggleEntryReadability({ id, url })
|
||||
}
|
||||
}, [toggle])
|
||||
}, [id, url])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue