Skip to content

Commit 0504d0b

Browse files
committed
feat(core): init feature flag service (#7856)
1 parent 339c39c commit 0504d0b

File tree

19 files changed

+361
-312
lines changed

19 files changed

+361
-312
lines changed
Lines changed: 0 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { DebugLogger } from '@affine/debug';
22
import { setupGlobal } from '@affine/env/global';
3-
import type { DocCollection } from '@blocksuite/store';
43
import { atom } from 'jotai';
54
import { atomWithStorage } from 'jotai/utils';
65
import { atomEffect } from 'jotai-effect';
76

8-
import { getCurrentStore } from './root-store';
9-
107
setupGlobal();
118

129
const logger = new DebugLogger('affine:settings');
@@ -31,9 +28,7 @@ export type AppSetting = {
3128
enableNoisyBackground: boolean;
3229
autoCheckUpdate: boolean;
3330
autoDownloadUpdate: boolean;
34-
enableMultiView: boolean;
3531
enableTelemetry: boolean;
36-
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
3732
};
3833
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
3934
'frameless',
@@ -73,40 +68,8 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
7368
autoCheckUpdate: true,
7469
autoDownloadUpdate: true,
7570
enableTelemetry: true,
76-
enableMultiView: false,
77-
editorFlags: {},
7871
});
7972

80-
export function setupEditorFlags(docCollection: DocCollection) {
81-
const store = getCurrentStore();
82-
const syncEditorFlags = () => {
83-
try {
84-
const editorFlags = getCurrentStore().get(appSettingBaseAtom).editorFlags;
85-
Object.entries(editorFlags ?? {}).forEach(([key, value]) => {
86-
docCollection.awarenessStore.setFlag(
87-
key as keyof BlockSuiteFlags,
88-
value
89-
);
90-
});
91-
92-
// override this flag in app settings
93-
// TODO(@eyhn): need a better way to manage block suite flags
94-
Object.entries(blocksuiteFeatureFlags).forEach(([key, value]) => {
95-
if (value.defaultState !== undefined) {
96-
docCollection.awarenessStore.setFlag(
97-
key as keyof BlockSuiteFlags,
98-
value.defaultState
99-
);
100-
}
101-
});
102-
} catch (err) {
103-
logger.error('syncEditorFlags', err);
104-
}
105-
};
106-
store.sub(appSettingBaseAtom, syncEditorFlags);
107-
syncEditorFlags();
108-
}
109-
11073
type SetStateAction<Value> = Value | ((prev: Value) => Value);
11174

11275
// todo(@pengx17): use global state instead
@@ -143,89 +106,3 @@ export const appSettingAtom = atom<
143106
});
144107
}
145108
);
146-
147-
export type BuildChannel = 'stable' | 'beta' | 'canary' | 'internal';
148-
149-
export type FeedbackType = 'discord' | 'email' | 'github';
150-
151-
export type PreconditionType = () => boolean | undefined;
152-
153-
export type Flag<K extends string> = Partial<{
154-
[key in K]: {
155-
displayName: string;
156-
description?: string;
157-
precondition?: PreconditionType;
158-
defaultState?: boolean; // default to open and not controlled by user
159-
feedbackType?: FeedbackType;
160-
};
161-
}>;
162-
163-
const isNotStableBuild: PreconditionType = () => {
164-
return runtimeConfig.appBuildType !== 'stable';
165-
};
166-
const isDesktopEnvironment: PreconditionType = () => environment.isDesktop;
167-
const neverShow: PreconditionType = () => false;
168-
169-
export const blocksuiteFeatureFlags: Flag<keyof BlockSuiteFlags> = {
170-
enable_database_attachment_note: {
171-
displayName: 'Database Attachment Note',
172-
description: 'Allows adding notes to database attachments.',
173-
precondition: isNotStableBuild,
174-
},
175-
enable_database_statistics: {
176-
displayName: 'Database Block Statistics',
177-
description: 'Shows statistics for database blocks.',
178-
precondition: isNotStableBuild,
179-
},
180-
enable_block_query: {
181-
displayName: 'Todo Block Query',
182-
description: 'Enables querying of todo blocks.',
183-
precondition: isNotStableBuild,
184-
},
185-
enable_synced_doc_block: {
186-
displayName: 'Synced Doc Block',
187-
description: 'Enables syncing of doc blocks.',
188-
precondition: neverShow,
189-
defaultState: true,
190-
},
191-
enable_edgeless_text: {
192-
displayName: 'Edgeless Text',
193-
description: 'Enables edgeless text blocks.',
194-
precondition: neverShow,
195-
defaultState: true,
196-
},
197-
enable_color_picker: {
198-
displayName: 'Color Picker',
199-
description: 'Enables color picker blocks.',
200-
precondition: neverShow,
201-
defaultState: true,
202-
},
203-
enable_ai_chat_block: {
204-
displayName: 'AI Chat Block',
205-
description: 'Enables AI chat blocks.',
206-
precondition: neverShow,
207-
defaultState: true,
208-
},
209-
enable_ai_onboarding: {
210-
displayName: 'AI Onboarding',
211-
description: 'Enables AI onboarding.',
212-
precondition: neverShow,
213-
defaultState: true,
214-
},
215-
enable_expand_database_block: {
216-
displayName: 'Expand Database Block',
217-
description: 'Enables expanding of database blocks.',
218-
precondition: neverShow,
219-
defaultState: true,
220-
},
221-
};
222-
223-
export const affineFeatureFlags: Flag<keyof AppSetting> = {
224-
enableMultiView: {
225-
displayName: 'Split View',
226-
description:
227-
'The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.',
228-
feedbackType: 'discord',
229-
precondition: isDesktopEnvironment,
230-
},
231-
};

packages/common/infra/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './initialization';
66
export * from './livedata';
77
export * from './modules/db';
88
export * from './modules/doc';
9+
export * from './modules/feature-flag';
910
export * from './modules/global-context';
1011
export * from './modules/lifecycle';
1112
export * from './modules/storage';
@@ -17,6 +18,7 @@ export * from './utils';
1718
import type { Framework } from './framework';
1819
import { configureWorkspaceDBModule } from './modules/db';
1920
import { configureDocModule } from './modules/doc';
21+
import { configureFeatureFlagModule } from './modules/feature-flag';
2022
import { configureGlobalContextModule } from './modules/global-context';
2123
import { configureLifecycleModule } from './modules/lifecycle';
2224
import {
@@ -35,6 +37,7 @@ export function configureInfraModules(framework: Framework) {
3537
configureGlobalStorageModule(framework);
3638
configureGlobalContextModule(framework);
3739
configureLifecycleModule(framework);
40+
configureFeatureFlagModule(framework);
3841
}
3942

4043
export function configureTestingInfraModules(framework: Framework) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { FlagInfo } from './types';
2+
3+
const isNotStableBuild = runtimeConfig.appBuildType !== 'stable';
4+
const isDesktopEnvironment = environment.isDesktop;
5+
const isCanaryBuild = runtimeConfig.appBuildType === 'canary';
6+
7+
export const AFFINE_FLAGS = {
8+
enable_database_attachment_note: {
9+
category: 'blocksuite',
10+
bsFlag: 'enable_database_attachment_note',
11+
displayName: 'Database Attachment Note',
12+
description: 'Allows adding notes to database attachments.',
13+
configurable: isNotStableBuild,
14+
},
15+
enable_database_statistics: {
16+
category: 'blocksuite',
17+
bsFlag: 'enable_database_statistics',
18+
displayName: 'Database Block Statistics',
19+
description: 'Shows statistics for database blocks.',
20+
configurable: isNotStableBuild,
21+
},
22+
enable_block_query: {
23+
category: 'blocksuite',
24+
bsFlag: 'enable_block_query',
25+
displayName: 'Todo Block Query',
26+
description: 'Enables querying of todo blocks.',
27+
configurable: isNotStableBuild,
28+
},
29+
enable_synced_doc_block: {
30+
category: 'blocksuite',
31+
bsFlag: 'enable_synced_doc_block',
32+
displayName: 'Synced Doc Block',
33+
description: 'Enables syncing of doc blocks.',
34+
configurable: false,
35+
defaultState: true,
36+
},
37+
enable_edgeless_text: {
38+
category: 'blocksuite',
39+
bsFlag: 'enable_edgeless_text',
40+
displayName: 'Edgeless Text',
41+
description: 'Enables edgeless text blocks.',
42+
configurable: false,
43+
defaultState: true,
44+
},
45+
enable_color_picker: {
46+
category: 'blocksuite',
47+
bsFlag: 'enable_color_picker',
48+
displayName: 'Color Picker',
49+
description: 'Enables color picker blocks.',
50+
configurable: false,
51+
defaultState: true,
52+
},
53+
enable_ai_chat_block: {
54+
category: 'blocksuite',
55+
bsFlag: 'enable_ai_chat_block',
56+
displayName: 'AI Chat Block',
57+
description: 'Enables AI chat blocks.',
58+
configurable: false,
59+
defaultState: true,
60+
},
61+
enable_ai_onboarding: {
62+
category: 'blocksuite',
63+
bsFlag: 'enable_ai_onboarding',
64+
displayName: 'AI Onboarding',
65+
description: 'Enables AI onboarding.',
66+
configurable: false,
67+
defaultState: true,
68+
},
69+
enable_expand_database_block: {
70+
category: 'blocksuite',
71+
bsFlag: 'enable_expand_database_block',
72+
displayName: 'Expand Database Block',
73+
description: 'Enables expanding of database blocks.',
74+
configurable: false,
75+
defaultState: true,
76+
},
77+
enable_multi_view: {
78+
category: 'affine',
79+
displayName: 'Split View',
80+
description:
81+
'The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.',
82+
feedbackType: 'discord',
83+
configurable: isDesktopEnvironment,
84+
defaultState: isCanaryBuild,
85+
},
86+
} satisfies { [key in string]: FlagInfo };
87+
88+
export type AFFINE_FLAGS = typeof AFFINE_FLAGS;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { NEVER } from 'rxjs';
2+
3+
import { Entity } from '../../../framework';
4+
import { LiveData } from '../../../livedata';
5+
import type { GlobalStateService } from '../../storage';
6+
import { AFFINE_FLAGS } from '../constant';
7+
import type { FlagInfo } from '../types';
8+
9+
const FLAG_PREFIX = 'affine-flag:';
10+
11+
export type Flag<F extends FlagInfo = FlagInfo> = {
12+
readonly value: F['defaultState'] extends boolean
13+
? boolean
14+
: boolean | undefined;
15+
set: (value: boolean) => void;
16+
// eslint-disable-next-line rxjs/finnish
17+
$: F['defaultState'] extends boolean
18+
? LiveData<boolean>
19+
: LiveData<boolean> | LiveData<boolean | undefined>;
20+
} & F;
21+
22+
export class Flags extends Entity {
23+
private readonly globalState = this.globalStateService.globalState;
24+
25+
constructor(private readonly globalStateService: GlobalStateService) {
26+
super();
27+
28+
Object.entries(AFFINE_FLAGS).forEach(([flagKey, flag]) => {
29+
const configurable = flag.configurable ?? true;
30+
const defaultState =
31+
'defaultState' in flag ? flag.defaultState : undefined;
32+
const item = {
33+
...flag,
34+
value: configurable
35+
? (this.globalState.get<boolean>(FLAG_PREFIX + flagKey) ??
36+
defaultState)
37+
: defaultState,
38+
set: (value: boolean) => {
39+
if (!configurable) {
40+
return;
41+
}
42+
this.globalState.set(FLAG_PREFIX + flagKey, value);
43+
},
44+
$: configurable
45+
? LiveData.from<boolean | undefined>(
46+
this.globalState.watch<boolean>(FLAG_PREFIX + flagKey),
47+
undefined
48+
).map(value => value ?? defaultState)
49+
: LiveData.from(NEVER, defaultState),
50+
} as Flag<typeof flag>;
51+
Object.defineProperty(this, flagKey, {
52+
get: () => {
53+
return item;
54+
},
55+
});
56+
});
57+
}
58+
}
59+
60+
export type FlagsExt = Flags & {
61+
[K in keyof AFFINE_FLAGS]: Flag<AFFINE_FLAGS[K]>;
62+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Framework } from '../../framework';
2+
import { GlobalStateService } from '../storage';
3+
import { Flags } from './entities/flags';
4+
import { FeatureFlagService } from './services/feature-flag';
5+
6+
export { AFFINE_FLAGS } from './constant';
7+
export type { Flag } from './entities/flags';
8+
export { FeatureFlagService } from './services/feature-flag';
9+
export type { FlagInfo } from './types';
10+
11+
export function configureFeatureFlagModule(framework: Framework) {
12+
framework.service(FeatureFlagService).entity(Flags, [GlobalStateService]);
13+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { OnEvent, Service } from '../../../framework';
2+
import type { Workspace } from '../../workspace';
3+
import { WorkspaceInitialized } from '../../workspace/events';
4+
import { AFFINE_FLAGS } from '../constant';
5+
import { Flags, type FlagsExt } from '../entities/flags';
6+
7+
@OnEvent(WorkspaceInitialized, e => e.setupBlocksuiteEditorFlags)
8+
export class FeatureFlagService extends Service {
9+
flags = this.framework.createEntity(Flags) as FlagsExt;
10+
11+
setupBlocksuiteEditorFlags(workspace: Workspace) {
12+
for (const [key, flag] of Object.entries(AFFINE_FLAGS)) {
13+
if (flag.category === 'blocksuite') {
14+
const value = this.flags[key as keyof AFFINE_FLAGS].value;
15+
if (value !== undefined) {
16+
workspace.docCollection.awarenessStore.setFlag(flag.bsFlag, value);
17+
}
18+
}
19+
}
20+
}
21+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
type FeedbackType = 'discord' | 'email' | 'github';
2+
3+
export type FlagInfo = {
4+
displayName: string;
5+
description?: string;
6+
configurable?: boolean;
7+
defaultState?: boolean; // default to open and not controlled by user
8+
feedbackType?: FeedbackType;
9+
} & (
10+
| {
11+
category: 'affine';
12+
}
13+
| {
14+
category: 'blocksuite';
15+
bsFlag: keyof BlockSuiteFlags;
16+
}
17+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { createEvent } from '../../../framework';
22
import type { WorkspaceEngine } from '../entities/engine';
3+
import type { Workspace } from '../entities/workspace';
34

45
export const WorkspaceEngineBeforeStart = createEvent<WorkspaceEngine>(
56
'WorkspaceEngineBeforeStart'
67
);
8+
9+
export const WorkspaceInitialized = createEvent<Workspace>(
10+
'WorkspaceInitialized'
11+
);

0 commit comments

Comments
 (0)