Skip to content

Commit 7f3b787

Browse files
committed
init
Signed-off-by: Innei <[email protected]>
1 parent 1ad247d commit 7f3b787

File tree

13 files changed

+293
-8
lines changed

13 files changed

+293
-8
lines changed

apps/renderer/src/hooks/biz/useAb.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useAtomValue } from "jotai"
2+
import PostHog from "posthog-js"
3+
import { useFeatureFlagEnabled } from "posthog-js/react"
4+
5+
import { jotaiStore } from "~/lib/jotai"
6+
import type { FeatureKeys } from "~/modules/ab/atoms"
7+
import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "~/modules/ab/atoms"
8+
9+
export const useAb = (feature: FeatureKeys) => {
10+
const isEnableDebugOverrides = useAtomValue(enableDebugOverrideAtom)
11+
const debugFeatureOverrides = useAtomValue(debugFeaturesAtom)
12+
13+
const isEnabled = useFeatureFlagEnabled(feature)
14+
15+
if (IS_DEBUG_ENV && isEnableDebugOverrides) return debugFeatureOverrides[feature]
16+
17+
return isEnabled
18+
}
19+
20+
export const getAbValue = (feature: FeatureKeys) => {
21+
const enabled = PostHog.getFeatureFlag(feature)
22+
const debugOverride = jotaiStore.get(debugFeaturesAtom)
23+
24+
const isEnableOverride = jotaiStore.get(enableDebugOverrideAtom)
25+
26+
if (isEnableOverride) {
27+
return debugOverride[feature]
28+
}
29+
30+
return enabled
31+
}

apps/renderer/src/initialize/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,12 @@ export const initializeApp = async () => {
9595
})
9696

9797
// should after hydrateSettings
98-
const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings()
98+
const { dataPersist: enabledDataPersist } = getGeneralSettings()
9999

100100
initSentry()
101+
initPostHog()
101102
await apm("i18n", initI18n)
102103

103-
if (sendAnonymousData) initPostHog()
104-
105104
let dataHydratedTime: undefined | number
106105
// Initialize the database
107106
if (enabledDataPersist) {

apps/renderer/src/initialize/posthog.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { env } from "@follow/shared/env"
22
import type { CaptureOptions, Properties } from "posthog-js"
3+
import { posthog } from "posthog-js"
34

5+
import { getGeneralSettings } from "~/atoms/settings/general"
46
import { whoami } from "~/atoms/user"
57

68
declare global {
@@ -12,9 +14,6 @@ declare global {
1214
}
1315
}
1416
export const initPostHog = async () => {
15-
if (import.meta.env.DEV) return
16-
const { default: posthog } = await import("posthog-js")
17-
1817
if (env.VITE_POSTHOG_KEY === undefined) return
1918
posthog.init(env.VITE_POSTHOG_KEY, {
2019
person_profiles: "identified_only",
@@ -25,6 +24,10 @@ export const initPostHog = async () => {
2524
window.posthog = {
2625
reset,
2726
capture(event_name: string, properties?: Properties | null, options?: CaptureOptions) {
27+
if (import.meta.env.DEV) return
28+
if (!getGeneralSettings().sendAnonymousData) {
29+
return
30+
}
2831
return capture.apply(posthog, [
2932
event_name,
3033
{

apps/renderer/src/lib/img-proxy.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { env } from "@follow/shared/env"
22
import { imageRefererMatches } from "@follow/shared/image"
33

4+
import { getAbValue } from "~/hooks/biz/useAb"
5+
46
export const getImageProxyUrl = ({
57
url,
68
width,
@@ -9,7 +11,13 @@ export const getImageProxyUrl = ({
911
url: string
1012
width: number
1113
height: number
12-
}) => `${env.VITE_IMGPROXY_URL}?url=${encodeURIComponent(url)}&width=${width}&height=${height}`
14+
}) => {
15+
if (getAbValue("Image_Proxy_V2")) {
16+
return `${env.VITE_IMGPROXY_URL}?url=${encodeURIComponent(url)}&width=${width}&height=${height}`
17+
} else {
18+
return `${env.VITE_IMGPROXY_URL}/unsafe/fit-in/${width}x${height}/${encodeURIComponent(url)}`
19+
}
20+
}
1321

1422
export const replaceImgUrlIfNeed = (url: string) => {
1523
for (const rule of imageRefererMatches) {

apps/renderer/src/modules/ab/atoms.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import FEATURES from "@constants/flags.json"
2+
import { atom } from "jotai"
3+
import { atomWithStorage } from "jotai/utils"
4+
5+
import { getStorageNS } from "~/lib/ns"
6+
7+
export type FeatureKeys = keyof typeof FEATURES
8+
9+
export const debugFeaturesAtom = atomWithStorage(getStorageNS("ab"), FEATURES, undefined, {
10+
getOnInit: true,
11+
})
12+
13+
export const IS_DEBUG_ENV = import.meta.env.DEV || import.meta.env["PREVIEW_MODE"]
14+
15+
export const enableDebugOverrideAtom = atom(IS_DEBUG_ENV)

apps/renderer/src/modules/ab/hoc.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { FC } from "react"
2+
import { forwardRef } from "react"
3+
4+
import { useAb } from "~/hooks/biz/useAb"
5+
6+
import type { FeatureKeys } from "./atoms"
7+
8+
const Noop = () => null
9+
export const withFeature =
10+
(feature: FeatureKeys) =>
11+
<T extends object>(
12+
Component: FC<T>,
13+
14+
FallbackComponent: any = Noop,
15+
) => {
16+
// @ts-expect-error
17+
const WithFeature = forwardRef((props: T, ref: any) => {
18+
const isEnabled = useAb(feature)
19+
20+
if (isEnabled === undefined) return null
21+
22+
return isEnabled ? (
23+
<Component {...props} ref={ref} />
24+
) : (
25+
<FallbackComponent {...props} ref={ref} />
26+
)
27+
})
28+
29+
return WithFeature
30+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useAtomValue, useSetAtom } from "jotai"
2+
3+
import { Divider } from "~/components/ui/divider"
4+
import { Label } from "~/components/ui/label"
5+
import { useModalStack } from "~/components/ui/modal"
6+
import { RootPortal } from "~/components/ui/portal"
7+
import { Switch } from "~/components/ui/switch"
8+
9+
import type { FeatureKeys } from "./atoms"
10+
import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "./atoms"
11+
12+
export const FeatureFlagDebugger = () => {
13+
if (IS_DEBUG_ENV) return <DebugToggle />
14+
15+
return null
16+
}
17+
18+
const DebugToggle = () => {
19+
const { present } = useModalStack()
20+
return (
21+
<RootPortal>
22+
<div
23+
tabIndex={-1}
24+
onClick={() => {
25+
present({
26+
title: "A/B",
27+
content: ABModalContent,
28+
})
29+
}}
30+
className="fixed bottom-5 right-0 flex size-5 items-center justify-center opacity-40 duration-200 hover:opacity-100"
31+
>
32+
<i className="i-mingcute-switch-line" />
33+
</div>
34+
</RootPortal>
35+
)
36+
}
37+
38+
const SwitchInternal = ({ Key }: { Key: FeatureKeys }) => {
39+
const enabled = useAtomValue(debugFeaturesAtom)[Key]
40+
const setDebugFeatures = useSetAtom(debugFeaturesAtom)
41+
return (
42+
<Switch
43+
checked={enabled}
44+
onCheckedChange={(checked) => {
45+
setDebugFeatures((prev) => ({ ...prev, [Key]: checked }))
46+
}}
47+
/>
48+
)
49+
}
50+
51+
const ABModalContent = () => {
52+
const features = useAtomValue(debugFeaturesAtom)
53+
54+
const enableOverride = useAtomValue(enableDebugOverrideAtom)
55+
const setEnableDebugOverride = useSetAtom(enableDebugOverrideAtom)
56+
return (
57+
<div>
58+
<Label className="flex items-center justify-between">
59+
Enable Override A/B
60+
<Switch
61+
checked={enableOverride}
62+
onCheckedChange={(checked) => {
63+
setEnableDebugOverride(checked)
64+
}}
65+
/>
66+
</Label>
67+
68+
<Divider />
69+
70+
<div className={enableOverride ? "opacity-100" : "pointer-events-none opacity-40"}>
71+
{Object.keys(features).map((key) => {
72+
return (
73+
<div key={key} className="flex w-full items-center justify-between">
74+
<Label className="flex w-full items-center justify-between text-sm">
75+
{key}
76+
<SwitchInternal Key={key as any} />
77+
</Label>
78+
</div>
79+
)
80+
})}
81+
</div>
82+
</div>
83+
)
84+
}

apps/renderer/src/providers/root-providers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Toaster } from "~/components/ui/sonner"
1010
import { HotKeyScopeMap } from "~/constants"
1111
import { jotaiStore } from "~/lib/jotai"
1212
import { persistConfig, queryClient } from "~/lib/query-client"
13+
import { FeatureFlagDebugger } from "~/modules/ab/providers"
1314

1415
import { ContextMenuProvider } from "./context-menu-provider"
1516
import { EventProvider } from "./event-provider"
@@ -39,6 +40,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
3940
<ModalStackProvider />
4041
<ContextMenuProvider />
4142
<StableRouterProvider />
43+
<FeatureFlagDebugger />
4244
{import.meta.env.DEV && <Devtools />}
4345
{children}
4446
</I18nProvider>

apps/renderer/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"paths": {
1515
"~/*": ["src/*"],
1616
"@pkg": ["../../package.json"],
17-
"@locales/*": ["../../locales/*"]
17+
"@locales/*": ["../../locales/*"],
18+
"@constants/*": ["../../constants/*"]
1819
}
1920
}
2021
}

configs/vite.render.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export const viteRenderBaseConfig = {
100100
"@env": resolve("src/env.ts"),
101101
"@locales": resolve("locales"),
102102
"@follow/electron-main": resolve("apps/main/src"),
103+
"@constants": resolve("constants"),
103104
},
104105
},
105106
base: "/",

constants/flags.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"Image_Proxy_V2": true
3+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"prepare": "pnpm exec simple-git-hooks && shx test -f .env || shx cp .env.example .env",
3232
"publish": "electron-vite build --outDir=dist && electron-forge publish",
3333
"start": "electron-vite preview",
34+
"sync:ab": "tsx scripts/pull-ab-flags.ts",
3435
"test": "pnpm -F web run test",
3536
"typecheck": "npm run typecheck:node && npm run typecheck:web",
3637
"typecheck:node": "pnpm -F electron-main run typecheck",

scripts/pull-ab-flags.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import "dotenv/config"
2+
3+
import { readFileSync, writeFileSync } from "node:fs"
4+
import path, { dirname, resolve } from "node:path"
5+
import { fileURLToPath } from "node:url"
6+
import { inspect } from "node:util"
7+
8+
import { ofetch } from "ofetch"
9+
10+
const { POSTHOG_TOKEN } = process.env
11+
12+
if (!POSTHOG_TOKEN) {
13+
throw new Error("POSTHOG_TOKEN is not set")
14+
}
15+
// https://posthog.com/docs/api/feature-flags#post-api-organizations-parent_lookup_organization_id-feature_flags-copy_flags
16+
const listRes: ListRes = await ofetch(
17+
`https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/?limit=9999`,
18+
{
19+
method: "GET",
20+
headers: {
21+
Authorization: `Bearer ${POSTHOG_TOKEN}`,
22+
},
23+
},
24+
)
25+
26+
interface ListRes {
27+
count: number
28+
next: null
29+
previous: null
30+
results: ResultsItem[]
31+
}
32+
interface ResultsItem {
33+
id: number
34+
name: string
35+
key: string
36+
filters: any[]
37+
deleted: boolean
38+
active: boolean
39+
created_by: any[]
40+
created_at: string
41+
is_simple_flag: boolean
42+
rollout_percentage: number
43+
ensure_experience_continuity: boolean
44+
experiment_set: any[]
45+
surveys: any[]
46+
features: any[]
47+
rollback_conditions: any[]
48+
performed_rollback: boolean
49+
can_edit: boolean
50+
usage_dashboard: number
51+
analytics_dashboards: any[]
52+
has_enriched_analytics: boolean
53+
tags: any[]
54+
}
55+
56+
const existFlags = {} as Record<string, boolean>
57+
58+
listRes.results.forEach((flag) => (existFlags[flag.key] = true))
59+
60+
const __dirname = resolve(dirname(fileURLToPath(import.meta.url)))
61+
const localFlagsString = readFileSync(path.join(__dirname, "../constants/flags.json"), "utf8")
62+
const localFlags = JSON.parse(localFlagsString as string) as Record<string, boolean>
63+
64+
const updateToRmoteFlags = {} as Record<string, boolean>
65+
66+
// If remote key has but local not has, add to Local
67+
for (const key in existFlags) {
68+
if (!(key in localFlags)) {
69+
localFlags[key] = existFlags[key]
70+
}
71+
}
72+
73+
// Write to local flags
74+
writeFileSync(path.join(__dirname, "../constants/flags.json"), JSON.stringify(localFlags, null, 2))
75+
76+
console.info("update local flags", inspect(localFlags))
77+
78+
// Local first
79+
for (const key in localFlags) {
80+
// existFlags[key] = localFlags[key]
81+
if (existFlags[key] !== localFlags[key]) {
82+
updateToRmoteFlags[key] = localFlags[key]
83+
}
84+
}
85+
86+
if (Object.keys(updateToRmoteFlags).length > 0) {
87+
await Promise.allSettled(
88+
Object.entries(updateToRmoteFlags).map(([key, flag]) => {
89+
return fetch(
90+
`https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/`,
91+
{
92+
method: "POST",
93+
headers: {
94+
"Content-Type": "application/json",
95+
Authorization: `Bearer ${process.env.POSTHOG_PRIVATE_KEY}`,
96+
},
97+
body: JSON.stringify({
98+
key,
99+
active: flag,
100+
}),
101+
},
102+
)
103+
}),
104+
)
105+
106+
console.info("update flags", inspect(updateToRmoteFlags))
107+
}

0 commit comments

Comments
 (0)