{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 1d9cc2e2e..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"\n`,
-)
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 20b12b18e..37ec79bc7 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/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": "无效的邮箱地址",
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 (
-
+
const TOAST_Z_INDEX = 999999999
@@ -16,70 +17,7 @@ export const Toaster = ({ ...props }: ToasterProps) => {
gap={12}
toastOptions={{
unstyled: true,
- classNames: {
- toast: tw`
- group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4 shadow-lg
- backdrop-blur-xl border border-border/50
- bg-material-ultra-thick
- transition-all duration-300 ease-out
- hover:scale-[1.02] hover:shadow-xl
- data-[type=success]:border-green/30 data-[type=success]:bg-green/5
- data-[type=error]:border-red/30 data-[type=error]:bg-red/5
- data-[type=warning]:border-orange/30 data-[type=warning]:bg-orange/5
- data-[type=info]:border-blue/30 data-[type=info]:bg-blue/5
- data-[type=loading]:border-gray/30 data-[type=loading]:bg-gray/5
- max-w-md min-w-[320px]
- font-theme
- `,
- title: tw`
- text-sm font-medium text-text
- leading-tight
- `,
- description: tw`
- text-xs text-text-secondary
- leading-relaxed mt-1
- `,
- content: tw`
- flex-1 min-w-0
- `,
- icon: tw`
- flex-shrink-0 mt-0.5 size-5
- [li[data-type="success"]_&]:text-green
- [li[data-type="error"]_&]:text-red
- [li[data-type="warning"]_&]:text-orange
- [li[data-type="info"]_&]:text-blue
- [li[data-type="loading"]_&]:text-gray
- `,
- actionButton: tw`
- px-2.5 py-1 text-xs font-medium rounded-md
- transition-all duration-200
- focus:outline-none focus:shadow-lg bg-accent
- group-data-[type=success]:bg-green group-data-[type=success]:text-white group-data-[type=success]:hover:bg-green/90 group-data-[type=success]:focus:shadow-green/50
- group-data-[type=error]:bg-red group-data-[type=error]:text-white group-data-[type=error]:hover:bg-red/90 group-data-[type=error]:focus:shadow-red/50
- group-data-[type=warning]:bg-orange group-data-[type=warning]:text-white group-data-[type=warning]:hover:bg-orange/90 group-data-[type=warning]:focus:shadow-orange/50
- group-data-[type=info]:bg-blue group-data-[type=info]:text-white group-data-[type=info]:hover:bg-blue/90 group-data-[type=info]:focus:shadow-blue/50
- group-data-[type=loading]:bg-gray group-data-[type=loading]:text-white group-data-[type=loading]:hover:bg-gray/90 group-data-[type=loading]:focus:shadow-gray/50
- hover:shadow-md active:scale-95
- `,
- cancelButton: tw`
- px-2.5 py-1 text-xs font-medium rounded-md
- bg-fill-secondary text-text-secondary
- hover:bg-fill-tertiary hover:text-text
- transition-colors duration-200
- focus:outline-none focus:ring-2 focus:ring-fill/50 focus:ring-offset-1
- `,
- closeButton: tw`
- absolute top-2 right-2 w-6 h-6 rounded-full
- flex items-center justify-center
- bg-fill text-text-tertiary
- hover:bg-fill-secondary hover:text-text-secondary
- active:bg-fill-tertiary active:text-text
- transition-all duration-200
- opacity-0 group-hover:opacity-100
- focus:outline-none focus:ring-2 focus:ring-accent/50
- focus:opacity-100
- `,
- },
+ classNames: toastStyles,
}}
icons={{
success: ,
diff --git a/packages/internal/components/src/ui/toast/styles.ts b/packages/internal/components/src/ui/toast/styles.ts
new file mode 100644
index 000000000..d0b4160bc
--- /dev/null
+++ b/packages/internal/components/src/ui/toast/styles.ts
@@ -0,0 +1,66 @@
+export const toastStyles = {
+ toast: tw`
+ group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4 shadow-lg
+ backdrop-blur-background border border-border/50
+ bg-material-ultra-thick duration-300 ease-out
+ data-[type=success]:border-green/30 data-[type=success]:bg-green/20
+ data-[type=error]:border-red/30 data-[type=error]:bg-red/20
+ data-[type=warning]:border-orange/30 data-[type=warning]:bg-orange/20
+ data-[type=info]:border-blue/30 data-[type=info]:bg-blue/20
+ data-[type=loading]:border-gray/30 data-[type=loading]:bg-gray/20
+ max-w-md min-w-[320px]
+ font-theme
+ `,
+ title: tw`
+ text-sm font-medium text-text
+ leading-tight
+ `,
+ description: tw`
+ text-xs text-text-secondary
+ leading-relaxed mt-1
+ `,
+ content: tw`
+ flex-1 min-w-0
+ `,
+ icon: tw`
+ flex-shrink-0 mt-0.5 size-5
+ [li[data-type="success"]_&]:text-green
+ [li[data-type="error"]_&]:text-red
+ [li[data-type="warning"]_&]:text-orange
+ [li[data-type="info"]_&]:text-blue
+ [li[data-type="loading"]_&]:text-gray
+ `,
+ actionButton: tw`
+ shrink-0
+ h-6
+ px-2.5 text-xs font-medium rounded-md
+ transition-all duration-200
+ focus:outline-none focus:shadow-lg bg-accent
+ group-data-[type=success]:bg-green group-data-[type=success]:text-white group-data-[type=success]:hover:bg-green/90 group-data-[type=success]:focus:shadow-green/50
+ group-data-[type=error]:bg-red group-data-[type=error]:text-white group-data-[type=error]:hover:bg-red/90 group-data-[type=error]:focus:shadow-red/50
+ group-data-[type=warning]:bg-orange group-data-[type=warning]:text-white group-data-[type=warning]:hover:bg-orange/90 group-data-[type=warning]:focus:shadow-orange/50
+ group-data-[type=info]:bg-blue group-data-[type=info]:text-white group-data-[type=info]:hover:bg-blue/90 group-data-[type=info]:focus:shadow-blue/50
+ group-data-[type=loading]:bg-gray group-data-[type=loading]:text-white group-data-[type=loading]:hover:bg-gray/90 group-data-[type=loading]:focus:shadow-gray/50
+ hover:shadow-md active:scale-95
+ `,
+ cancelButton: tw`
+ h-6
+ px-2.5 text-xs font-medium rounded-md
+ bg-fill-secondary text-text-secondary
+ hover:bg-fill-tertiary hover:text-text
+ transition-colors duration-200
+ focus:outline-none focus:ring-2 focus:ring-fill/50 focus:ring-offset-1
+ `,
+ closeButton: tw`
+ absolute -top-2 -right-2 w-6 h-6 rounded-full
+ flex items-center justify-center
+ text-text
+ border border-border
+ backdrop-blur-background
+ bg-material-ultra-thick
+ transition-all duration-200
+ opacity-0 group-hover:opacity-100
+ focus:outline-none focus:ring-2 focus:ring-accent/50
+ focus:opacity-100
+ `,
+}
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 */}
diff --git a/packages/internal/constants/package.json b/packages/internal/constants/package.json
index f96b544fb..d7996c1fc 100644
--- a/packages/internal/constants/package.json
+++ b/packages/internal/constants/package.json
@@ -1,5 +1,6 @@
{
"name": "@follow/constants",
+ "type": "module",
"private": true,
"sideEffects": false,
"exports": {
diff --git a/packages/internal/database/src/services/collection.ts b/packages/internal/database/src/services/collection.ts
index 364bc54b7..ea19e3821 100644
--- a/packages/internal/database/src/services/collection.ts
+++ b/packages/internal/database/src/services/collection.ts
@@ -11,9 +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)
@@ -27,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/shared/src/hono.ts b/packages/internal/shared/src/hono.ts
index b141ff232..b835feb52 100644
--- a/packages/internal/shared/src/hono.ts
+++ b/packages/internal/shared/src/hono.ts
@@ -20928,7 +20928,18 @@ declare const _routes: hono_hono_base.HonoBase, "/">;
+}, "/trending"> | hono_types.MergeSchemaPath<{
+ "/g": {
+ $post: {
+ input: {
+ json: any;
+ };
+ output: Response;
+ outputFormat: "json";
+ status: hono_utils_http_status.StatusCode;
+ };
+ };
+}, "/data">, "/">;
type AppType = typeof _routes;
export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, listAnalytics, listAnalyticsOpenAPISchema, listAnalyticsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubAnalytics, rsshubAnalyticsOpenAPISchema, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations };
diff --git a/packages/internal/store/src/collection/store.ts b/packages/internal/store/src/collection/store.ts
index b997f32c9..f3462486e 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,42 +106,63 @@ 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 | 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()
}
- 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..555eb57f7 100644
--- a/packages/internal/store/src/entry/store.ts
+++ b/packages/internal/store/src/entry/store.ts
@@ -489,12 +489,12 @@ 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, 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/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..be9bf2e20 100644
--- a/packages/internal/store/src/morph/hono.ts
+++ b/packages/internal/store/src/morph/hono.ts
@@ -146,23 +146,33 @@ 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[]
- 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 {
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({
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