release(mobile): Release v0.1.6

release(mobile): Release v0.1.6
This commit is contained in:
DIYgod 2025-04-20 11:52:33 +08:00 committed by GitHub
commit 40d47faaec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
355 changed files with 9222 additions and 8627 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

42
.github/workflows/sync.yaml vendored Normal file
View File

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

View File

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

View File

@ -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).
| [![Discord](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/followapp) | Join our Discord server to connect with developers, request features, and receive support. |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import type { Spring } from "framer-motion"
import type { Spring } from "motion/react"
export const reboundPreset: Spring = {
type: "spring",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import type { MotionProps, Target } from "framer-motion"
import type { MotionProps, Target } from "motion/react"
import { microReboundPreset } from "../../constants/spring"

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useDragControls } from "framer-motion"
import { useDragControls } from "motion/react"
import type { PointerEventHandler, RefObject } from "react"
import { useCallback } from "react"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useReducedMotion } from "framer-motion"
import { useReducedMotion } from "motion/react"
import { useUISettingKey } from "~/atoms/settings/ui"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,6 +35,7 @@ export const EntryColumnGrid: FC<EntryListProps> = (props) => {
hasNextPage={hasNextPage}
endReached={fetchNextPage}
data={entriesIds}
Footer={props.Footer}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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