feat(core): init feature flag service (#7856)

This commit is contained in:
EYHN
2024-08-14 10:35:21 +00:00
parent 339c39c1ec
commit 0504d0b0ff
19 changed files with 361 additions and 312 deletions

View File

@@ -1,12 +1,9 @@
import { DebugLogger } from '@affine/debug';
import { setupGlobal } from '@affine/env/global';
import type { DocCollection } from '@blocksuite/store';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { atomEffect } from 'jotai-effect';
import { getCurrentStore } from './root-store';
setupGlobal();
const logger = new DebugLogger('affine:settings');
@@ -31,9 +28,7 @@ export type AppSetting = {
enableNoisyBackground: boolean;
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
enableMultiView: boolean;
enableTelemetry: boolean;
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
};
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
'frameless',
@@ -73,40 +68,8 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
autoCheckUpdate: true,
autoDownloadUpdate: true,
enableTelemetry: true,
enableMultiView: false,
editorFlags: {},
});
export function setupEditorFlags(docCollection: DocCollection) {
const store = getCurrentStore();
const syncEditorFlags = () => {
try {
const editorFlags = getCurrentStore().get(appSettingBaseAtom).editorFlags;
Object.entries(editorFlags ?? {}).forEach(([key, value]) => {
docCollection.awarenessStore.setFlag(
key as keyof BlockSuiteFlags,
value
);
});
// override this flag in app settings
// TODO(@eyhn): need a better way to manage block suite flags
Object.entries(blocksuiteFeatureFlags).forEach(([key, value]) => {
if (value.defaultState !== undefined) {
docCollection.awarenessStore.setFlag(
key as keyof BlockSuiteFlags,
value.defaultState
);
}
});
} catch (err) {
logger.error('syncEditorFlags', err);
}
};
store.sub(appSettingBaseAtom, syncEditorFlags);
syncEditorFlags();
}
type SetStateAction<Value> = Value | ((prev: Value) => Value);
// todo(@pengx17): use global state instead
@@ -143,89 +106,3 @@ export const appSettingAtom = atom<
});
}
);
export type BuildChannel = 'stable' | 'beta' | 'canary' | 'internal';
export type FeedbackType = 'discord' | 'email' | 'github';
export type PreconditionType = () => boolean | undefined;
export type Flag<K extends string> = Partial<{
[key in K]: {
displayName: string;
description?: string;
precondition?: PreconditionType;
defaultState?: boolean; // default to open and not controlled by user
feedbackType?: FeedbackType;
};
}>;
const isNotStableBuild: PreconditionType = () => {
return runtimeConfig.appBuildType !== 'stable';
};
const isDesktopEnvironment: PreconditionType = () => environment.isDesktop;
const neverShow: PreconditionType = () => false;
export const blocksuiteFeatureFlags: Flag<keyof BlockSuiteFlags> = {
enable_database_attachment_note: {
displayName: 'Database Attachment Note',
description: 'Allows adding notes to database attachments.',
precondition: isNotStableBuild,
},
enable_database_statistics: {
displayName: 'Database Block Statistics',
description: 'Shows statistics for database blocks.',
precondition: isNotStableBuild,
},
enable_block_query: {
displayName: 'Todo Block Query',
description: 'Enables querying of todo blocks.',
precondition: isNotStableBuild,
},
enable_synced_doc_block: {
displayName: 'Synced Doc Block',
description: 'Enables syncing of doc blocks.',
precondition: neverShow,
defaultState: true,
},
enable_edgeless_text: {
displayName: 'Edgeless Text',
description: 'Enables edgeless text blocks.',
precondition: neverShow,
defaultState: true,
},
enable_color_picker: {
displayName: 'Color Picker',
description: 'Enables color picker blocks.',
precondition: neverShow,
defaultState: true,
},
enable_ai_chat_block: {
displayName: 'AI Chat Block',
description: 'Enables AI chat blocks.',
precondition: neverShow,
defaultState: true,
},
enable_ai_onboarding: {
displayName: 'AI Onboarding',
description: 'Enables AI onboarding.',
precondition: neverShow,
defaultState: true,
},
enable_expand_database_block: {
displayName: 'Expand Database Block',
description: 'Enables expanding of database blocks.',
precondition: neverShow,
defaultState: true,
},
};
export const affineFeatureFlags: Flag<keyof AppSetting> = {
enableMultiView: {
displayName: 'Split View',
description:
'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.',
feedbackType: 'discord',
precondition: isDesktopEnvironment,
},
};

View File

@@ -6,6 +6,7 @@ export * from './initialization';
export * from './livedata';
export * from './modules/db';
export * from './modules/doc';
export * from './modules/feature-flag';
export * from './modules/global-context';
export * from './modules/lifecycle';
export * from './modules/storage';
@@ -17,6 +18,7 @@ export * from './utils';
import type { Framework } from './framework';
import { configureWorkspaceDBModule } from './modules/db';
import { configureDocModule } from './modules/doc';
import { configureFeatureFlagModule } from './modules/feature-flag';
import { configureGlobalContextModule } from './modules/global-context';
import { configureLifecycleModule } from './modules/lifecycle';
import {
@@ -35,6 +37,7 @@ export function configureInfraModules(framework: Framework) {
configureGlobalStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);
}
export function configureTestingInfraModules(framework: Framework) {

View File

@@ -0,0 +1,88 @@
import type { FlagInfo } from './types';
const isNotStableBuild = runtimeConfig.appBuildType !== 'stable';
const isDesktopEnvironment = environment.isDesktop;
const isCanaryBuild = runtimeConfig.appBuildType === 'canary';
export const AFFINE_FLAGS = {
enable_database_attachment_note: {
category: 'blocksuite',
bsFlag: 'enable_database_attachment_note',
displayName: 'Database Attachment Note',
description: 'Allows adding notes to database attachments.',
configurable: isNotStableBuild,
},
enable_database_statistics: {
category: 'blocksuite',
bsFlag: 'enable_database_statistics',
displayName: 'Database Block Statistics',
description: 'Shows statistics for database blocks.',
configurable: isNotStableBuild,
},
enable_block_query: {
category: 'blocksuite',
bsFlag: 'enable_block_query',
displayName: 'Todo Block Query',
description: 'Enables querying of todo blocks.',
configurable: isNotStableBuild,
},
enable_synced_doc_block: {
category: 'blocksuite',
bsFlag: 'enable_synced_doc_block',
displayName: 'Synced Doc Block',
description: 'Enables syncing of doc blocks.',
configurable: false,
defaultState: true,
},
enable_edgeless_text: {
category: 'blocksuite',
bsFlag: 'enable_edgeless_text',
displayName: 'Edgeless Text',
description: 'Enables edgeless text blocks.',
configurable: false,
defaultState: true,
},
enable_color_picker: {
category: 'blocksuite',
bsFlag: 'enable_color_picker',
displayName: 'Color Picker',
description: 'Enables color picker blocks.',
configurable: false,
defaultState: true,
},
enable_ai_chat_block: {
category: 'blocksuite',
bsFlag: 'enable_ai_chat_block',
displayName: 'AI Chat Block',
description: 'Enables AI chat blocks.',
configurable: false,
defaultState: true,
},
enable_ai_onboarding: {
category: 'blocksuite',
bsFlag: 'enable_ai_onboarding',
displayName: 'AI Onboarding',
description: 'Enables AI onboarding.',
configurable: false,
defaultState: true,
},
enable_expand_database_block: {
category: 'blocksuite',
bsFlag: 'enable_expand_database_block',
displayName: 'Expand Database Block',
description: 'Enables expanding of database blocks.',
configurable: false,
defaultState: true,
},
enable_multi_view: {
category: 'affine',
displayName: 'Split View',
description:
'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.',
feedbackType: 'discord',
configurable: isDesktopEnvironment,
defaultState: isCanaryBuild,
},
} satisfies { [key in string]: FlagInfo };
export type AFFINE_FLAGS = typeof AFFINE_FLAGS;

View File

@@ -0,0 +1,62 @@
import { NEVER } from 'rxjs';
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import type { GlobalStateService } from '../../storage';
import { AFFINE_FLAGS } from '../constant';
import type { FlagInfo } from '../types';
const FLAG_PREFIX = 'affine-flag:';
export type Flag<F extends FlagInfo = FlagInfo> = {
readonly value: F['defaultState'] extends boolean
? boolean
: boolean | undefined;
set: (value: boolean) => void;
// eslint-disable-next-line rxjs/finnish
$: F['defaultState'] extends boolean
? LiveData<boolean>
: LiveData<boolean> | LiveData<boolean | undefined>;
} & F;
export class Flags extends Entity {
private readonly globalState = this.globalStateService.globalState;
constructor(private readonly globalStateService: GlobalStateService) {
super();
Object.entries(AFFINE_FLAGS).forEach(([flagKey, flag]) => {
const configurable = flag.configurable ?? true;
const defaultState =
'defaultState' in flag ? flag.defaultState : undefined;
const item = {
...flag,
value: configurable
? (this.globalState.get<boolean>(FLAG_PREFIX + flagKey) ??
defaultState)
: defaultState,
set: (value: boolean) => {
if (!configurable) {
return;
}
this.globalState.set(FLAG_PREFIX + flagKey, value);
},
$: configurable
? LiveData.from<boolean | undefined>(
this.globalState.watch<boolean>(FLAG_PREFIX + flagKey),
undefined
).map(value => value ?? defaultState)
: LiveData.from(NEVER, defaultState),
} as Flag<typeof flag>;
Object.defineProperty(this, flagKey, {
get: () => {
return item;
},
});
});
}
}
export type FlagsExt = Flags & {
[K in keyof AFFINE_FLAGS]: Flag<AFFINE_FLAGS[K]>;
};

View File

@@ -0,0 +1,13 @@
import type { Framework } from '../../framework';
import { GlobalStateService } from '../storage';
import { Flags } from './entities/flags';
import { FeatureFlagService } from './services/feature-flag';
export { AFFINE_FLAGS } from './constant';
export type { Flag } from './entities/flags';
export { FeatureFlagService } from './services/feature-flag';
export type { FlagInfo } from './types';
export function configureFeatureFlagModule(framework: Framework) {
framework.service(FeatureFlagService).entity(Flags, [GlobalStateService]);
}

View File

@@ -0,0 +1,21 @@
import { OnEvent, Service } from '../../../framework';
import type { Workspace } from '../../workspace';
import { WorkspaceInitialized } from '../../workspace/events';
import { AFFINE_FLAGS } from '../constant';
import { Flags, type FlagsExt } from '../entities/flags';
@OnEvent(WorkspaceInitialized, e => e.setupBlocksuiteEditorFlags)
export class FeatureFlagService extends Service {
flags = this.framework.createEntity(Flags) as FlagsExt;
setupBlocksuiteEditorFlags(workspace: Workspace) {
for (const [key, flag] of Object.entries(AFFINE_FLAGS)) {
if (flag.category === 'blocksuite') {
const value = this.flags[key as keyof AFFINE_FLAGS].value;
if (value !== undefined) {
workspace.docCollection.awarenessStore.setFlag(flag.bsFlag, value);
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
type FeedbackType = 'discord' | 'email' | 'github';
export type FlagInfo = {
displayName: string;
description?: string;
configurable?: boolean;
defaultState?: boolean; // default to open and not controlled by user
feedbackType?: FeedbackType;
} & (
| {
category: 'affine';
}
| {
category: 'blocksuite';
bsFlag: keyof BlockSuiteFlags;
}
);

View File

@@ -1,6 +1,11 @@
import { createEvent } from '../../../framework';
import type { WorkspaceEngine } from '../entities/engine';
import type { Workspace } from '../entities/workspace';
export const WorkspaceEngineBeforeStart = createEvent<WorkspaceEngine>(
'WorkspaceEngineBeforeStart'
);
export const WorkspaceInitialized = createEvent<Workspace>(
'WorkspaceInitialized'
);

View File

@@ -1,6 +1,6 @@
export type { WorkspaceProfileInfo } from './entities/profile';
export { Workspace } from './entities/workspace';
export { WorkspaceEngineBeforeStart } from './events';
export { WorkspaceEngineBeforeStart, WorkspaceInitialized } from './events';
export { globalBlockSuiteSchema } from './global-schema';
export type { WorkspaceMetadata } from './metadata';
export type { WorkspaceOpenOptions } from './open-options';

View File

@@ -1,10 +1,10 @@
import { DebugLogger } from '@affine/debug';
import { setupEditorFlags } from '../../../atom';
import { fixWorkspaceVersion } from '../../../blocksuite';
import { Service } from '../../../framework';
import { ObjectPool } from '../../../utils';
import type { Workspace } from '../entities/workspace';
import { WorkspaceInitialized } from '../events';
import type { WorkspaceOpenOptions } from '../open-options';
import type { WorkspaceFlavourProvider } from '../providers/flavour';
import { WorkspaceScope } from '../scopes/workspace';
@@ -103,7 +103,7 @@ export class WorkspaceRepositoryService extends Service {
// apply compatibility fix
fixWorkspaceVersion(workspace.docCollection.doc);
setupEditorFlags(workspace.docCollection);
this.framework.emitEvent(WorkspaceInitialized, workspace);
this.profileRepo
.getProfile(openOptions.metadata)