From b358640113899871cfcccd1097c54d69dedd8656 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 23 Jun 2025 22:34:07 +0800 Subject: [PATCH 01/18] release(desktop): release v0.6.0 --- apps/desktop/changelog/0.6.0.md | 29 +++++++++++++++++++++++++++++ apps/desktop/changelog/next.md | 18 ------------------ apps/desktop/package.json | 4 ++-- 3 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/changelog/0.6.0.md diff --git a/apps/desktop/changelog/0.6.0.md b/apps/desktop/changelog/0.6.0.md new file mode 100644 index 000000000..533d8bb13 --- /dev/null +++ b/apps/desktop/changelog/0.6.0.md @@ -0,0 +1,29 @@ +# What's new in v0.6.0 + +## ⚠️ Important + +We’ve made some updates to our `user` database to help keep things running smoothly and securely. Some fields—like your name, email, profile image link, handle, bio, and website—now have maximum character limits: + +- Name: up to 64 characters +- Email: up to 64 characters +- Profile image link: up to 256 characters +- Handle: up to 36 characters +- Bio: up to 256 characters +- Website: up to 256 characters + +If you previously entered information that’s longer than these limits, it will be automatically shortened to fit. Other fields, like your email verification status, two-factor authentication, and social links, are not affected by these changes. + +## Shiny new things + +- 🎉 Say hello to a snazzy new look for your personal profile! We've sprinkled in some cool social attribute settings to spice things up. But hold onto your hats—this is just the appetizer for our grand social vision! 🚀 Stay tuned for more! 😎 +- We have redesigned the pages for Discover, RSSHub, and Actions. These pages are now simpler and easier to use, with a more modern UI. + +## Improvements + +## No longer broken + +🎉 Ta-da! We’ve squashed a bunch of pesky bugs you awesome folks in the community pointed out—high fives all around! 🙌 But if something’s still acting wonky, don’t be shy—holler at us with an issue report, pretty please! 😜 Let’s keep the good vibes rolling! 🚀 + +## Thanks + +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 71b73d1bd..8f5eac449 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -1,29 +1,11 @@ # What's new in vNEXT_VERSION -## ⚠️ Important - -We’ve made some updates to our `user` database to help keep things running smoothly and securely. Some fields—like your name, email, profile image link, handle, bio, and website—now have maximum character limits: - -- Name: up to 64 characters -- Email: up to 64 characters -- Profile image link: up to 256 characters -- Handle: up to 36 characters -- Bio: up to 256 characters -- Website: up to 256 characters - -If you previously entered information that’s longer than these limits, it will be automatically shortened to fit. Other fields, like your email verification status, two-factor authentication, and social links, are not affected by these changes. - ## Shiny new things -- 🎉 Say hello to a snazzy new look for your personal profile! We've sprinkled in some cool social attribute settings to spice things up. But hold onto your hats—this is just the appetizer for our grand social vision! 🚀 Stay tuned for more! 😎 -- We have redesigned the pages for Discover, RSSHub, and Actions. These pages are now simpler and easier to use, with a more modern UI. - ## Improvements ## No longer broken -🎉 Ta-da! We’ve squashed a bunch of pesky bugs you awesome folks in the community pointed out—high fives all around! 🙌 But if something’s still acting wonky, don’t be shy—holler at us with an issue report, pretty please! 😜 Let’s keep the good vibes rolling! 🚀 - ## Thanks Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a0959076b..088a562c9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -94,5 +94,5 @@ "vite-tsconfig-paths": "5.1.4" }, "productName": "Folo", - "mainHash": "c4275ef4fd948d72b930953bd4d4b3a45bb37a20eeab354f78b86b6eca1a19d6" + "mainHash": "a57052b632dc4602db20bd054171a736fca97cdd302764b413ed95838c7067eb" } From 93574a7deb210bb86d09b96229ca3557fc554718 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:01:12 +0800 Subject: [PATCH 02/18] fix: reapply sentry patch (#3993) --- patches/@sentry__electron.patch | 8 ++++---- pnpm-lock.yaml | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/patches/@sentry__electron.patch b/patches/@sentry__electron.patch index 2aa4921df..d7f241ca2 100644 --- a/patches/@sentry__electron.patch +++ b/patches/@sentry__electron.patch @@ -1,8 +1,8 @@ diff --git a/esm/main/ipc.js b/esm/main/ipc.js -index 8edfaf4660734f9120a6d5f5f806688c25a3a026..a692b1c16470b0dbed7c53e1c9aeff8a456f1c5a 100644 +index b5d4478b20cac94e1e1e54900d616286aa9f7733..3639b802c2f4012e943ed2f3d0454fbf5eeda3e3 100644 --- a/esm/main/ipc.js +++ b/esm/main/ipc.js -@@ -111,14 +111,6 @@ function configureProtocol(client, options) { +@@ -134,14 +134,6 @@ function configureProtocol(client, options) { if (app.isReady()) { throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired"); } @@ -18,10 +18,10 @@ index 8edfaf4660734f9120a6d5f5f806688c25a3a026..a692b1c16470b0dbed7c53e1c9aeff8a app .whenReady() diff --git a/main/ipc.js b/main/ipc.js -index 01fc75bdf031b62195504cc0bf7055ebbf15b641..0519242ba5f391f9f618229044948feefeae1de8 100644 +index 6866654fb79361ec90cc9b175a624d80c1e614b4..74494413854aba2acf777d641e356d0200b5a6d1 100644 --- a/main/ipc.js +++ b/main/ipc.js -@@ -111,14 +111,6 @@ function configureProtocol(client, options) { +@@ -134,14 +134,6 @@ function configureProtocol(client, options) { if (electron.app.isReady()) { throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired"); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b88062eea..faf20fb81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ patchedDependencies: hash: 5b5ab1ba36e8c0d7ffee912ebf29c1a18bc101c9c661ceb1bb0bda3deaf4c667 path: patches/@pengx17__electron-forge-maker-appimage.patch '@sentry/electron': - hash: b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb + hash: a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979 path: patches/@sentry__electron.patch daisyui@4.12.24: hash: d393ab1cbfbfcff21dce0796a59c2d8a37e2c6dd634a8ab476cbc67e47b93d9c @@ -356,7 +356,7 @@ importers: version: 1.0.1 '@sentry/electron': specifier: 6.8.0 - version: 6.8.0(patch_hash=b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb) + version: 6.8.0(patch_hash=a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979) builder-util-runtime: specifier: 9.3.1 version: 9.3.1 @@ -21940,7 +21940,7 @@ snapshots: '@sentry/core@9.30.0': {} - '@sentry/electron@6.8.0(patch_hash=b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb)': + '@sentry/electron@6.8.0(patch_hash=a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979)': dependencies: '@sentry/browser': 9.26.0 '@sentry/core': 9.26.0 From db53bdb468054ca43a824a104aa1279d64245b4c Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 24 Jun 2025 13:16:20 +0800 Subject: [PATCH 03/18] fix: ssr server move to esm (#3994) * chore: fix prettier error * refactor: migrate to ES modules and update global constants handling - Changed the global constants definition to use Object.assign for better clarity. - Replaced usage of `isDev` with `__DEV__` throughout the codebase for consistency. - Updated imports to use ES module syntax, including dynamic imports where necessary. - Removed the deprecated `env.ts` file and adjusted related logic in the build scripts. - Ensured compatibility with ES module format in various files, including Vite and API client configurations. Signed-off-by: Innei * refactor(api): migrate handler to ES module syntax - Updated the API handler to use ES module export syntax. - Removed commented-out configuration for Node.js runtime. Signed-off-by: Innei * feat(404): enhance 404 page with animations and improve accessibility - Replaced `motion` components with `m` shorthand for better readability. - Introduced animation controls for various elements on the 404 page to enhance user experience. - Updated the glitch text array for consistency. - Adjusted layout and styling for improved visual presentation. - Removed unused `framer-lazy-feature` file and updated `MotionProvider` to use `domMax` for performance optimization. Signed-off-by: Innei * feat(og): replace Folo text with SVG logo in OGCanvas component - Updated the OGCanvas component to replace the static "Folo" text with a new LogoText SVG component for improved branding. - Added the LogoText function that defines the SVG structure for the logo. Signed-off-by: Innei * refactor(tsdown): update font imports to use dynamic loading - Modified font imports in `tsdown.config.ts` to utilize dynamic loading with `then` for improved performance and error handling. - Introduced a no-operation function to handle the promise resolution of font imports. Signed-off-by: Innei * refactor(404): replace Jotai provider with RootProviders for improved context management - Updated the 404 component to use `RootProviders` instead of the Jotai store provider for better context handling. - Simplified the component structure while maintaining functionality. Signed-off-by: Innei --------- Signed-off-by: Innei --- apps/ssr/api/index.ts | 10 +- apps/ssr/client/components/common/404.tsx | 145 +++++++++++------- apps/ssr/global.ts | 10 +- apps/ssr/index.ts | 17 +- apps/ssr/package.json | 1 + apps/ssr/scripts/cleanup-vercel-build.ts | 11 +- apps/ssr/scripts/prepare-vercel-build.ts | 20 +-- apps/ssr/src/lib/api-client.ts | 5 +- apps/ssr/src/lib/dev-vite.ts | 4 +- apps/ssr/src/lib/env.ts | 1 - apps/ssr/src/lib/og/fonts.ts | 3 + apps/ssr/src/router/global.ts | 14 +- apps/ssr/src/router/og/__base.tsx | 35 ++++- apps/ssr/tsdown.config.ts | 13 +- apps/ssr/vite.config.mts | 5 +- .../components/src/common/MotionProvider.tsx | 5 +- .../components/src/framer-lazy-feature.ts | 1 - packages/internal/constants/package.json | 1 + 18 files changed, 182 insertions(+), 119 deletions(-) delete mode 100644 apps/ssr/src/lib/env.ts delete mode 100644 packages/internal/components/src/framer-lazy-feature.ts diff --git a/apps/ssr/api/index.ts b/apps/ssr/api/index.ts index ff2d9f11d..eaf5e8d32 100644 --- a/apps/ssr/api/index.ts +++ b/apps/ssr/api/index.ts @@ -1,10 +1,8 @@ -const { createApp } = require("../dist/server/index.js") +// @ts-ignore +// eslint-disable-next-line antfu/no-import-dist +import { createApp } from "../dist/server/index.js" -// export const config = { -// runtime: "nodejs", // this is a pre-requisite -// } - -module.exports = async function handler(req: any, res: any) { +export default async function handler(req: any, res: any) { const app = await createApp() await app.ready() app.server.emit("request", req, res) diff --git a/apps/ssr/client/components/common/404.tsx b/apps/ssr/client/components/common/404.tsx index 0c8676743..14d6c2bbf 100644 --- a/apps/ssr/client/components/common/404.tsx +++ b/apps/ssr/client/components/common/404.tsx @@ -1,30 +1,82 @@ import { Header } from "@client/components/layout/header" import { openInFollowApp } from "@client/lib/helper" -import { jotaiStore } from "@client/lib/store" +import { RootProviders } from "@client/providers/root-providers" import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx" import { PoweredByFooter } from "@follow/components/common/PoweredByFooter.jsx" import { Button } from "@follow/components/ui/button/index.jsx" import { useSyncThemeWebApp, useTitle } from "@follow/hooks" -import { Provider } from "jotai" -import { m as motion } from "motion/react" +import { m, useAnimationControls } from "motion/react" import { Fragment, useEffect, useState } from "react" const NotFoundContent = () => { - const [isVisible, setIsVisible] = useState(false) const [glitchText, setGlitchText] = useState("404") const [isGlitching, setIsGlitching] = useState(false) + // Animation controls + const iconControls = useAnimationControls() + const messageControls = useAnimationControls() + const titleControls = useAnimationControls() + const descriptionControls = useAnimationControls() + const buttonsControls = useAnimationControls() + const helpControls = useAnimationControls() + useTitle("404 - Page Not Found") useSyncThemeWebApp() useEffect(() => { - setIsVisible(true) - }, []) + // Start all animations in parallel with their respective delays + iconControls.start({ + scale: 1, + rotate: 0, + transition: { + duration: 0.8, + type: "spring", + stiffness: 100, + delay: 0.2, + }, + }) + + messageControls.start({ + opacity: 1, + y: 0, + transition: { duration: 0.6, delay: 0.4 }, + }) + + titleControls.start({ + opacity: 1, + x: 0, + transition: { duration: 0.5, delay: 0.6 }, + }) + + descriptionControls.start({ + opacity: 1, + x: 0, + transition: { duration: 0.5, delay: 0.8 }, + }) + + buttonsControls.start({ + opacity: 1, + scale: 1, + transition: { duration: 0.5, delay: 1 }, + }) + + helpControls.start({ + opacity: 1, + transition: { duration: 0.5, delay: 1.2 }, + }) + }, [ + iconControls, + messageControls, + titleControls, + descriptionControls, + buttonsControls, + helpControls, + ]) useEffect(() => { if (!isGlitching) return - const glitchTexts = ["404", "40₄", "4Ø4", "404", "4◯4", "4○4", "4𝟘4"] + const glitchTexts = ["404", "40₄", "4Ø4", "404", "4◯4", "4○4", "4𝟘4"] const glitchInterval = setInterval(() => { const randomText = glitchTexts[Math.floor(Math.random() * glitchTexts.length)] @@ -95,21 +147,15 @@ const NotFoundContent = () => { }`}
-
+
{/* 404 Icon with animations */} - - { setIsGlitching(false) }} > - { transition={{ duration: 0.1 }} > {glitchText} - - - + + + {/* Error Message with stagger animation */} - - Page Not Found - - + Sorry, the page you are looking for doesn't exist or has been moved. Please check the URL or return to the homepage to continue browsing. - - + + {/* Action Buttons with hover effects */} - - + - + - + - - + + {/* Additional Help with fade in */} - +

If you believe this is an error, please submit a issue on{" "} - { style={{ display: "inline-block" }} > GitHub - +

-
+ {/* Floating particles effect */}
{Array.from({ length: 6 }).map((_, i) => ( - { export const NotFound = () => { return ( - + - + ) } diff --git a/apps/ssr/global.ts b/apps/ssr/global.ts index 52056c3b6..e3d3d94d4 100644 --- a/apps/ssr/global.ts +++ b/apps/ssr/global.ts @@ -1,5 +1,11 @@ -export const defineGlobalConstants = () => { +Object.assign(globalThis, { + APP_NAME: "Folo", +}) + +try { + void __DEV__ +} catch { Object.assign(globalThis, { - APP_NAME: "Folo", + __DEV__: process.env.NODE_ENV === "development", }) } diff --git a/apps/ssr/index.ts b/apps/ssr/index.ts index b14fce283..3b2e3becf 100644 --- a/apps/ssr/index.ts +++ b/apps/ssr/index.ts @@ -1,5 +1,8 @@ +import "./global" import "./src/lib/load-env" +import os from "node:os" + import middie from "@fastify/middie" import { fastifyRequestContext } from "@fastify/request-context" import { env } from "@follow/shared/env.ssr" @@ -8,16 +11,12 @@ import Fastify from "fastify" import { nanoid } from "nanoid" import { FetchError } from "ofetch" -import { isDev } from "~/lib/env" import { MetaError } from "~/meta-handler" import { staticRoute } from "~/router/static" -import { defineGlobalConstants } from "./global" import { globalRoute } from "./src/router/global" import { ogRoute } from "./src/router/og" -defineGlobalConstants() - const isVercel = process.env.VERCEL === "1" declare module "@fastify/request-context" { @@ -64,7 +63,7 @@ export const createApp = async () => { const finalHost = forwardedHost || host const upstreamEnv = finalHost?.includes("dev") ? "dev" : "prod" - if (!isDev) req.requestContext.set("upstreamEnv", upstreamEnv) + if (!__DEV__) req.requestContext.set("upstreamEnv", upstreamEnv) if (upstreamEnv === "prod") { req.requestContext.set("upstreamOrigin", env.VITE_WEB_PROD_URL || env.VITE_WEB_URL) } else { @@ -74,8 +73,8 @@ export const createApp = async () => { done() }) - if (isDev) { - const devVite = require("./src/lib/dev-vite") + if (__DEV__) { + const devVite = await import("./src/lib/dev-vite") await devVite.registerDevViteServer(app) } @@ -97,11 +96,11 @@ if (!isVercel) { } function getIPAddress() { - const interfaces = require("node:os").networkInterfaces() + const interfaces = os.networkInterfaces() for (const devName in interfaces) { const iface = interfaces[devName] - for (const alias of iface) { + for (const alias of iface || []) { if (alias.family === "IPv4" && alias.address !== "127.0.0.1" && !alias.internal) return alias.address } diff --git a/apps/ssr/package.json b/apps/ssr/package.json index 023a74725..9fd6111c3 100644 --- a/apps/ssr/package.json +++ b/apps/ssr/package.json @@ -1,5 +1,6 @@ { "name": "@follow/ssr", + "type": "module", "private": true, "scripts": { "build": "cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsdown && tsx scripts/cleanup-vercel-build.ts", diff --git a/apps/ssr/scripts/cleanup-vercel-build.ts b/apps/ssr/scripts/cleanup-vercel-build.ts index ecd6e75fc..9c0eb1113 100644 --- a/apps/ssr/scripts/cleanup-vercel-build.ts +++ b/apps/ssr/scripts/cleanup-vercel-build.ts @@ -1,10 +1,7 @@ -import { rmSync, writeFileSync } from "node:fs" -import { resolve } from "node:path" +import { rmSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +const __dirname = dirname(fileURLToPath(import.meta.url)) rmSync(resolve(__dirname, "../.generated"), { recursive: true, force: true }) // restore env file - -writeFileSync( - resolve(__dirname, "../src/lib/env.ts"), - `export const isDev = process.env.NODE_ENV === "development"`, -) diff --git a/apps/ssr/scripts/prepare-vercel-build.ts b/apps/ssr/scripts/prepare-vercel-build.ts index b9a34ec7a..f45eff263 100644 --- a/apps/ssr/scripts/prepare-vercel-build.ts +++ b/apps/ssr/scripts/prepare-vercel-build.ts @@ -1,6 +1,9 @@ import { mkdirSync } from "node:fs" import fs from "node:fs/promises" -import path from "node:path" +import path, { dirname } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) mkdirSync(path.join(__dirname, "../.generated"), { recursive: true }) @@ -12,21 +15,8 @@ async function generateIndexHtmlData() { ) } -async function replaceEnvFile() { - const envFile = await fs.readFile(path.join(__dirname, "../src/lib/env.ts"), "utf-8") - - await fs.writeFile( - path.join(__dirname, "../src/lib/env.ts"), - // For tree shaking - envFile.replace( - `export const isDev = process.env.NODE_ENV === "development"`, - `export const isDev = ${process.env.NODE_ENV === "development"}`, - ), - ) -} - async function main() { - await Promise.all([generateIndexHtmlData(), replaceEnvFile()]) + await generateIndexHtmlData() } main() diff --git a/apps/ssr/src/lib/api-client.ts b/apps/ssr/src/lib/api-client.ts index c5187f36a..db4472ab0 100644 --- a/apps/ssr/src/lib/api-client.ts +++ b/apps/ssr/src/lib/api-client.ts @@ -7,7 +7,6 @@ import { hc } from "hono/client" import { ofetch } from "ofetch" import PKG from "../../../desktop/package.json" -import { isDev } from "./env" const getBaseURL = () => { const req = requestContext.get("req")! @@ -34,7 +33,7 @@ export const createApiFetch = () => { credentials: "include", retry: false, onRequest(context) { - if (isDev) console.info(`request: ${context.request}`) + if (__DEV__) console.info(`request: ${context.request}`) context.options.headers.set("User-Agent", `Folo External Server Api Client/${PKG.version}`) }, @@ -57,7 +56,7 @@ export const createApiClient = () => { headers() { return { "X-App-Version": PKG.version, - "X-App-Dev": isDev ? "1" : "0", + "X-App-Dev": __DEV__ ? "1" : "0", "User-Agent": `Folo External Server Api Client/${PKG.version}`, Cookie: authSessionToken ? `__Secure-better-auth.session_token=${authSessionToken}` : "", } diff --git a/apps/ssr/src/lib/dev-vite.ts b/apps/ssr/src/lib/dev-vite.ts index 84fa90c83..b961318bd 100644 --- a/apps/ssr/src/lib/dev-vite.ts +++ b/apps/ssr/src/lib/dev-vite.ts @@ -1,8 +1,10 @@ -import { resolve } from "node:path" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import type { FastifyInstance } from "fastify" import type { ViteDevServer } from "vite" +const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, "../..") let globalVite: ViteDevServer diff --git a/apps/ssr/src/lib/env.ts b/apps/ssr/src/lib/env.ts deleted file mode 100644 index 2dd9d4a4b..000000000 --- a/apps/ssr/src/lib/env.ts +++ /dev/null @@ -1 +0,0 @@ -export const isDev = process.env.NODE_ENV === "development" diff --git a/apps/ssr/src/lib/og/fonts.ts b/apps/ssr/src/lib/og/fonts.ts index 356eb94a4..4b5440fba 100644 --- a/apps/ssr/src/lib/og/fonts.ts +++ b/apps/ssr/src/lib/og/fonts.ts @@ -1,6 +1,9 @@ import fs from "node:fs" +import { createRequire } from "node:module" import path, { resolve } from "node:path" +const require = createRequire(import.meta.url) + const weights = [ { name: "Thin", diff --git a/apps/ssr/src/router/global.ts b/apps/ssr/src/router/global.ts index dfd0f86de..f93131db3 100644 --- a/apps/ssr/src/router/global.ts +++ b/apps/ssr/src/router/global.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs" -import path, { resolve } from "node:path" +import path, { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import { env } from "@follow/shared/env.ssr" import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" @@ -8,7 +9,6 @@ import { parseHTML } from "linkedom" import { FetchError } from "ofetch" import xss from "xss" -import { isDev } from "~/lib/env" import { NotFoundError } from "~/lib/not-found" import { buildSeoMetaTags } from "~/lib/seo" @@ -17,10 +17,11 @@ import { injectMetaHandler, MetaError } from "../meta-handler" const devHandler = (app: FastifyInstance) => { app.get("*", async (req, reply) => { const url = req.originalUrl + const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, "../..") - const vite = require("../lib/dev-vite").getViteServer() + const vite = await import("../lib/dev-vite").then((m) => m.getViteServer()) try { let template = readFileSync(path.resolve(root, vite.config.root, "index.html"), "utf-8") template = await vite.transformIndexHtml(url, template) @@ -30,14 +31,15 @@ const devHandler = (app: FastifyInstance) => { reply.type("text/html") reply.send(document.toString()) } catch (e) { - vite.ssrFixStacktrace(e) + vite.ssrFixStacktrace(e as Error) reply.code(500).send(e) } }) } const prodHandler = (app: FastifyInstance) => { app.get("*", async (req, reply) => { - const template = require("../../.generated/index.template").default + // @ts-expect-error + const template = await import("../../.generated/index.template").then((m) => m.default) const { document } = parseHTML(template) await safeInjectMetaToTemplate(document, req, reply) @@ -91,7 +93,7 @@ injectEnv({"VITE_API_URL":"${apiUrl}","VITE_EXTERNAL_API_URL":"${apiUrl}","VITE_ ) }) } -export const globalRoute = isDev ? devHandler : prodHandler +export const globalRoute = __DEV__ ? devHandler : prodHandler async function safeInjectMetaToTemplate( document: Document, diff --git a/apps/ssr/src/router/og/__base.tsx b/apps/ssr/src/router/og/__base.tsx index c91d7f53c..ee58debb6 100644 --- a/apps/ssr/src/router/og/__base.tsx +++ b/apps/ssr/src/router/og/__base.tsx @@ -69,11 +69,7 @@ export const OGCanvas = ({ children, seed }: { children: React.ReactNode; seed: {/* Follow Logo */}
- - Folo - +
{/* AI RSS */} @@ -132,6 +128,35 @@ function FollowIcon() { ) } +function LogoText() { + return ( + + + + + + + ) +} + export async function getImageBase64(image: string | null | undefined) { if (!image) { return null diff --git a/apps/ssr/tsdown.config.ts b/apps/ssr/tsdown.config.ts index d641ac0a8..c110d7434 100644 --- a/apps/ssr/tsdown.config.ts +++ b/apps/ssr/tsdown.config.ts @@ -9,12 +9,14 @@ export default defineConfig({ outDir: "dist/server", clean: true, - format: ["cjs"], + format: ["esm"], external: ["lightningcss", "vite"], treeshake: true, + define: { __DEV__: JSON.stringify(process.env.NODE_ENV === "development"), }, + hooks(hooks) { hooks.hook("build:done", async () => { if (process.env.VERCEL !== "1") return @@ -24,10 +26,11 @@ export default defineConfig({ try { const insertCode = `try { -require.resolve("@fontsource/sn-pro") -require.resolve('kose-font') -require.resolve('kose-font/fonts/KosefontP-JP.ttf') -require.resolve('kose-font/fonts/Kosefont-JP.ttf') +const noop = () => {} +import("@fontsource/sn-pro").then(noop) +import('kose-font').then(noop) +import('kose-font/fonts/KosefontP-JP.ttf').then(noop) +import('kose-font/fonts/Kosefont-JP.ttf').then(noop) ${(() => { const require = createRequire(import.meta.url) const fontDepsPath = require.resolve("@fontsource/sn-pro") diff --git a/apps/ssr/vite.config.mts b/apps/ssr/vite.config.mts index 2a13089b6..0373224bd 100644 --- a/apps/ssr/vite.config.mts +++ b/apps/ssr/vite.config.mts @@ -1,4 +1,5 @@ -import { resolve } from "node:path" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import react from "@vitejs/plugin-react" import { codeInspectorPlugin } from "code-inspector-plugin" @@ -13,6 +14,8 @@ const routeBuilderPluginV2 = await tsImport( import.meta.url, ).then((m) => m.default) +const __dirname = dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ resolve: { alias: { diff --git a/packages/internal/components/src/common/MotionProvider.tsx b/packages/internal/components/src/common/MotionProvider.tsx index 898c9a31a..d2fb812d6 100644 --- a/packages/internal/components/src/common/MotionProvider.tsx +++ b/packages/internal/components/src/common/MotionProvider.tsx @@ -1,9 +1,8 @@ -import { LazyMotion, MotionConfig } from "motion/react" +import { domMax, LazyMotion, MotionConfig } from "motion/react" -const loadFeatures = () => import("../framer-lazy-feature").then((res) => res.default) export const MotionProvider = ({ children }: { children: React.ReactNode }) => { return ( - + Date: Tue, 24 Jun 2025 13:41:41 +0800 Subject: [PATCH 04/18] chore: ignore generated route import Signed-off-by: Innei --- apps/desktop/layer/renderer/src/router.tsx | 1 + apps/desktop/layer/renderer/src/router.web.tsx | 1 + apps/ssr/client/router.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/desktop/layer/renderer/src/router.tsx b/apps/desktop/layer/renderer/src/router.tsx index 9c19e10fa..80e8bf822 100644 --- a/apps/desktop/layer/renderer/src/router.tsx +++ b/apps/desktop/layer/renderer/src/router.tsx @@ -5,6 +5,7 @@ import { createBrowserRouter, createHashRouter } from "react-router" import { Component as App } from "./App" import { ErrorElement } from "./components/common/ErrorElement" import { NotFound } from "./components/common/NotFound" +// @ts-ignore import { routes as tree } from "./generated-routes" let routerCreator = diff --git a/apps/desktop/layer/renderer/src/router.web.tsx b/apps/desktop/layer/renderer/src/router.web.tsx index 760b96590..71dae9901 100644 --- a/apps/desktop/layer/renderer/src/router.web.tsx +++ b/apps/desktop/layer/renderer/src/router.web.tsx @@ -4,6 +4,7 @@ import { createBrowserRouter, createHashRouter } from "react-router" import { ErrorElement } from "./components/common/ErrorElement" import { NotFound } from "./components/common/NotFound" +// @ts-ignore import { routes as tree } from "./generated-routes" let routerCreator = diff --git a/apps/ssr/client/router.tsx b/apps/ssr/client/router.tsx index c4a4922bf..e633126ae 100644 --- a/apps/ssr/client/router.tsx +++ b/apps/ssr/client/router.tsx @@ -2,6 +2,7 @@ import { wrapCreateBrowserRouterV7 } from "@sentry/react" import { createBrowserRouter, createHashRouter } from "react-router" import { NotFound } from "./components/common/404" +// @ts-ignore import tree from "./generated-routes" declare global { From e55a400be4f2321f460a6fd4788920e9b1093dbe Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 25 Jun 2025 23:49:10 +0800 Subject: [PATCH 05/18] fix: tooltip spring animation Signed-off-by: Innei --- packages/internal/components/src/ui/tooltip/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/components/src/ui/tooltip/index.tsx b/packages/internal/components/src/ui/tooltip/index.tsx index be4a225e8..1d827ae5d 100644 --- a/packages/internal/components/src/ui/tooltip/index.tsx +++ b/packages/internal/components/src/ui/tooltip/index.tsx @@ -35,7 +35,7 @@ const TooltipContent = ({ {/* https://github.com/radix-ui/primitives/discussions/868 */} From fbd0b3e08fe73950ceed46911859ae792fb9be8f Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:30:59 +0800 Subject: [PATCH 06/18] fix: collection is not up to date (#4009) --- apps/desktop/layer/renderer/src/main.tsx | 8 +- .../src/modules/discover/FeedForm.tsx | 10 +- .../src/modules/discover/ListForm.tsx | 10 +- .../entry-column/hooks/useEntriesByView.ts | 30 +++--- .../subscription-column/FeedCategory.tsx | 7 -- .../layer/renderer/src/queries/entries.ts | 93 +------------------ .../layer/renderer/src/queries/feed.ts | 14 +-- apps/mobile/src/main.tsx | 8 +- .../mobile/src/modules/context-menu/feeds.tsx | 23 ++--- apps/mobile/src/modules/screen/atoms.ts | 19 ++-- .../database/src/services/collection.ts | 12 ++- .../internal/store/src/collection/store.ts | 40 +++++--- packages/internal/store/src/context.ts | 3 + packages/internal/store/src/entry/hooks.ts | 65 ++++++++++--- packages/internal/store/src/entry/store.ts | 11 +-- packages/internal/store/src/entry/types.ts | 4 +- packages/internal/store/src/morph/hono.ts | 2 +- .../internal/store/src/subscription/store.ts | 5 + 18 files changed, 159 insertions(+), 205 deletions(-) diff --git a/apps/desktop/layer/renderer/src/main.tsx b/apps/desktop/layer/renderer/src/main.tsx index 439da6cbb..283988fef 100644 --- a/apps/desktop/layer/renderer/src/main.tsx +++ b/apps/desktop/layer/renderer/src/main.tsx @@ -3,7 +3,11 @@ import "@follow/components/tailwind" import "./styles/main.css" import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants" -import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context" +import { + apiClientSimpleContext, + authClientSimpleContext, + queryClientSimpleContext, +} from "@follow/store/context" import { getOS } from "@follow/utils/utils" import * as React from "react" import ReactDOM from "react-dom/client" @@ -16,10 +20,12 @@ import { setAppIsReady } from "./atoms/app" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" import { initializeApp } from "./initialize" import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts" +import { queryClient } from "./lib/query-client" import { router } from "./router" apiClientSimpleContext.provide(apiClient) authClientSimpleContext.provide(authClient) +queryClientSimpleContext.provide(queryClient) initializeApp().finally(() => { import("./push-notification").then(({ registerWebPushNotifications }) => { diff --git a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx index ab8414db5..6232ea55f 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx @@ -15,6 +15,7 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js" import { Switch } from "@follow/components/ui/switch/index.jsx" import { FeedViewType } from "@follow/constants" import type { EntryModelSimple, FeedAnalyticsModel, FeedModel } from "@follow/models/types" +import { invalidateEntriesQuery } from "@follow/store/entry/hooks" import { useFeedByIdOrUrl } from "@follow/store/feed/hooks" import { useCategories, useSubscriptionByFeedId } from "@follow/store/subscription/hooks" import { subscriptionSyncService } from "@follow/store/subscription/store" @@ -36,7 +37,6 @@ import { getRouteParams } from "~/hooks/biz/useRouteParams" import { useI18n } from "~/hooks/common" import { apiClient } from "~/lib/api-fetch" import { toastFetchError } from "~/lib/error-parser" -import { entries as entriesQuery } from "~/queries/entries" import { feed as feedQuery, useFeedQuery } from "~/queries/feed" import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup" @@ -250,13 +250,7 @@ const FeedInnerForm = ({ }, onSuccess: (data, variables) => { if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) { - entriesQuery - .entries({ - feedId: "all", - view: Number(variables.view), - excludePrivate: true, - }) - .invalidate({ exact: true }) + invalidateEntriesQuery({ views: [Number(variables.view)] }) } if ("unread" in data) { diff --git a/apps/desktop/layer/renderer/src/modules/discover/ListForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/ListForm.tsx index 94ea6c832..1dc27b409 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/ListForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/ListForm.tsx @@ -13,6 +13,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import { Switch } from "@follow/components/ui/switch/index.jsx" import { FeedViewType } from "@follow/constants" import type { ListAnalyticsModel } from "@follow/models/types" +import { invalidateEntriesQuery } from "@follow/store/entry/hooks" import { useListById, usePrefetchListById } from "@follow/store/list/hooks" import type { ListModel } from "@follow/store/list/types" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" @@ -34,7 +35,6 @@ import { useI18n } from "~/hooks/common" import { apiClient } from "~/lib/api-fetch" import { getFetchErrorMessage, toastFetchError } from "~/lib/error-parser" import { getNewIssueUrl } from "~/lib/issues" -import { entries as entriesQuery } from "~/queries/entries" import { useTOTPModalWrapper } from "../profile/hooks" import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup" @@ -224,13 +224,7 @@ const ListInnerForm = ({ }, onSuccess: (data, variables) => { if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) { - entriesQuery - .entries({ - feedId: "all", - view: Number(variables.view), - excludePrivate: true, - }) - .invalidate({ exact: true }) + invalidateEntriesQuery({ views: [Number(variables.view)] }) } if ("unread" in data) { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts index 2c30f8f42..b373f6479 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts @@ -1,6 +1,7 @@ import { views } from "@follow/constants" import { useCollectionEntryList } from "@follow/store/collection/hooks" import { + useEntriesQuery, useEntryIdsByFeedId, useEntryIdsByFeedIds, useEntryIdsByInboxId, @@ -21,7 +22,7 @@ import { useGeneralSettingKey } from "~/atoms/settings/general" import { ROUTE_FEED_PENDING } from "~/constants/app" import { useRouteParams } from "~/hooks/biz/useRouteParams" import { useAuthQuery } from "~/hooks/common" -import { entries, useEntries } from "~/queries/entries" +import { entries } from "~/queries/entries" import { useIsPreviewFeed } from "./useIsPreviewFeed" @@ -45,8 +46,10 @@ const useRemoteEntries = (): UseEntriesReturn => { inboxId, listId, view, - ...(unreadOnly === true && !isPreview && { read: false }), - ...(hidePrivateSubscriptionsInTimeline === true && { excludePrivate: true }), + ...(unreadOnly === true && !isPreview && { unreadOnly: true }), + ...(hidePrivateSubscriptionsInTimeline === true && { + hidePrivateSubscriptionsInTimeline: true, + }), } if (feedId && listId && isBizId(feedId)) { @@ -64,7 +67,7 @@ const useRemoteEntries = (): UseEntriesReturn => { view, hidePrivateSubscriptionsInTimeline, ]) - const query = useEntries(entriesOptions) + const query = useEntriesQuery(entriesOptions) const [fetchedTime, setFetchedTime] = useState() useEffect(() => { @@ -155,15 +158,16 @@ const useLocalEntries = (): UseEntriesReturn => { const allEntries = useEntryStore( useCallback( (state) => { - const ids = showEntriesByView - ? (entryIdsByView ?? []) - : (getEntryIdsFromMultiplePlace( - entryIdsByCollections, - entryIdsByFeedId, - entryIdsByCategory, - entryIdsByListId, - entryIdsByInboxId, - ) ?? []) + const ids = isCollection + ? entryIdsByCollections + : showEntriesByView + ? (entryIdsByView ?? []) + : (getEntryIdsFromMultiplePlace( + entryIdsByFeedId, + entryIdsByCategory, + entryIdsByListId, + entryIdsByInboxId, + ) ?? []) return ids .map((id) => { diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx index 3b30ed0a8..2eaa1fb9e 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx @@ -35,7 +35,6 @@ import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useContextMenu } from "~/hooks/common/useContextMenu" import { createErrorToaster } from "~/lib/error-parser" -import { invalidateEntriesQuery } from "~/queries/entries" import { getPreferredTitle } from "~/store/feed/hooks" import { useModalStack } from "../../components/ui/modal/stacked/hooks" @@ -162,12 +161,6 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego newView: nextView, }) }, - - onSuccess(_data, variables) { - invalidateEntriesQuery({ - views: [view, variables], - }) - }, }) const [isCategoryEditing, setIsCategoryEditing] = useState(false) diff --git a/apps/desktop/layer/renderer/src/queries/entries.ts b/apps/desktop/layer/renderer/src/queries/entries.ts index 10a0773e7..50b17f172 100644 --- a/apps/desktop/layer/renderer/src/queries/entries.ts +++ b/apps/desktop/layer/renderer/src/queries/entries.ts @@ -1,61 +1,9 @@ -import type { FeedViewType } from "@follow/constants" -import { useFeedUnreadIsDirty } from "@follow/store/atoms/feed" -import { entrySyncServices } from "@follow/store/entry/store" - -import { useAuthInfiniteQuery, useAuthQuery } from "~/hooks/common" +import { useAuthQuery } from "~/hooks/common" import { apiClient } from "~/lib/api-fetch" import { defineQuery } from "~/lib/defineQuery" -import { queryClient } from "~/lib/query-client" import { getEntriesParams } from "~/lib/utils" -export function invalidateEntriesQuery({ views }: { views: FeedViewType[] }) { - return queryClient.invalidateQueries({ - predicate: (query) => { - const { queryKey } = query - if (Array.isArray(queryKey) && queryKey[0] === "entries") { - const view = queryKey[2] - return views.includes(view as FeedViewType) - } - return false - }, - }) -} - export const entries = { - entries: ({ - feedId, - inboxId, - listId, - view, - read, - excludePrivate, - limit, - }: { - feedId?: string - inboxId?: string - listId?: string - view?: number - read?: boolean - excludePrivate?: boolean - limit?: number - }) => - defineQuery( - ["entries", inboxId || listId || feedId, view, read, excludePrivate, limit], - async ({ pageParam }) => - entrySyncServices.fetchEntries({ - feedId, - inboxId, - listId, - view, - read, - excludePrivate, - limit, - pageParam: pageParam as string, - }), - { - rootKey: ["entries", inboxId || listId || feedId], - }, - ), preview: (id: string) => defineQuery( ["entries-preview", id], @@ -123,45 +71,6 @@ export const entries = { ), } -const defaultStaleTime = 10 * (60 * 1000) // 10 minutes - -export const useEntries = ({ - feedId, - inboxId, - listId, - view, - read, - excludePrivate, -}: { - feedId?: string - inboxId?: string - listId?: string - view?: number - read?: boolean - excludePrivate?: boolean -}) => { - const fetchUnread = read === false - const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || "") - - return useAuthInfiniteQuery( - entries.entries({ feedId, inboxId, listId, view, read, excludePrivate }), - { - enabled: feedId !== undefined || inboxId !== undefined || listId !== undefined, - getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt, - initialPageParam: undefined, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - // DON'T refetch when the router is pop to previous page - refetchOnMount: fetchUnread && feedUnreadDirty && !history.isPop ? "always" : false, - - staleTime: - // Force refetch unread entries when feed is dirty - // HACK: disable refetch when the router is pop to previous page - history.isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime, - }, - ) -} - export const useEntriesPreview = ({ id }: { id?: string }) => useAuthQuery(entries.preview(id!), { enabled: !!id, diff --git a/apps/desktop/layer/renderer/src/queries/feed.ts b/apps/desktop/layer/renderer/src/queries/feed.ts index 882574f15..f32ecbed5 100644 --- a/apps/desktop/layer/renderer/src/queries/feed.ts +++ b/apps/desktop/layer/renderer/src/queries/feed.ts @@ -12,8 +12,6 @@ import { apiClient } from "~/lib/api-fetch" import { defineQuery } from "~/lib/defineQuery" import { toastFetchError } from "~/lib/error-parser" -import { entries } from "./entries" - type FeedQueryParams = { id?: string; url?: string } export const feed = { @@ -76,15 +74,6 @@ export const useRefreshFeedMutation = (feedId?: string) => useMutation({ mutationKey: ["refreshFeed", feedId], mutationFn: () => apiClient.feeds.refresh.$get({ query: { id: feedId! } }), - - onSuccess() { - if (!feedId) return - entries - .entries({ - feedId: feedId!, - }) - .invalidateRoot() - }, async onError(err) { toastFetchError(err) }, @@ -99,8 +88,7 @@ export const useResetFeed = () => { toastIDRef.current = toast.loading(t("sidebar.feed_actions.resetting_feed")) await apiClient.feeds.reset.$get({ query: { id: feedId } }) }, - onSuccess: (_, feedId) => { - entries.entries({ feedId }).invalidateRoot() + onSuccess: () => { toast.success( t("sidebar.feed_actions.reset_feed_success"), toastIDRef.current ? { id: toastIDRef.current } : undefined, diff --git a/apps/mobile/src/main.tsx b/apps/mobile/src/main.tsx index 7167ada68..d812e0b82 100644 --- a/apps/mobile/src/main.tsx +++ b/apps/mobile/src/main.tsx @@ -1,6 +1,10 @@ import "./global.css" -import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context" +import { + apiClientSimpleContext, + authClientSimpleContext, + queryClientSimpleContext, +} from "@follow/store/context" import { registerRootComponent } from "expo" import { Image } from "expo-image" import { LinearGradient } from "expo-linear-gradient" @@ -20,6 +24,7 @@ import { TabBarPortal } from "./lib/navigation/bottom-tab/TabBarPortal" import { TabRoot } from "./lib/navigation/bottom-tab/TabRoot" import { TabScreen } from "./lib/navigation/bottom-tab/TabScreen" import { RootStackNavigation } from "./lib/navigation/StackNavigation" +import { queryClient } from "./lib/query-client" import { RootProviders } from "./providers" import { IndexTabScreen } from "./screens/(stack)/(tabs)" import { DiscoverTabScreen } from "./screens/(stack)/(tabs)/discover" @@ -33,6 +38,7 @@ global.APP_NAME = "Folo" global.ELECTRON = false apiClientSimpleContext.provide(apiClient) authClientSimpleContext.provide(authClient) +queryClientSimpleContext.provide(queryClient) enableFreeze(true) ;[Image, LinearGradient].forEach((Component) => { diff --git a/apps/mobile/src/modules/context-menu/feeds.tsx b/apps/mobile/src/modules/context-menu/feeds.tsx index 5e0027469..387d2b0d1 100644 --- a/apps/mobile/src/modules/context-menu/feeds.tsx +++ b/apps/mobile/src/modules/context-menu/feeds.tsx @@ -1,9 +1,5 @@ import { FeedViewType } from "@follow/constants" -import { - getInvalidateEntriesQueryPredicate, - useEntriesQuery, - useEntryIdsByFeedId, -} from "@follow/store/entry/hooks" +import { useEntriesQuery, useEntryIdsByFeedId } from "@follow/store/entry/hooks" import { getFeedById } from "@follow/store/feed/getter" import { getSubscriptionById } from "@follow/store/subscription/getter" import { getSubscriptionCategory } from "@follow/store/subscription/hooks" @@ -24,7 +20,6 @@ import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformA import { views } from "@/src/constants/views" import { useNavigation } from "@/src/lib/navigation/hooks" import type { Navigation } from "@/src/lib/navigation/Navigation" -import { queryClient } from "@/src/lib/query-client" import { toast } from "@/src/lib/toast" import { FollowScreen } from "@/src/screens/(modal)/FollowScreen" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -315,17 +310,11 @@ export const SubscriptionFeedCategoryContextMenu = ({ key={`SubContent/${view.name}`} value={isSelected} onSelect={() => { - subscriptionSyncService - .changeCategoryView({ - category, - currentView, - newView: view.view, - }) - .then(() => { - queryClient.invalidateQueries({ - predicate: getInvalidateEntriesQueryPredicate([view.view, currentView]), - }) - }) + subscriptionSyncService.changeCategoryView({ + category, + currentView, + newView: view.view, + }) }} > {t(view.name, { ns: "common" })} diff --git a/apps/mobile/src/modules/screen/atoms.ts b/apps/mobile/src/modules/screen/atoms.ts index bf576e804..2a185d890 100644 --- a/apps/mobile/src/modules/screen/atoms.ts +++ b/apps/mobile/src/modules/screen/atoms.ts @@ -234,15 +234,16 @@ function useLocalEntries(props?: UseEntriesProps): UseEntriesReturn { const allEntries = useEntryStore( useCallback( (state) => { - const ids = showEntriesByView - ? (entryIdsByView ?? []) - : (getEntryIdsFromMultiplePlace( - entryIdsByCollections, - entryIdsByFeedId, - entryIdsByCategory, - entryIdsByListId, - entryIdsByInboxId, - ) ?? []) + const ids = isCollection + ? entryIdsByCollections + : showEntriesByView + ? (entryIdsByView ?? []) + : (getEntryIdsFromMultiplePlace( + entryIdsByFeedId, + entryIdsByCategory, + entryIdsByListId, + entryIdsByInboxId, + ) ?? []) return ids .map((id) => { diff --git a/packages/internal/database/src/services/collection.ts b/packages/internal/database/src/services/collection.ts index 7ee0dab39..ea19e3821 100644 --- a/packages/internal/database/src/services/collection.ts +++ b/packages/internal/database/src/services/collection.ts @@ -11,8 +11,13 @@ class CollectionServiceStatic implements Resetable { await db.delete(collectionsTable).execute() } - async upsertMany(collections: CollectionSchema[]) { + async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) { if (collections.length === 0) return + + if (options?.reset) { + await db.delete(collectionsTable).execute() + } + await db .insert(collectionsTable) .values(collections) @@ -26,6 +31,11 @@ class CollectionServiceStatic implements Resetable { await db.delete(collectionsTable).where(eq(collectionsTable.entryId, entryId)) } + async deleteMany(entryId: string[]) { + if (entryId.length === 0) return + await db.delete(collectionsTable).where(inArray(collectionsTable.entryId, entryId)) + } + getCollectionMany(entryId: string[]) { return db.query.collectionsTable.findMany({ where: inArray(collectionsTable.entryId, entryId) }) } diff --git a/packages/internal/store/src/collection/store.ts b/packages/internal/store/src/collection/store.ts index b997f32c9..af55bdf8c 100644 --- a/packages/internal/store/src/collection/store.ts +++ b/packages/internal/store/src/collection/store.ts @@ -4,7 +4,8 @@ import { CollectionService } from "@follow/database/services/collection" import { apiClient } from "../context" import { getEntry } from "../entry/getter" -import type { Hydratable } from "../internal/base" +import { invalidateEntriesQuery } from "../entry/hooks" +import type { Hydratable, Resetable } from "../internal/base" import { createTransaction, createZustandStore } from "../internal/helper" interface CollectionState { @@ -52,6 +53,8 @@ class CollectionSyncService { }) await tx.run() + + invalidateEntriesQuery({ collection: true }) } async unstarEntry(entryId: string) { @@ -75,20 +78,24 @@ class CollectionSyncService { }) await tx.run() + + invalidateEntriesQuery({ collection: true }) } } -class CollectionActions implements Hydratable { +class CollectionActions implements Hydratable, Resetable { async hydrate() { const collections = await CollectionService.getCollectionAll() collectionActions.upsertManyInSession(collections) } - upsertManyInSession(collections: CollectionSchema[]) { + upsertManyInSession(collections: CollectionSchema[], options?: { reset?: boolean }) { const state = get() - const nextCollections: CollectionState["collections"] = { - ...state.collections, - } + const nextCollections: CollectionState["collections"] = options?.reset + ? {} + : { + ...state.collections, + } collections.forEach((collection) => { if (!collection.entryId) return nextCollections[collection.entryId] = collection @@ -99,18 +106,18 @@ class CollectionActions implements Hydratable { }) } - async upsertMany(collections: CollectionSchema[]) { + async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) { const tx = createTransaction() tx.store(() => { - this.upsertManyInSession(collections) + this.upsertManyInSession(collections, options) }) tx.persist(() => { - return CollectionService.upsertMany(collections) + return CollectionService.upsertMany(collections, options) }) await tx.run() } - async deleteInSession(entryId: string) { + deleteInSession(entryId: string) { const state = useCollectionStore.getState() const nextCollections: CollectionState["collections"] = { ...state.collections, @@ -133,8 +140,17 @@ class CollectionActions implements Hydratable { tx.run() } - reset() { - set(defaultState) + async reset() { + const tx = createTransaction() + tx.store(() => { + set(defaultState) + }) + + tx.persist(() => { + return CollectionService.reset() + }) + + await tx.run() } } diff --git a/packages/internal/store/src/context.ts b/packages/internal/store/src/context.ts index 403f0d526..720efea67 100644 --- a/packages/internal/store/src/context.ts +++ b/packages/internal/store/src/context.ts @@ -1,4 +1,5 @@ import type { AuthClient } from "@follow/shared/auth" +import type { QueryClient } from "@tanstack/react-query" import type { APIClient } from "./types" @@ -27,5 +28,7 @@ function createSimpleContext() { export const apiClientSimpleContext = createSimpleContext() export const authClientSimpleContext = createSimpleContext() +export const queryClientSimpleContext = createSimpleContext() export const apiClient = apiClientSimpleContext.consumer export const authClient = authClientSimpleContext.consumer +export const queryClient = queryClientSimpleContext.consumer diff --git a/packages/internal/store/src/entry/hooks.ts b/packages/internal/store/src/entry/hooks.ts index 14e830d59..dca313b5d 100644 --- a/packages/internal/store/src/entry/hooks.ts +++ b/packages/internal/store/src/entry/hooks.ts @@ -1,26 +1,48 @@ import type { FeedViewType } from "@follow/constants" -import type { Query } from "@tanstack/react-query" import { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { useCallback } from "react" +import { useFeedUnreadIsDirty } from "../atoms/feed" +import { FEED_COLLECTION_LIST } from "../constants/app" +import { queryClient } from "../context" import { getSubscriptionByEntryId } from "../subscription/getter" import { getEntry } from "./getter" import { entrySyncServices, useEntryStore } from "./store" import type { EntryModel, FetchEntriesProps, FetchEntriesPropsSettings } from "./types" -export const getInvalidateEntriesQueryPredicate = (views: FeedViewType[]) => { - return (query: Query) => { - const { queryKey } = query - if (Array.isArray(queryKey) && queryKey[0] === "entries") { - const view = queryKey[4] - return views.includes(view as FeedViewType) - } - return false - } +export const invalidateEntriesQuery = ({ + views, + collection, +}: { + views?: FeedViewType[] + collection?: true +}) => { + return queryClient().invalidateQueries({ + predicate: (query) => { + const { queryKey } = query + if (Array.isArray(queryKey) && queryKey[0] === "entries") { + const feedId = queryKey[1] + const view = queryKey[4] + + const isCollection = queryKey[7] + if (views) { + return views.includes(view as FeedViewType) + } + + if (collection) { + return isCollection === true || feedId === FEED_COLLECTION_LIST + } + } + return false + }, + }) } +const defaultStaleTime = 10 * (60 * 1000) // 10 minutes + export const useEntriesQuery = ( - props?: Omit & FetchEntriesPropsSettings, + props?: Omit & + FetchEntriesPropsSettings, ) => { const { feedId, @@ -29,10 +51,17 @@ export const useEntriesQuery = ( view, limit, feedIdList, + isCollection, unreadOnly, hidePrivateSubscriptionsInTimeline, } = props || {} + const fetchUnread = unreadOnly + const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || "") + + const isPop = + "history" in globalThis && "isPop" in globalThis.history && !!globalThis.history.isPop + return useInfiniteQuery({ queryKey: [ "entries", @@ -40,9 +69,10 @@ export const useEntriesQuery = ( inboxId, listId, view, - unreadOnly, limit, feedIdList, + isCollection, + unreadOnly, hidePrivateSubscriptionsInTimeline, ], queryFn: ({ pageParam }) => @@ -52,11 +82,18 @@ export const useEntriesQuery = ( read: unreadOnly ? false : undefined, excludePrivate: hidePrivateSubscriptionsInTimeline, }), - staleTime: 3 * 60 * 1000, + getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt, initialPageParam: undefined as undefined | string, refetchOnWindowFocus: false, refetchOnReconnect: false, + // DON'T refetch when the router is pop to previous page + refetchOnMount: fetchUnread && feedUnreadDirty && !isPop ? "always" : false, + + staleTime: + // Force refetch unread entries when feed is dirty + // HACK: disable refetch when the router is pop to previous page + isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime, enabled: !!props, }) } @@ -104,7 +141,7 @@ function sortEntryIdsByPublishDate(a: string, b: string) { return entryB.publishedAt.getTime() - entryA.publishedAt.getTime() } -export const useEntryIdsByView = (view: FeedViewType, excludePrivate: boolean) => { +export const useEntryIdsByView = (view: FeedViewType, excludePrivate: boolean | undefined) => { return useEntryStore( useCallback( (state) => { diff --git a/packages/internal/store/src/entry/store.ts b/packages/internal/store/src/entry/store.ts index 79a07adb1..830d57a32 100644 --- a/packages/internal/store/src/entry/store.ts +++ b/packages/internal/store/src/entry/store.ts @@ -489,12 +489,11 @@ class EntrySyncServices { await entryActions.upsertMany(entries) - if (isCollection && res.data) { - if (view === undefined) { - console.error("view is required for collection") - } - const collections = honoMorph.toCollections(res.data, view ?? 0) - await collectionActions.upsertMany(collections) + if (typeof view === "number") { + const collections = honoMorph.toCollections(res.data, view) + await collectionActions.upsertMany(collections, { + reset: params.isCollection && !pageParam, + }) } const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === "feed") diff --git a/packages/internal/store/src/entry/types.ts b/packages/internal/store/src/entry/types.ts index 80fdd74d0..7a60b5a5e 100644 --- a/packages/internal/store/src/entry/types.ts +++ b/packages/internal/store/src/entry/types.ts @@ -16,8 +16,8 @@ export type FetchEntriesProps = { } export type FetchEntriesPropsSettings = { - hidePrivateSubscriptionsInTimeline: boolean - unreadOnly: boolean + hidePrivateSubscriptionsInTimeline?: boolean + unreadOnly?: boolean } export type UseEntriesProps = { diff --git a/packages/internal/store/src/morph/hono.ts b/packages/internal/store/src/morph/hono.ts index 49f7d9b8d..f82937ebd 100644 --- a/packages/internal/store/src/morph/hono.ts +++ b/packages/internal/store/src/morph/hono.ts @@ -146,7 +146,7 @@ class Morph { } toCollections( - data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post, + data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post | undefined, view: FeedViewType, ): CollectionModel[] { if (!data) return [] satisfies CollectionModel[] diff --git a/packages/internal/store/src/subscription/store.ts b/packages/internal/store/src/subscription/store.ts index 1c2ec7d7b..aa93a25e4 100644 --- a/packages/internal/store/src/subscription/store.ts +++ b/packages/internal/store/src/subscription/store.ts @@ -4,6 +4,7 @@ import { tracker } from "@follow/tracker" import { omit } from "es-toolkit" import { apiClient } from "../context" +import { invalidateEntriesQuery } from "../entry/hooks" import { getFeedById } from "../feed/getter" import { feedActions } from "../feed/store" import { inboxActions } from "../inbox/store" @@ -585,6 +586,10 @@ class SubscriptionSyncService { feedIds: folderFeedIds, view: newView, }) + + invalidateEntriesQuery({ + views: [currentView, newView], + }) } async renameCategory({ From 601dfeb8f2ec272e7c64fc5278880f998554cdb3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:15:00 +0800 Subject: [PATCH 07/18] fix: clear collection form entries fetch --- .../internal/store/src/collection/store.ts | 20 ++++++++-- packages/internal/store/src/entry/store.ts | 3 +- packages/internal/store/src/morph/hono.ts | 38 ++++++++++++------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/internal/store/src/collection/store.ts b/packages/internal/store/src/collection/store.ts index af55bdf8c..f3462486e 100644 --- a/packages/internal/store/src/collection/store.ts +++ b/packages/internal/store/src/collection/store.ts @@ -117,25 +117,37 @@ class CollectionActions implements Hydratable, Resetable { await tx.run() } - deleteInSession(entryId: string) { + deleteInSession(entryId: string | string[]) { + const normalizedEntryId = Array.isArray(entryId) ? entryId : [entryId] + const state = useCollectionStore.getState() const nextCollections: CollectionState["collections"] = { ...state.collections, } - delete nextCollections[entryId] + + normalizedEntryId.forEach((id) => { + delete nextCollections[id] + }) set({ ...state, collections: nextCollections, }) } - async delete(entryId: string) { + async delete(entryId: string | string[]) { + const entryIdsInCollection = new Set(Object.keys(get().collections)) + const normalizedEntryId = (Array.isArray(entryId) ? entryId : [entryId]).filter((id) => + entryIdsInCollection.has(id), + ) + + if (normalizedEntryId.length === 0) return + const tx = createTransaction() tx.store(() => { this.deleteInSession(entryId) }) tx.persist(() => { - return CollectionService.delete(entryId) + return CollectionService.deleteMany(normalizedEntryId) }) tx.run() } diff --git a/packages/internal/store/src/entry/store.ts b/packages/internal/store/src/entry/store.ts index 830d57a32..555eb57f7 100644 --- a/packages/internal/store/src/entry/store.ts +++ b/packages/internal/store/src/entry/store.ts @@ -490,10 +490,11 @@ class EntrySyncServices { await entryActions.upsertMany(entries) if (typeof view === "number") { - const collections = honoMorph.toCollections(res.data, view) + const { collections, entryIdsNotInCollections } = honoMorph.toCollections(res.data, view) await collectionActions.upsertMany(collections, { reset: params.isCollection && !pageParam, }) + await collectionActions.delete(entryIdsNotInCollections) } const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === "feed") diff --git a/packages/internal/store/src/morph/hono.ts b/packages/internal/store/src/morph/hono.ts index f82937ebd..be9bf2e20 100644 --- a/packages/internal/store/src/morph/hono.ts +++ b/packages/internal/store/src/morph/hono.ts @@ -148,21 +148,31 @@ class Morph { toCollections( data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post | undefined, view: FeedViewType, - ): CollectionModel[] { - if (!data) return [] satisfies CollectionModel[] - return data - .map((item) => { - if (!item.collections) { - return null - } - return { - createdAt: item.collections.createdAt, - entryId: item.entries.id, - feedId: item.feeds.id, - view, - } satisfies CollectionModel + ): { + collections: CollectionModel[] + entryIdsNotInCollections: string[] + } { + if (!data) return { collections: [], entryIdsNotInCollections: [] } + + const collections: CollectionModel[] = [] + const entryIdsNotInCollections: string[] = [] + for (const item of data) { + if (!item.collections) { + entryIdsNotInCollections.push(item.entries.id) + continue + } + collections.push({ + createdAt: item.collections.createdAt, + entryId: item.entries.id, + feedId: item.feeds.id, + view, }) - .filter((i) => i !== null) + } + + return { + collections, + entryIdsNotInCollections, + } } toEntry(data?: HonoApiClient.Entry_Get | HonoApiClient.Entry_Inbox_Get): EntryModel | null { From a0f3c8907939f1453d5c21addd83b78ba0752d7f Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 26 Jun 2025 11:53:04 +0800 Subject: [PATCH 08/18] feat: update copywriting --- locales/external/en.json | 2 +- locales/external/zh-CN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/external/en.json b/locales/external/en.json index 9183b2895..4bda047f7 100644 --- a/locales/external/en.json +++ b/locales/external/en.json @@ -41,7 +41,7 @@ "login.confirm_password.label": "Confirm Password", "login.continueWith": "Continue with {{provider}}", "login.email": "Email", - "login.enter_token": "If you aren't redirected automatically, copy the token below and paste it in the \"Enter authorization token to continue\" form on the Desktop App.", + "login.enter_token": "If you aren't redirected automatically, copy the token below and paste it in the \"Enter authorization token to continue\" form on the Desktop App (v0.5.0+).", "login.errors.unknown": "Errors Unknown", "login.forget_password.description": "Enter the email address associated with your account and we'll send you an email about how to reset your password.", "login.forget_password.email_invalid": "Invalid email", diff --git a/locales/external/zh-CN.json b/locales/external/zh-CN.json index e8e046983..82638ad88 100644 --- a/locales/external/zh-CN.json +++ b/locales/external/zh-CN.json @@ -41,7 +41,7 @@ "login.confirm_password.label": "确认密码", "login.continueWith": "使用 {{provider}} 登录", "login.email": "邮件地址", - "login.enter_token": "如果未自动重定向,请复制下方令牌并粘贴到桌面应用的“输入授权令牌以继续”表单中。", + "login.enter_token": "如果未自动重定向,请复制下方令牌并粘贴到桌面应用(v0.5.0 以上)的“输入授权令牌以继续”表单中。", "login.errors.unknown": "未知错误", "login.forget_password.description": "请输入与你的帐户关联的邮件地址,我们将向你发送一封关于如何重置密码的邮件。", "login.forget_password.email_invalid": "无效的邮箱地址", From 92d87b1d728f92c4f27e442116497bd9d01d0246 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 26 Jun 2025 14:31:33 +0800 Subject: [PATCH 09/18] refactor: update toast styles and improve component structure - Added toastStyles import to NeedActivationToast and updated button class. - Enhanced AppNotificationContainer's toast message styling. - Refactored Toaster component to utilize centralized toastStyles for consistency. Signed-off-by: Innei --- .../src/hooks/biz/useSubscriptionActions.tsx | 20 +++--- .../layer/renderer/src/lib/api-fetch.ts | 1 + .../activation/NeedActivationToast.tsx | 5 +- .../src/modules/upgrade/container.tsx | 2 +- .../components/src/ui/toast/index.tsx | 66 +----------------- .../components/src/ui/toast/styles.ts | 68 +++++++++++++++++++ 6 files changed, 85 insertions(+), 77 deletions(-) create mode 100644 packages/internal/components/src/ui/toast/styles.ts diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx index 9de2d49dd..66fdff637 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -57,12 +57,12 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } toast.dismiss(toastId) } - const toastId = toast("", { + const toastId = toast.warning("", { duration: 3000, description: , action: { label: ( - + {t("words.undo")} $mod+Z @@ -95,13 +95,15 @@ const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => { preventDefault: true, }) return ( - {title}, - }} - /> + + {title}, + }} + /> + ) } diff --git a/apps/desktop/layer/renderer/src/lib/api-fetch.ts b/apps/desktop/layer/renderer/src/lib/api-fetch.ts index e2f47df07..30a6f95b0 100644 --- a/apps/desktop/layer/renderer/src/lib/api-fetch.ts +++ b/apps/desktop/layer/renderer/src/lib/api-fetch.ts @@ -61,6 +61,7 @@ export const apiFetch = ofetch.create({ { closeButton: true, duration: 10e4, + classNames: { content: tw`w-full`, }, diff --git a/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx b/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx index 1849a62ec..edce2be1e 100644 --- a/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx +++ b/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx @@ -1,3 +1,4 @@ +import { toastStyles } from "@follow/components/ui/toast/styles.js" import { stopPropagation } from "@follow/utils/dom" import { useCallback } from "react" import { useTranslation } from "react-i18next" @@ -13,10 +14,8 @@ export const NeedActivationToast = (props: { dimiss: () => void }) => {
{t("activation.description")}