diff --git a/apps/renderer/src/hooks/biz/useAb.ts b/apps/renderer/src/hooks/biz/useAb.ts new file mode 100644 index 0000000000..3eddbb14f4 --- /dev/null +++ b/apps/renderer/src/hooks/biz/useAb.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "jotai" +import PostHog from "posthog-js" +import { useFeatureFlagEnabled } from "posthog-js/react" + +import { jotaiStore } from "~/lib/jotai" +import type { FeatureKeys } from "~/modules/ab/atoms" +import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "~/modules/ab/atoms" + +export const useAb = (feature: FeatureKeys) => { + const isEnableDebugOverrides = useAtomValue(enableDebugOverrideAtom) + const debugFeatureOverrides = useAtomValue(debugFeaturesAtom) + + const isEnabled = useFeatureFlagEnabled(feature) + + if (IS_DEBUG_ENV && isEnableDebugOverrides) return debugFeatureOverrides[feature] + + return isEnabled +} + +export const getAbValue = (feature: FeatureKeys) => { + const enabled = PostHog.getFeatureFlag(feature) + const debugOverride = jotaiStore.get(debugFeaturesAtom) + + const isEnableOverride = jotaiStore.get(enableDebugOverrideAtom) + + if (isEnableOverride) { + return debugOverride[feature] + } + + return enabled +} diff --git a/apps/renderer/src/initialize/index.ts b/apps/renderer/src/initialize/index.ts index b978e73280..60e9373484 100644 --- a/apps/renderer/src/initialize/index.ts +++ b/apps/renderer/src/initialize/index.ts @@ -95,13 +95,12 @@ export const initializeApp = async () => { }) // should after hydrateSettings - const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings() + const { dataPersist: enabledDataPersist } = getGeneralSettings() initSentry() + initPostHog() await apm("i18n", initI18n) - if (sendAnonymousData) initPostHog() - let dataHydratedTime: undefined | number // Initialize the database if (enabledDataPersist) { diff --git a/apps/renderer/src/initialize/posthog.ts b/apps/renderer/src/initialize/posthog.ts index c5a367f88b..018851061e 100644 --- a/apps/renderer/src/initialize/posthog.ts +++ b/apps/renderer/src/initialize/posthog.ts @@ -1,6 +1,8 @@ import { env } from "@follow/shared/env" import type { CaptureOptions, Properties } from "posthog-js" +import { posthog } from "posthog-js" +import { getGeneralSettings } from "~/atoms/settings/general" import { whoami } from "~/atoms/user" declare global { @@ -12,9 +14,6 @@ declare global { } } export const initPostHog = async () => { - if (import.meta.env.DEV) return - const { default: posthog } = await import("posthog-js") - if (env.VITE_POSTHOG_KEY === undefined) return posthog.init(env.VITE_POSTHOG_KEY, { person_profiles: "identified_only", @@ -25,6 +24,10 @@ export const initPostHog = async () => { window.posthog = { reset, capture(event_name: string, properties?: Properties | null, options?: CaptureOptions) { + if (import.meta.env.DEV) return + if (!getGeneralSettings().sendAnonymousData) { + return + } return capture.apply(posthog, [ event_name, { diff --git a/apps/renderer/src/lib/img-proxy.ts b/apps/renderer/src/lib/img-proxy.ts index bc4c9e8021..c839c39371 100644 --- a/apps/renderer/src/lib/img-proxy.ts +++ b/apps/renderer/src/lib/img-proxy.ts @@ -1,6 +1,8 @@ import { env } from "@follow/shared/env" import { imageRefererMatches } from "@follow/shared/image" +import { getAbValue } from "~/hooks/biz/useAb" + export const getImageProxyUrl = ({ url, width, @@ -9,7 +11,13 @@ export const getImageProxyUrl = ({ url: string width: number height: number -}) => `${env.VITE_IMGPROXY_URL}?url=${encodeURIComponent(url)}&width=${width}&height=${height}` +}) => { + if (getAbValue("Image_Proxy_V2")) { + return `${env.VITE_IMGPROXY_URL}?url=${encodeURIComponent(url)}&width=${width}&height=${height}` + } else { + return `${env.VITE_IMGPROXY_URL}/unsafe/fit-in/${width}x${height}/${encodeURIComponent(url)}` + } +} export const replaceImgUrlIfNeed = (url: string) => { for (const rule of imageRefererMatches) { diff --git a/apps/renderer/src/modules/ab/atoms.ts b/apps/renderer/src/modules/ab/atoms.ts new file mode 100644 index 0000000000..f450683267 --- /dev/null +++ b/apps/renderer/src/modules/ab/atoms.ts @@ -0,0 +1,15 @@ +import FEATURES from "@constants/flags.json" +import { atom } from "jotai" +import { atomWithStorage } from "jotai/utils" + +import { getStorageNS } from "~/lib/ns" + +export type FeatureKeys = keyof typeof FEATURES + +export const debugFeaturesAtom = atomWithStorage(getStorageNS("ab"), FEATURES, undefined, { + getOnInit: true, +}) + +export const IS_DEBUG_ENV = import.meta.env.DEV || import.meta.env["PREVIEW_MODE"] + +export const enableDebugOverrideAtom = atom(IS_DEBUG_ENV) diff --git a/apps/renderer/src/modules/ab/hoc.tsx b/apps/renderer/src/modules/ab/hoc.tsx new file mode 100644 index 0000000000..823e9bfb90 --- /dev/null +++ b/apps/renderer/src/modules/ab/hoc.tsx @@ -0,0 +1,30 @@ +import type { FC } from "react" +import { forwardRef } from "react" + +import { useAb } from "~/hooks/biz/useAb" + +import type { FeatureKeys } from "./atoms" + +const Noop = () => null +export const withFeature = + (feature: FeatureKeys) => + ( + Component: FC, + + FallbackComponent: any = Noop, + ) => { + // @ts-expect-error + const WithFeature = forwardRef((props: T, ref: any) => { + const isEnabled = useAb(feature) + + if (isEnabled === undefined) return null + + return isEnabled ? ( + + ) : ( + + ) + }) + + return WithFeature + } diff --git a/apps/renderer/src/modules/ab/providers.tsx b/apps/renderer/src/modules/ab/providers.tsx new file mode 100644 index 0000000000..45758074ad --- /dev/null +++ b/apps/renderer/src/modules/ab/providers.tsx @@ -0,0 +1,84 @@ +import { useAtomValue, useSetAtom } from "jotai" + +import { Divider } from "~/components/ui/divider" +import { Label } from "~/components/ui/label" +import { useModalStack } from "~/components/ui/modal" +import { RootPortal } from "~/components/ui/portal" +import { Switch } from "~/components/ui/switch" + +import type { FeatureKeys } from "./atoms" +import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "./atoms" + +export const FeatureFlagDebugger = () => { + if (IS_DEBUG_ENV) return + + return null +} + +const DebugToggle = () => { + const { present } = useModalStack() + return ( + +
{ + present({ + title: "A/B", + content: ABModalContent, + }) + }} + className="fixed bottom-5 right-0 flex size-5 items-center justify-center opacity-40 duration-200 hover:opacity-100" + > + +
+
+ ) +} + +const SwitchInternal = ({ Key }: { Key: FeatureKeys }) => { + const enabled = useAtomValue(debugFeaturesAtom)[Key] + const setDebugFeatures = useSetAtom(debugFeaturesAtom) + return ( + { + setDebugFeatures((prev) => ({ ...prev, [Key]: checked })) + }} + /> + ) +} + +const ABModalContent = () => { + const features = useAtomValue(debugFeaturesAtom) + + const enableOverride = useAtomValue(enableDebugOverrideAtom) + const setEnableDebugOverride = useSetAtom(enableDebugOverrideAtom) + return ( +
+ + + + +
+ {Object.keys(features).map((key) => { + return ( +
+ +
+ ) + })} +
+
+ ) +} diff --git a/apps/renderer/src/providers/root-providers.tsx b/apps/renderer/src/providers/root-providers.tsx index 8966cff19e..c14d309e2c 100644 --- a/apps/renderer/src/providers/root-providers.tsx +++ b/apps/renderer/src/providers/root-providers.tsx @@ -10,6 +10,7 @@ import { Toaster } from "~/components/ui/sonner" import { HotKeyScopeMap } from "~/constants" import { jotaiStore } from "~/lib/jotai" import { persistConfig, queryClient } from "~/lib/query-client" +import { FeatureFlagDebugger } from "~/modules/ab/providers" import { ContextMenuProvider } from "./context-menu-provider" import { EventProvider } from "./event-provider" @@ -39,6 +40,7 @@ export const RootProviders: FC = ({ children }) => ( + {import.meta.env.DEV && } {children} diff --git a/apps/renderer/tsconfig.json b/apps/renderer/tsconfig.json index 0011f98075..1f6fa670c2 100644 --- a/apps/renderer/tsconfig.json +++ b/apps/renderer/tsconfig.json @@ -14,7 +14,8 @@ "paths": { "~/*": ["src/*"], "@pkg": ["../../package.json"], - "@locales/*": ["../../locales/*"] + "@locales/*": ["../../locales/*"], + "@constants/*": ["../../constants/*"] } } } diff --git a/configs/vite.render.config.ts b/configs/vite.render.config.ts index 62bea76d92..a38a42e021 100644 --- a/configs/vite.render.config.ts +++ b/configs/vite.render.config.ts @@ -100,6 +100,7 @@ export const viteRenderBaseConfig = { "@env": resolve("src/env.ts"), "@locales": resolve("locales"), "@follow/electron-main": resolve("apps/main/src"), + "@constants": resolve("constants"), }, }, base: "/", diff --git a/constants/flags.json b/constants/flags.json new file mode 100644 index 0000000000..c0da2a81f4 --- /dev/null +++ b/constants/flags.json @@ -0,0 +1,3 @@ +{ + "Image_Proxy_V2": true +} diff --git a/package.json b/package.json index 060a2c681d..02cf33bd02 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "prepare": "pnpm exec simple-git-hooks && shx test -f .env || shx cp .env.example .env", "publish": "electron-vite build --outDir=dist && electron-forge publish", "start": "electron-vite preview", + "sync:ab": "tsx scripts/pull-ab-flags.ts", "test": "pnpm -F web run test", "typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck:node": "pnpm -F electron-main run typecheck", diff --git a/scripts/pull-ab-flags.ts b/scripts/pull-ab-flags.ts new file mode 100644 index 0000000000..3755973fac --- /dev/null +++ b/scripts/pull-ab-flags.ts @@ -0,0 +1,107 @@ +import "dotenv/config" + +import { readFileSync, writeFileSync } from "node:fs" +import path, { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { inspect } from "node:util" + +import { ofetch } from "ofetch" + +const { POSTHOG_TOKEN } = process.env + +if (!POSTHOG_TOKEN) { + throw new Error("POSTHOG_TOKEN is not set") +} +// https://posthog.com/docs/api/feature-flags#post-api-organizations-parent_lookup_organization_id-feature_flags-copy_flags +const listRes: ListRes = await ofetch( + `https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/?limit=9999`, + { + method: "GET", + headers: { + Authorization: `Bearer ${POSTHOG_TOKEN}`, + }, + }, +) + +interface ListRes { + count: number + next: null + previous: null + results: ResultsItem[] +} +interface ResultsItem { + id: number + name: string + key: string + filters: any[] + deleted: boolean + active: boolean + created_by: any[] + created_at: string + is_simple_flag: boolean + rollout_percentage: number + ensure_experience_continuity: boolean + experiment_set: any[] + surveys: any[] + features: any[] + rollback_conditions: any[] + performed_rollback: boolean + can_edit: boolean + usage_dashboard: number + analytics_dashboards: any[] + has_enriched_analytics: boolean + tags: any[] +} + +const existFlags = {} as Record + +listRes.results.forEach((flag) => (existFlags[flag.key] = true)) + +const __dirname = resolve(dirname(fileURLToPath(import.meta.url))) +const localFlagsString = readFileSync(path.join(__dirname, "../constants/flags.json"), "utf8") +const localFlags = JSON.parse(localFlagsString as string) as Record + +const updateToRmoteFlags = {} as Record + +// If remote key has but local not has, add to Local +for (const key in existFlags) { + if (!(key in localFlags)) { + localFlags[key] = existFlags[key] + } +} + +// Write to local flags +writeFileSync(path.join(__dirname, "../constants/flags.json"), JSON.stringify(localFlags, null, 2)) + +console.info("update local flags", inspect(localFlags)) + +// Local first +for (const key in localFlags) { + // existFlags[key] = localFlags[key] + if (existFlags[key] !== localFlags[key]) { + updateToRmoteFlags[key] = localFlags[key] + } +} + +if (Object.keys(updateToRmoteFlags).length > 0) { + await Promise.allSettled( + Object.entries(updateToRmoteFlags).map(([key, flag]) => { + return fetch( + `https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.POSTHOG_PRIVATE_KEY}`, + }, + body: JSON.stringify({ + key, + active: flag, + }), + }, + ) + }), + ) + + console.info("update flags", inspect(updateToRmoteFlags)) +}