diff --git a/packages/internal/components/src/utils/parse-markdown.tsx b/packages/internal/components/src/utils/parse-markdown.tsx
index 03553de6822..dd3a6950122 100644
--- a/packages/internal/components/src/utils/parse-markdown.tsx
+++ b/packages/internal/components/src/utils/parse-markdown.tsx
@@ -1,4 +1,5 @@
-import "@microflash/remark-callout-directives/themes/github/index.css"
+// @ts-expect-error CSS side-effect import from package exports has no type declaration.
+import "@microflash/remark-callout-directives/theme/github"
import "remark-gh-alerts/styles/github-colors-light.css"
import "remark-gh-alerts/styles/github-colors-dark-media.css"
import "remark-gh-alerts/styles/github-base.css"
diff --git a/packages/internal/components/tsconfig.json b/packages/internal/components/tsconfig.json
index 0e20c46a823..7931be5a0bf 100644
--- a/packages/internal/components/tsconfig.json
+++ b/packages/internal/components/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
- "baseUrl": ".",
"jsx": "preserve",
"declaration": true,
"types": ["@follow/types/react", "@follow/types/global", "vite/client"],
diff --git a/packages/internal/constants/tsconfig.json b/packages/internal/constants/tsconfig.json
index ddd00de10ad..74c03bc257d 100644
--- a/packages/internal/constants/tsconfig.json
+++ b/packages/internal/constants/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
- "baseUrl": ".",
"declaration": true,
"types": ["@follow/types/react", "@follow/types/global", "vite/client"],
"paths": {
diff --git a/packages/internal/database/package.json b/packages/internal/database/package.json
index fc4d2e51bdb..138037fa549 100644
--- a/packages/internal/database/package.json
+++ b/packages/internal/database/package.json
@@ -36,14 +36,13 @@
"@follow/constants": "workspace:*",
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
- "ai": "6.0.85",
- "drizzle-orm": "0.45.1",
- "expo-sqlite": "55.0.15",
- "sqlocal": "npm:@hyoban/sqlocal@0.14.1-fork.4",
+ "drizzle-orm": "0.45.2",
+ "expo-sqlite": "57.0.0",
+ "sqlocal": "npm:@hyoban/sqlocal@0.14.1-fork.10",
"wa-sqlite": "git+https://github.com/rhashimoto/wa-sqlite.git#v1.0.8"
},
"devDependencies": {
"@follow/configs": "workspace:*",
- "drizzle-kit": "0.31.9"
+ "drizzle-kit": "0.31.10"
}
}
diff --git a/packages/internal/database/src/drizzle/0033_shiny_sebastian_shaw.sql b/packages/internal/database/src/drizzle/0033_shiny_sebastian_shaw.sql
index 66618b80adf..f62e70f5a7d 100644
--- a/packages/internal/database/src/drizzle/0033_shiny_sebastian_shaw.sql
+++ b/packages/internal/database/src/drizzle/0033_shiny_sebastian_shaw.sql
@@ -1,6 +1,7 @@
ALTER TABLE `ai_chat` RENAME TO `ai_chat_sessions`;--> statement-breakpoint
ALTER TABLE `ai_chat_sessions` RENAME COLUMN "room_id" TO "id";--> statement-breakpoint
-ALTER TABLE `ai_chat_sessions` ADD `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL;--> statement-breakpoint
+ALTER TABLE `ai_chat_sessions` ADD `updated_at` integer;--> statement-breakpoint
+UPDATE `ai_chat_sessions` SET `updated_at` = COALESCE(`created_at`, unixepoch() * 1000) WHERE `updated_at` IS NULL;--> statement-breakpoint
CREATE INDEX `idx_ai_chat_sessions_updated_at` ON `ai_chat_sessions` (`updated_at`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_ai_chat_messages` (
@@ -16,10 +17,33 @@ CREATE TABLE `__new_ai_chat_messages` (
FOREIGN KEY (`chat_id`) REFERENCES `ai_chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
-INSERT INTO `__new_ai_chat_messages`("id", "chat_id", "role", "rich_text_schema", "created_at", "metadata", "status", "finished_at", "message_parts") SELECT "id", "chat_id", "role", "rich_text_schema", "created_at", "metadata", "status", "finished_at", "message_parts" FROM `ai_chat_messages`;--> statement-breakpoint
+INSERT INTO `__new_ai_chat_messages`("id", "chat_id", "role", "rich_text_schema", "created_at", "metadata", "status", "finished_at", "message_parts")
+SELECT
+ "id",
+ "room_id",
+ CASE
+ WHEN json_valid("message") THEN COALESCE(json_extract("message", '$.role'), 'user')
+ ELSE 'user'
+ END,
+ NULL,
+ "created_at",
+ CASE
+ WHEN json_valid("message") THEN json_extract("message", '$.metadata')
+ ELSE NULL
+ END,
+ 'completed',
+ NULL,
+ CASE
+ WHEN json_valid("message") THEN COALESCE(
+ json_extract("message", '$.parts'),
+ json_array(json_object('type', 'text', 'text', COALESCE(json_extract("message", '$.content'), CAST("message" AS TEXT))))
+ )
+ ELSE json_array(json_object('type', 'text', 'text', CAST("message" AS TEXT)))
+ END
+FROM `ai_chat_messages`;--> statement-breakpoint
DROP TABLE `ai_chat_messages`;--> statement-breakpoint
ALTER TABLE `__new_ai_chat_messages` RENAME TO `ai_chat_messages`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `idx_ai_chat_messages_chat_id_created_at` ON `ai_chat_messages` (`chat_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_ai_chat_messages_status` ON `ai_chat_messages` (`status`);--> statement-breakpoint
-CREATE INDEX `idx_ai_chat_messages_chat_id_role` ON `ai_chat_messages` (`chat_id`,`role`);
\ No newline at end of file
+CREATE INDEX `idx_ai_chat_messages_chat_id_role` ON `ai_chat_messages` (`chat_id`,`role`);
diff --git a/packages/internal/database/src/types.ts b/packages/internal/database/src/types.ts
index 9b5e24bc0d9..41add401c83 100644
--- a/packages/internal/database/src/types.ts
+++ b/packages/internal/database/src/types.ts
@@ -3,5 +3,4 @@ import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"
import type * as schema from "./schemas"
export type DB =
- | BaseSQLiteDatabase<"async", any, typeof schema>
- | BaseSQLiteDatabase<"sync", any, typeof schema>
+ BaseSQLiteDatabase<"async", any, typeof schema> | BaseSQLiteDatabase<"sync", any, typeof schema>
diff --git a/packages/internal/hooks/package.json b/packages/internal/hooks/package.json
index 8de758768f2..0a520811eb2 100644
--- a/packages/internal/hooks/package.json
+++ b/packages/internal/hooks/package.json
@@ -15,10 +15,10 @@
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
- "@tanstack/react-query": "5.90.21",
- "es-toolkit": "1.44.0",
- "foxact": "0.2.52",
- "jotai": "2.17.1",
+ "@tanstack/react-query": "5.101.2",
+ "es-toolkit": "1.49.0",
+ "foxact": "0.3.8",
+ "jotai": "2.20.1",
"sonner": "2.0.7",
"usehooks-ts": "3.1.1"
},
diff --git a/packages/internal/hooks/src/useRefValue.ts b/packages/internal/hooks/src/useRefValue.ts
index 798960c9886..719ca4479d6 100644
--- a/packages/internal/hooks/src/useRefValue.ts
+++ b/packages/internal/hooks/src/useRefValue.ts
@@ -3,7 +3,6 @@ import { useLayoutEffect, useRef } from "react"
export const useRefValue =
(
value: S,
): Readonly<{
- // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
current: S extends Function ? S : Readonly
}> => {
const ref = useRef(value)
diff --git a/packages/internal/hooks/tsconfig.json b/packages/internal/hooks/tsconfig.json
index fa1b1cd9912..070bc18e4fb 100644
--- a/packages/internal/hooks/tsconfig.json
+++ b/packages/internal/hooks/tsconfig.json
@@ -2,7 +2,6 @@
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
"noEmit": true,
- "baseUrl": ".",
"jsx": "preserve",
"declaration": true,
"types": ["@follow/types/react", "@follow/types/global", "vite/client"],
diff --git a/packages/internal/logger/package.json b/packages/internal/logger/package.json
index 49fab07e22a..4f1cd803966 100644
--- a/packages/internal/logger/package.json
+++ b/packages/internal/logger/package.json
@@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "electron-log": "5.4.3"
+ "electron-log": "5.4.4"
},
"devDependencies": {
"@follow/configs": "workspace:*"
diff --git a/packages/internal/logger/tsconfig.json b/packages/internal/logger/tsconfig.json
index 246025b1cfa..dd3d9cc584c 100644
--- a/packages/internal/logger/tsconfig.json
+++ b/packages/internal/logger/tsconfig.json
@@ -2,7 +2,6 @@
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
"noEmit": true,
- "baseUrl": ".",
"jsx": "preserve",
"declaration": true,
"paths": {
diff --git a/packages/internal/models/tsconfig.json b/packages/internal/models/tsconfig.json
index dda8ec819ae..76f12ce277c 100644
--- a/packages/internal/models/tsconfig.json
+++ b/packages/internal/models/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
- "baseUrl": ".",
"jsx": "preserve",
"declaration": true,
"types": ["@follow/types/react", "@follow/types/global", "vite/client"],
diff --git a/packages/internal/shared/package.json b/packages/internal/shared/package.json
index 4b3d2e3712d..432d9a2122f 100644
--- a/packages/internal/shared/package.json
+++ b/packages/internal/shared/package.json
@@ -31,17 +31,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@better-auth/stripe": "1.5.5",
+ "@better-auth/stripe": "1.6.23",
"@electron-toolkit/preload": "3.0.2",
"@electron-toolkit/tsconfig": "2.0.0",
"@follow-app/client-sdk": "catalog:",
"@folo-services/drizzle": "0.1.47",
- "@t3-oss/env-core": "0.13.10",
- "ai": "6.0.85",
- "better-auth": "1.5.5",
- "drizzle-orm": "0.45.1",
+ "@t3-oss/env-core": "0.13.11",
+ "better-auth": "1.6.23",
+ "drizzle-orm": "0.45.2",
"sonner": "2.0.7",
- "stripe": "20.3.1",
- "zod": "3.25.76"
+ "stripe": "22.3.0",
+ "zod": "4.4.3"
}
}
diff --git a/packages/internal/shared/src/auth.ts b/packages/internal/shared/src/auth.ts
index 73f3b463baa..1b686a676ad 100644
--- a/packages/internal/shared/src/auth.ts
+++ b/packages/internal/shared/src/auth.ts
@@ -1,7 +1,11 @@
import { stripeClient } from "@better-auth/stripe/client"
import { IN_ELECTRON } from "@follow/shared"
import type { AuthPlugins } from "@follow-app/client-sdk/auth"
-import type { BetterAuthClientPlugin, BetterFetchOption } from "better-auth/client"
+import type {
+ BetterAuthClientOptions,
+ BetterAuthClientPlugin,
+ BetterFetchOption,
+} from "better-auth/client"
import { createAuthClient } from "better-auth/client"
import {
inferAdditionalFields,
@@ -54,12 +58,19 @@ export const baseAuthPlugins = [
stripeClient({ subscription: true }),
lastLoginMethodClient(),
magicLinkClient(),
-]
+] as const satisfies readonly BetterAuthClientPlugin[]
+
+export type BaseAuthPlugins = [...typeof baseAuthPlugins]
+
+export type FoloAuthClientOptions = Omit<
+ BetterAuthClientOptions,
+ "plugins"
+> & {
+ plugins: [...BaseAuthPlugins, ...ExtraPlugins]
+}
export type AuthClient = ReturnType<
- typeof createAuthClient<{
- plugins: [...typeof baseAuthPlugins, ...ExtraPlugins]
- }>
+ typeof createAuthClient>
>
export type LoginRuntime = "browser" | "app"
@@ -74,9 +85,11 @@ export class Auth {
fetchOptions?: BetterFetchOption
},
) {
- this.authClient = createAuthClient({
+ const plugins = [...baseAuthPlugins] as BaseAuthPlugins
+
+ this.authClient = createAuthClient({
baseURL: `${this.options.apiURL}/better-auth`,
- plugins: baseAuthPlugins,
+ plugins,
fetchOptions: {
...this.options.fetchOptions,
credentials: "include",
diff --git a/packages/internal/shared/src/review-prompt.ts b/packages/internal/shared/src/review-prompt.ts
index 0cf889baf73..cc32bcc2838 100644
--- a/packages/internal/shared/src/review-prompt.ts
+++ b/packages/internal/shared/src/review-prompt.ts
@@ -1,10 +1,7 @@
const DAY_IN_MS = 24 * 60 * 60 * 1000
export type ReviewPromptOutcome =
- | "dismissed"
- | "negative_feedback"
- | "positive_store_redirect"
- | "native_request"
+ "dismissed" | "negative_feedback" | "positive_store_redirect" | "native_request"
export interface ReviewPromptState {
firstSeenAt: string | null
diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts
index c05a5a6f73b..f25186b84ce 100644
--- a/packages/internal/shared/src/settings/interface.ts
+++ b/packages/internal/shared/src/settings/interface.ts
@@ -38,15 +38,7 @@ export interface GeneralSettings {
}
export type AccentColor =
- | "orange"
- | "blue"
- | "green"
- | "purple"
- | "pink"
- | "red"
- | "yellow"
- | "gray"
- | string // Allow custom hex colors
+ "orange" | "blue" | "green" | "purple" | "pink" | "red" | "yellow" | "gray" | string // Allow custom hex colors
export interface UISettings {
accentColor: AccentColor
customAccentColor?: string // Store custom color value
diff --git a/packages/internal/shared/tsconfig.json b/packages/internal/shared/tsconfig.json
index 2728ed9fb4f..cb62a039c2c 100644
--- a/packages/internal/shared/tsconfig.json
+++ b/packages/internal/shared/tsconfig.json
@@ -7,7 +7,6 @@
"noImplicitReturns": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
- "baseUrl": ".",
"paths": {
"@shared/*": ["./src/*"],
"@pkg": ["../../package.json"],
diff --git a/packages/internal/store/package.json b/packages/internal/store/package.json
index aab44a9c1f5..78164a20883 100644
--- a/packages/internal/store/package.json
+++ b/packages/internal/store/package.json
@@ -102,7 +102,7 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
- "react": "19.2.0"
+ "react": "19.2.7"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
@@ -113,13 +113,13 @@
"@follow/shared": "workspace:*",
"@follow/tracker": "workspace:*",
"@follow/utils": "workspace:*",
- "@tanstack/react-query": "5.90.21",
+ "@tanstack/react-query": "5.101.2",
"@yornaath/batshit": "0.14.0",
- "es-toolkit": "1.44.0",
- "i18next": "25.8.6",
- "immer": "11.1.4",
- "jotai": "2.17.1",
+ "es-toolkit": "1.49.0",
+ "i18next": "26.3.5",
+ "immer": "11.1.11",
+ "jotai": "2.20.1",
"sf-symbols-typescript": "2.2.0",
- "zustand": "5.0.11"
+ "zustand": "5.0.14"
}
}
diff --git a/packages/internal/store/src/lib/helper.ts b/packages/internal/store/src/lib/helper.ts
index f4b8d4ac41b..336ec7d173f 100644
--- a/packages/internal/store/src/lib/helper.ts
+++ b/packages/internal/store/src/lib/helper.ts
@@ -1,6 +1,7 @@
/**
* @copy from renderer/src/store/utils/helper.ts
*/
+import type { Draft } from "immer"
import { enableMapSet, isDraft, original, produce } from "immer"
import type { StateCreator, StoreApi, UseBoundStore } from "zustand"
import { shallow } from "zustand/shallow"
@@ -78,9 +79,10 @@ export function createImmerSetter(useStore: UseBoundStore>) {
)
}
-type MayBeDraft = T
+type MayBeDraft = T | Draft
+const isImmerDraft = (value: MayBeDraft): value is Draft => isDraft(value)
export const toRaw = (draft: MayBeDraft): T => {
- return isDraft(draft) ? original(draft)! : draft
+ return isImmerDraft(draft) ? original(draft) : draft
}
type SyncOrAsync = T | Promise
type ExecutorFn = (snapshot: S, ctx: Ctx) => SyncOrAsync
diff --git a/packages/internal/store/src/modules/user/store.ts b/packages/internal/store/src/modules/user/store.ts
index 2d129af74af..b883e6184c2 100644
--- a/packages/internal/store/src/modules/user/store.ts
+++ b/packages/internal/store/src/modules/user/store.ts
@@ -249,7 +249,12 @@ class UserActions implements Hydratable, Resetable {
tx.store(() => this.upsertManyInSession(users))
const { whoami } = useUserStore.getState()
tx.persist(() =>
- UserService.upsertMany(users.map((user) => ({ ...user, isMe: whoami?.id === user.id }))),
+ UserService.upsertMany(
+ users.map((user) => ({
+ ...user,
+ isMe: user.isMe || whoami?.id === user.id,
+ })),
+ ),
)
await tx.run()
}
diff --git a/packages/internal/tracker/package.json b/packages/internal/tracker/package.json
index f8741aac512..fede0eec098 100644
--- a/packages/internal/tracker/package.json
+++ b/packages/internal/tracker/package.json
@@ -5,11 +5,11 @@
"main": "./src/index.ts",
"peerDependencies": {
"posthog-js": "1.255.0",
- "posthog-react-native": "4.34.0"
+ "posthog-react-native": "4.54.4"
},
"devDependencies": {
"@follow-app/client-sdk": "catalog:",
"@follow/configs": "workspace:*",
- "@react-native-firebase/analytics": "24.0.0"
+ "@react-native-firebase/analytics": "25.1.0"
}
}
diff --git a/packages/internal/tracker/src/adapters/firebase.test.ts b/packages/internal/tracker/src/adapters/firebase.test.ts
new file mode 100644
index 00000000000..022726569df
--- /dev/null
+++ b/packages/internal/tracker/src/adapters/firebase.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from "vitest"
+
+import { TrackerMapper } from "../enums"
+import { FirebaseAdapter } from "./firebase"
+
+const createAdapter = () => {
+ const logEvent = vi.fn()
+ const adapter = new FirebaseAdapter({
+ instance: {
+ logEvent,
+ setUserId: vi.fn(),
+ setUserProperties: vi.fn(),
+ } as never,
+ })
+
+ return { adapter, logEvent }
+}
+
+describe("FirebaseAdapter", () => {
+ it("routes default mapped events by the internal event name without forwarding internal properties", async () => {
+ const { adapter, logEvent } = createAdapter()
+ const properties = {
+ __code: TrackerMapper.AppInit,
+ __eventName: "app_init",
+ source: "launch",
+ }
+ const originalProperties = { ...properties }
+
+ await adapter.track({ eventName: "ignored_event_name", properties })
+
+ expect(logEvent).toHaveBeenCalledWith("app_init", { source: "launch" })
+ expect(properties).toEqual(originalProperties)
+ })
+
+ it("removes internal properties from ordinary events without mutating the input", async () => {
+ const { adapter, logEvent } = createAdapter()
+ const properties = {
+ __code: undefined,
+ __eventName: "internal_event_name",
+ source: "manual",
+ }
+ const originalProperties = { ...properties }
+
+ await adapter.track({ eventName: "manual_event", properties })
+
+ expect(logEvent).toHaveBeenCalledWith("manual_event", { source: "manual" })
+ expect(properties).toEqual(originalProperties)
+ })
+})
diff --git a/packages/internal/tracker/src/adapters/firebase.ts b/packages/internal/tracker/src/adapters/firebase.ts
index 38820bb236a..279f9531508 100644
--- a/packages/internal/tracker/src/adapters/firebase.ts
+++ b/packages/internal/tracker/src/adapters/firebase.ts
@@ -29,12 +29,16 @@ export class FirebaseAdapter implements TrackerAdapter {
try {
// Handle special Firebase events based on the original event code
- const code = (properties as any)?.__code as TrackerMapper
+ const code = properties?.__code as TrackerMapper | undefined
+ const internalEventName = properties?.__eventName as string | undefined
+ const firebaseProperties = properties ? { ...properties } : undefined
+ delete firebaseProperties?.__code
+ delete firebaseProperties?.__eventName
if (code !== undefined) {
- await this.handleSpecialEvents(code, properties)
+ await this.handleSpecialEvents(code, firebaseProperties, internalEventName)
} else {
- await this.firebaseInstance.logEvent(eventName, properties)
+ await this.firebaseInstance.logEvent(eventName, firebaseProperties)
}
} catch (error) {
console.error(`[Firebase] Failed to track event "${eventName}":`, error)
@@ -44,6 +48,7 @@ export class FirebaseAdapter implements TrackerAdapter {
private async handleSpecialEvents(
code: TrackerMapper,
properties?: Record,
+ internalEventName?: string,
): Promise {
switch (code) {
case TrackerMapper.Identify: {
@@ -99,8 +104,7 @@ export class FirebaseAdapter implements TrackerAdapter {
}
default: {
// For other events, use the event name directly
- const eventName = (properties?.__eventName as string) || "unknown_event"
- await this.firebaseInstance.logEvent(eventName, properties)
+ await this.firebaseInstance.logEvent(internalEventName || "unknown_event", properties)
}
}
}
diff --git a/packages/internal/tracker/src/adapters/index.ts b/packages/internal/tracker/src/adapters/index.ts
index d8958fc1de8..9973772b11b 100644
--- a/packages/internal/tracker/src/adapters/index.ts
+++ b/packages/internal/tracker/src/adapters/index.ts
@@ -1,4 +1,9 @@
export type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export { FirebaseAdapter, type FirebaseAdapterConfig } from "./firebase"
-export { PostHogAdapter, type PostHogAdapterConfig } from "./posthog"
+export {
+ createPostHogBeforeSend,
+ POSTHOG_SAMPLE_RATE,
+ PostHogAdapter,
+ type PostHogAdapterConfig,
+} from "./posthog"
export { ProxyAdapter } from "./proxy"
diff --git a/packages/internal/tracker/src/adapters/posthog.test.ts b/packages/internal/tracker/src/adapters/posthog.test.ts
new file mode 100644
index 00000000000..a07064e3720
--- /dev/null
+++ b/packages/internal/tracker/src/adapters/posthog.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest"
+
+import { createPostHogBeforeSend, POSTHOG_SAMPLE_RATE } from "./posthog"
+
+describe("PostHogAdapter", () => {
+ it("uses a 5% PostHog sample rate", () => {
+ expect(POSTHOG_SAMPLE_RATE).toBe(0.05)
+ })
+
+ it("drops PostHog events when outside the configured sample rate", () => {
+ const beforeSend = createPostHogBeforeSend(0.05, () => 0.5)
+
+ expect(beforeSend({ event: "app init" })).toBeNull()
+ })
+
+ it("keeps PostHog events when inside the configured sample rate", () => {
+ const beforeSend = createPostHogBeforeSend(0.05, () => 0.04)
+ const event = { event: "app init" }
+
+ expect(beforeSend(event)).toBe(event)
+ })
+})
diff --git a/packages/internal/tracker/src/adapters/posthog.ts b/packages/internal/tracker/src/adapters/posthog.ts
index 715f317d339..083d0340d4b 100644
--- a/packages/internal/tracker/src/adapters/posthog.ts
+++ b/packages/internal/tracker/src/adapters/posthog.ts
@@ -3,6 +3,25 @@ import type PostHogReactNative from "posthog-react-native"
import type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
+export const POSTHOG_SAMPLE_RATE = 0.05
+
+const normalizeSampleRate = (sampleRate: number): number => {
+ if (!Number.isFinite(sampleRate)) return 1
+
+ return Math.min(Math.max(sampleRate, 0), 1)
+}
+
+export const createPostHogBeforeSend = (
+ sampleRate = POSTHOG_SAMPLE_RATE,
+ sampleRandom: () => number = Math.random,
+) => {
+ const normalizedSampleRate = normalizeSampleRate(sampleRate)
+ const isSampled =
+ normalizedSampleRate >= 1 || (normalizedSampleRate > 0 && sampleRandom() < normalizedSampleRate)
+
+ return (event: T): T | null => (isSampled ? event : null)
+}
+
export interface PostHogAdapterConfig {
instance: PostHog | PostHogReactNative
enabled?: boolean
diff --git a/packages/internal/tracker/src/index.ts b/packages/internal/tracker/src/index.ts
index 2585a7856fa..182f01da56d 100644
--- a/packages/internal/tracker/src/index.ts
+++ b/packages/internal/tracker/src/index.ts
@@ -9,9 +9,11 @@ export const tracker = new TrackerPoints()
export {
type CaptureExceptionPayload,
+ createPostHogBeforeSend,
FirebaseAdapter,
type FirebaseAdapterConfig,
type IdentifyPayload,
+ POSTHOG_SAMPLE_RATE,
PostHogAdapter,
type PostHogAdapterConfig,
ProxyAdapter,
diff --git a/packages/internal/tracker/tsconfig.json b/packages/internal/tracker/tsconfig.json
index ada0cc7a071..72f77bc2bf7 100644
--- a/packages/internal/tracker/tsconfig.json
+++ b/packages/internal/tracker/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
- "baseUrl": ".",
"declaration": false
}
}
diff --git a/packages/internal/utils/package.json b/packages/internal/utils/package.json
index 54c2790425b..b1def440bf2 100644
--- a/packages/internal/utils/package.json
+++ b/packages/internal/utils/package.json
@@ -33,36 +33,36 @@
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",
"@mozilla/readability": "0.6.0",
- "@types/hast": "3.0.4",
+ "@types/hast": "3.0.5",
"@types/unist": "3.0.3",
- "chardet": "2.1.1",
+ "chardet": "2.2.0",
"clsx": "2.1.1",
- "dayjs": "1.11.19",
- "dompurify": "3.3.1",
- "es-toolkit": "1.44.0",
+ "dayjs": "1.11.21",
+ "dompurify": "3.4.11",
+ "es-toolkit": "1.49.0",
"franc-min": "6.2.0",
"hast-util-sanitize": "5.0.2",
"hast-util-to-jsx-runtime": "2.3.6",
"hast-util-to-text": "4.0.2",
- "jotai": "2.17.1",
- "motion": "12.34.0",
- "nanoid": "5.1.6",
- "path-to-regexp": "8.3.0",
+ "jotai": "2.20.1",
+ "motion": "12.42.2",
+ "nanoid": "5.1.16",
+ "path-to-regexp": "8.4.2",
"rehype-infer-description-meta": "2.0.0",
"rehype-parse": "9.0.1",
"rehype-sanitize": "6.0.0",
"rehype-stringify": "10.0.1",
- "tailwind-merge": "3.4.0",
- "tldts": "7.0.23",
+ "tailwind-merge": "3.6.0",
+ "tldts": "7.4.7",
"unified": "11.0.5",
"uniqolor": "1.1.1",
"unist-util-visit": "5.1.0",
- "unist-util-visit-parents": "5.1.3",
- "zustand": "5.0.11"
+ "unist-util-visit-parents": "6.0.2",
+ "zustand": "5.0.14"
},
"devDependencies": {
"@follow/configs": "workspace:*",
- "react-native": "0.83.4",
+ "react-native": "0.86.0",
"vite-tsconfig-paths": "6.1.1"
}
}
diff --git a/packages/internal/utils/src/event-bus.rn.ts b/packages/internal/utils/src/event-bus.rn.ts
index a7ba930fefb..7bb45e94b20 100644
--- a/packages/internal/utils/src/event-bus.rn.ts
+++ b/packages/internal/utils/src/event-bus.rn.ts
@@ -1,6 +1,5 @@
import { DeviceEventEmitter } from "react-native"
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CustomEvent {}
export interface EventBusMap extends CustomEvent {}
diff --git a/packages/internal/utils/src/event-bus.ts b/packages/internal/utils/src/event-bus.ts
index ac365174340..d92c2a1f1ac 100644
--- a/packages/internal/utils/src/event-bus.ts
+++ b/packages/internal/utils/src/event-bus.ts
@@ -1,4 +1,3 @@
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface CustomEvent {}
export interface EventBusMap extends CustomEvent {}
diff --git a/packages/internal/utils/src/html.ts b/packages/internal/utils/src/html.ts
index 987990340c5..1c0dfe65bbc 100644
--- a/packages/internal/utils/src/html.ts
+++ b/packages/internal/utils/src/html.ts
@@ -188,6 +188,7 @@ export const parseHtml = (content: string, options?: ParseHtmlOptions) => {
"allowfullscreen",
"sandbox",
"loading",
+ "referrerPolicy",
"title",
"id",
"class",
diff --git a/packages/internal/utils/src/ns.ts b/packages/internal/utils/src/ns.ts
index 38dd0738e39..8daa11182d1 100644
--- a/packages/internal/utils/src/ns.ts
+++ b/packages/internal/utils/src/ns.ts
@@ -2,10 +2,10 @@ const ns = "follow"
export const getStorageNS = (key: string) => `${ns}:${key}`
export const clearStorage = () => {
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i)
- if (key && key.startsWith(ns)) {
+ const keys = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index))
+ keys.forEach((key) => {
+ if (key?.startsWith(ns)) {
localStorage.removeItem(key)
}
- }
+ })
}
diff --git a/packages/internal/utils/src/spotlight.ts b/packages/internal/utils/src/spotlight.ts
index 9608fbe9374..f00a2858146 100644
--- a/packages/internal/utils/src/spotlight.ts
+++ b/packages/internal/utils/src/spotlight.ts
@@ -19,8 +19,7 @@ export interface SpotlightPatternValidationError {
}
export type SpotlightPatternValidation =
- | SpotlightPatternValidationResult
- | SpotlightPatternValidationError
+ SpotlightPatternValidationResult | SpotlightPatternValidationError
export interface HighlightSegment {
text: string
diff --git a/packages/internal/utils/tsconfig.json b/packages/internal/utils/tsconfig.json
index 89cbad6d43f..2063509b3a9 100644
--- a/packages/internal/utils/tsconfig.json
+++ b/packages/internal/utils/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
- "baseUrl": ".",
"declaration": false,
"types": ["@follow/types/global", "@follow/types/vite"],
"paths": {
diff --git a/packages/readability/package.json b/packages/readability/package.json
index f9f65d8a47e..7224317e4c2 100644
--- a/packages/readability/package.json
+++ b/packages/readability/package.json
@@ -8,18 +8,21 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
- "build": "tsdown"
+ "build": "tsdown",
+ "test": "vitest run"
},
"dependencies": {
"@mozilla/readability": "0.6.0",
- "chardet": "2.1.1",
- "dompurify": "3.3.1",
- "linkedom": "0.18.12"
+ "chardet": "2.2.0",
+ "dompurify": "3.4.11",
+ "jsdom": "29.1.1",
+ "linkedom": "0.18.13"
},
"devDependencies": {
"@follow/configs": "workspace:*",
+ "@types/jsdom": "28.0.3",
"nbump": "2.1.8",
- "tsdown": "0.20.3"
+ "tsdown": "0.22.4"
},
"publishConfig": {
"exports": {
diff --git a/packages/readability/src/index.ts b/packages/readability/src/index.ts
index 126b46b1c65..54cb32f9311 100644
--- a/packages/readability/src/index.ts
+++ b/packages/readability/src/index.ts
@@ -1,26 +1,14 @@
import { Readability } from "@mozilla/readability"
import chardet from "chardet"
-import DOMPurify from "dompurify"
import { parseHTML } from "linkedom/worker"
+import { sanitizeHTMLString } from "./sanitize"
+
const isDev = process.env.NODE_ENV === "development"
const userAgents =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
-// For avoiding xss attack from readability, the raw document string should be sanitized.
-// The xss attack in electron may lead to more serious outcomes than browser environment.
-// It may allows remotely execute malicious scripts in main process.
-// Before the sanitizing, the DOMPurify requires a `window` environment provided by linkedom.
-function sanitizeHTMLString(dirtyDocumentString: string) {
- const parser = parseHTML(dirtyDocumentString)
- const purify = DOMPurify(parser.window)
- // How do DOMPurify changes the origin html structure,
- // You can refer its document https://github.com/cure53/DOMPurify?tab=readme-ov-file#can-i-configure-dompurify
- const sanitizedDocumentString = purify.sanitize(dirtyDocumentString)
- return sanitizedDocumentString
-}
-
/**
* Decodes the response body of a `fetch` request into a string, ensuring proper character set handling.
* @throws Will return "Failed to decode response content." if the decoding process encounters any errors.
diff --git a/packages/readability/src/sanitize.test.ts b/packages/readability/src/sanitize.test.ts
new file mode 100644
index 00000000000..c14ffad49c5
--- /dev/null
+++ b/packages/readability/src/sanitize.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest"
+
+import { sanitizeHTMLString } from "./sanitize"
+
+describe("sanitizeHTMLString", () => {
+ it("removes executable markup before readability parses the document", () => {
+ const clean = sanitizeHTMLString(`
+
+
+
+
+ link
+
+
+
+ `)
+
+ expect(clean).not.toContain("onerror")
+ expect(clean).not.toContain("onclick")
+ expect(clean).not.toContain("javascript:")
+ expect(clean).not.toContain("