From 8ce10e6d0a288081f789fd402429dc5602e85f5d Mon Sep 17 00:00:00 2001 From: pengx17 Date: Thu, 3 Apr 2025 15:56:52 +0000 Subject: [PATCH] feat(electron): add tray menu settings (#11437) fix AF-2447 --- .../src/app/effects/events.ts | 8 +- .../src/app/effects/modules.ts | 6 +- .../src/main/application-menu/create.ts | 4 +- .../src/main/application-menu/index.ts | 4 +- .../src/main/application-menu/subject.ts | 5 +- .../electron/src/main/recording/feature.ts | 76 +++++-- .../electron/src/main/shared-state-schema.ts | 10 +- .../apps/electron/src/main/tray/index.ts | 215 +++++++++++------- .../components/setting-components/wrapper.tsx | 4 +- .../general-setting/appearance/index.tsx | 25 ++ .../dialogs/setting/general-setting/index.tsx | 4 +- .../setting/general-setting/plans/index.tsx | 18 +- .../setting/general-setting/plans/layout.tsx | 16 +- .../src/desktop/dialogs/setting/index.tsx | 26 ++- .../dialogs/services/workspace-dialog.ts | 7 + .../core/src/modules/editor-setting/index.ts | 5 + .../editor-setting/services/tray-settings.ts | 28 +++ packages/frontend/i18n/src/i18n.gen.ts | 14 +- packages/frontend/i18n/src/resources/en.json | 5 +- 19 files changed, 330 insertions(+), 150 deletions(-) create mode 100644 packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/events.ts b/packages/frontend/apps/electron-renderer/src/app/effects/events.ts index 775ff078cc..924629c699 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/events.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/events.ts @@ -25,14 +25,18 @@ export function setupEvents(frameworkProvider: FrameworkProvider) { .catch(console.error); }); - events?.applicationMenu.openInSettingModal(activeTab => { + events?.applicationMenu.openInSettingModal(({ activeTab, scrollAnchor }) => { using currentWorkspace = getCurrentWorkspace(frameworkProvider); if (!currentWorkspace) { return; } const { workspace } = currentWorkspace; - workspace.scope.get(WorkspaceDialogService).open('setting', { + const workspaceDialogService = workspace.scope.get(WorkspaceDialogService); + // close all other dialogs first + workspaceDialogService.closeAll(); + workspaceDialogService.open('setting', { activeTab: activeTab as unknown as SettingTab, + scrollAnchor, }); }); diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts index 6da9912ef4..31f483a85c 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts @@ -6,7 +6,10 @@ import { configureDesktopApiModule, DesktopApiService, } from '@affine/core/modules/desktop-api'; -import { configureSpellCheckSettingModule } from '@affine/core/modules/editor-setting'; +import { + configureSpellCheckSettingModule, + configureTraySettingModule, +} from '@affine/core/modules/editor-setting'; import { configureFindInPageModule } from '@affine/core/modules/find-in-page'; import { configureElectronStateStorageImpls } from '@affine/core/modules/storage'; import { @@ -27,6 +30,7 @@ export function setupModules() { configureFindInPageModule(framework); configureDesktopApiModule(framework); configureSpellCheckSettingModule(framework); + configureTraySettingModule(framework); configureDesktopBackupModule(framework); framework.impl(PopupWindowProvider, p => { diff --git a/packages/frontend/apps/electron/src/main/application-menu/create.ts b/packages/frontend/apps/electron/src/main/application-menu/create.ts index 0a9e862738..aa5808b388 100644 --- a/packages/frontend/apps/electron/src/main/application-menu/create.ts +++ b/packages/frontend/apps/electron/src/main/application-menu/create.ts @@ -39,7 +39,9 @@ export function createApplicationMenu() { label: `About ${app.getName()}`, click: async () => { await showMainWindow(); - applicationMenuSubjects.openInSettingModal$.next('about'); + applicationMenuSubjects.openInSettingModal$.next({ + activeTab: 'about', + }); }, }, { type: 'separator' }, diff --git a/packages/frontend/apps/electron/src/main/application-menu/index.ts b/packages/frontend/apps/electron/src/main/application-menu/index.ts index e4378a6513..2a3f1c6bb4 100644 --- a/packages/frontend/apps/electron/src/main/application-menu/index.ts +++ b/packages/frontend/apps/electron/src/main/application-menu/index.ts @@ -18,7 +18,9 @@ export const applicationMenuEvents = { }; }, // todo: properly define the active tab type - openInSettingModal: (fn: (activeTab: string) => void) => { + openInSettingModal: ( + fn: (props: { activeTab: string; scrollAnchor?: string }) => void + ) => { const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn); return () => { sub.unsubscribe(); diff --git a/packages/frontend/apps/electron/src/main/application-menu/subject.ts b/packages/frontend/apps/electron/src/main/application-menu/subject.ts index 8452466bdf..ae37013a1f 100644 --- a/packages/frontend/apps/electron/src/main/application-menu/subject.ts +++ b/packages/frontend/apps/electron/src/main/application-menu/subject.ts @@ -3,5 +3,8 @@ import { Subject } from 'rxjs'; export const applicationMenuSubjects = { newPageAction$: new Subject<'page' | 'edgeless'>(), openJournal$: new Subject(), - openInSettingModal$: new Subject(), + openInSettingModal$: new Subject<{ + activeTab: string; + scrollAnchor?: string; + }>(), }; diff --git a/packages/frontend/apps/electron/src/main/recording/feature.ts b/packages/frontend/apps/electron/src/main/recording/feature.ts index 6104975f74..560d1bfe4e 100644 --- a/packages/frontend/apps/electron/src/main/recording/feature.ts +++ b/packages/frontend/apps/electron/src/main/recording/feature.ts @@ -342,18 +342,21 @@ function setupRecordingListeners() { status?.status === 'create-block-failed' ) { // show the popup for 10s - setTimeout(() => { - // check again if current status is still ready - if ( - (recordingStatus$.value?.status === 'create-block-success' || - recordingStatus$.value?.status === 'create-block-failed') && - recordingStatus$.value.id === status.id - ) { - popup.hide().catch(err => { - logger.error('failed to hide recording popup', err); - }); - } - }, 10_000); + setTimeout( + () => { + // check again if current status is still ready + if ( + (recordingStatus$.value?.status === 'create-block-success' || + recordingStatus$.value?.status === 'create-block-failed') && + recordingStatus$.value.id === status.id + ) { + popup.hide().catch(err => { + logger.error('failed to hide recording popup', err); + }); + } + }, + status?.status === 'create-block-failed' ? 30_000 : 10_000 + ); } else if (!status) { // status is removed, we should hide the popup popupManager @@ -550,22 +553,40 @@ export async function stopRecording(id: number) { return; } - const { file } = recording; + const { file, stream } = recording; + + // First stop the audio stream to prevent more data coming in + try { + stream.stop(); + } catch (err) { + logger.error('Failed to stop audio stream', err); + } + + // End the file with a timeout file.end(); - // Wait for file to finish writing try { - await new Promise((resolve, reject) => { - file.on('finish', () => { - // check if the file is empty - const stats = fs.statSync(file.path); - if (stats.size === 0) { - logger.error(`Recording ${id} is empty`); - reject(new Error('Recording is empty')); - } - resolve(); - }); - }); + await Promise.race([ + new Promise((resolve, reject) => { + file.on('finish', () => { + // check if the file is empty + const stats = fs.statSync(file.path); + if (stats.size === 0) { + reject(new Error('Recording is empty')); + return; + } + resolve(); + }); + + file.on('error', err => { + reject(err); + }); + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('File writing timeout')), 10000) + ), + ]); + const recordingStatus = recordingStateMachine.dispatch({ type: 'STOP_RECORDING', id, @@ -591,6 +612,11 @@ export async function stopRecording(id: number) { return; } return serializeRecordingStatus(recordingStatus); + } finally { + // Clean up the file stream if it's still open + if (!file.closed) { + file.destroy(); + } } } diff --git a/packages/frontend/apps/electron/src/main/shared-state-schema.ts b/packages/frontend/apps/electron/src/main/shared-state-schema.ts index c0a5459c71..fcad2ecb78 100644 --- a/packages/frontend/apps/electron/src/main/shared-state-schema.ts +++ b/packages/frontend/apps/electron/src/main/shared-state-schema.ts @@ -51,9 +51,17 @@ export const SpellCheckStateSchema = z.object({ }); export const SpellCheckStateKey = 'spellCheckState' as const; -// eslint-disable-next-line no-redeclare +// oxlint-disable-next-line no-redeclare export type SpellCheckStateSchema = z.infer; +export const MenubarStateKey = 'menubarState' as const; +export const MenubarStateSchema = z.object({ + enabled: z.boolean().default(true), +}); + +// eslint-disable-next-line no-redeclare +export type MenubarStateSchema = z.infer; + export const MeetingSettingsKey = 'meetingSettings' as const; export const MeetingSettingsSchema = z.object({ // global meeting feature control diff --git a/packages/frontend/apps/electron/src/main/tray/index.ts b/packages/frontend/apps/electron/src/main/tray/index.ts index 8160d4c89b..cb11edcc71 100644 --- a/packages/frontend/apps/electron/src/main/tray/index.ts +++ b/packages/frontend/apps/electron/src/main/tray/index.ts @@ -7,6 +7,7 @@ import { nativeImage, Tray, } from 'electron'; +import { map, shareReplay } from 'rxjs'; import { isMacOS } from '../../shared/utils'; import { applicationMenuSubjects } from '../application-menu'; @@ -22,9 +23,10 @@ import { stopRecording, updateApplicationsPing$, } from '../recording/feature'; +import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema'; +import { globalStateStorage } from '../shared-storage/storage'; import { getMainWindow } from '../windows-manager'; import { icons } from './icons'; - export interface TrayMenuConfigItem { label: string; click?: () => void; @@ -81,7 +83,7 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] { return menuConfig; } -class TrayState { +class TrayState implements Disposable { tray: Tray | null = null; // tray's icon @@ -94,6 +96,7 @@ class TrayState { constructor() { this.icon.setTemplateImage(true); + this.init(); } // sorry, no idea on better naming @@ -133,85 +136,94 @@ class TrayState { } getRecordingMenuProvider(): TrayMenuProvider | null { - if ( - !checkRecordingAvailable() || - !checkScreenRecordingPermission() || - !MeetingsSettingsState.value.enabled - ) { + if (!checkRecordingAvailable()) { return null; } const getConfig = () => { - const appGroups = appGroups$.value; - const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning); - - const recordingStatus = recordingStatus$.value; - + const items: TrayMenuConfig = []; if ( - !recordingStatus || - (recordingStatus?.status !== 'paused' && - recordingStatus?.status !== 'recording') + checkScreenRecordingPermission() && + MeetingsSettingsState.value.enabled ) { - const appMenuItems = runningAppGroups.map(appGroup => ({ - label: appGroup.name, - icon: appGroup.icon || undefined, - click: () => { - logger.info( - `User action: Start Recording Meeting (${appGroup.name})` - ); - startRecording(appGroup); - }, - })); - return [ - { - label: 'Start Recording Meeting', - icon: icons.record, - submenu: [ - { - label: 'System audio (all audio will be recorded)', - icon: icons.monitor, - click: () => { - logger.info( - 'User action: Start Recording Meeting (System audio)' - ); - startRecording(); - }, - }, - ...appMenuItems, - ], - }, - ...appMenuItems, - { - label: `Meetings Settings...`, - click: async () => { - showMainWindow(); - applicationMenuSubjects.openInSettingModal$.next('meetings'); + const appGroups = appGroups$.value; + const runningAppGroups = appGroups.filter( + appGroup => appGroup.isRunning + ); + + const recordingStatus = recordingStatus$.value; + + if ( + !recordingStatus || + (recordingStatus?.status !== 'paused' && + recordingStatus?.status !== 'recording') + ) { + const appMenuItems = runningAppGroups.map(appGroup => ({ + label: appGroup.name, + icon: appGroup.icon || undefined, + click: () => { + logger.info( + `User action: Start Recording Meeting (${appGroup.name})` + ); + startRecording(appGroup); }, - }, - ]; + })); + + items.push( + { + label: 'Start Recording Meeting', + icon: icons.record, + submenu: [ + { + label: 'System audio (all audio will be recorded)', + icon: icons.monitor, + click: () => { + logger.info( + 'User action: Start Recording Meeting (System audio)' + ); + startRecording(); + }, + }, + ...appMenuItems, + ], + }, + ...appMenuItems + ); + } else { + const recordingLabel = recordingStatus.appGroup?.name + ? `Recording (${recordingStatus.appGroup?.name})` + : 'Recording'; + + // recording is either started or paused + items.push( + { + label: recordingLabel, + icon: icons.recording, + disabled: true, + }, + { + label: 'Stop', + click: () => { + logger.info('User action: Stop Recording'); + stopRecording(recordingStatus.id).catch(err => { + logger.error('Failed to stop recording:', err); + }); + }, + } + ); + } } - - const recordingLabel = recordingStatus.appGroup?.name - ? `Recording (${recordingStatus.appGroup?.name})` - : 'Recording'; - - // recording is either started or paused - return [ - { - label: recordingLabel, - icon: icons.recording, - disabled: true, + items.push({ + label: `Meetings Settings...`, + click: () => { + showMainWindow(); + applicationMenuSubjects.openInSettingModal$.next({ + activeTab: 'meetings', + }); }, - { - label: 'Stop', - click: () => { - logger.info('User action: Stop Recording'); - stopRecording(recordingStatus.id).catch(err => { - logger.error('Failed to stop recording:', err); - }); - }, - }, - ]; + }); + + return items; }; return { @@ -237,11 +249,23 @@ class TrayState { }); }, }, + { + label: 'Menubar settings...', + click: () => { + showMainWindow(); + applicationMenuSubjects.openInSettingModal$.next({ + activeTab: 'appearance', + scrollAnchor: 'menubar', + }); + }, + }, { label: `About ${app.getName()}`, click: () => { showMainWindow(); - applicationMenuSubjects.openInSettingModal$.next('about'); + applicationMenuSubjects.openInSettingModal$.next({ + activeTab: 'about', + }); }, }, 'separator', @@ -270,6 +294,12 @@ class TrayState { return menu; } + disposables: (() => void)[] = []; + + [Symbol.dispose]() { + this.disposables.forEach(d => d()); + } + update() { if (!this.tray) { this.tray = new Tray(this.icon); @@ -287,8 +317,8 @@ class TrayState { logger.debug('App groups updated, refreshing tray menu'); this.update(); }); - beforeAppQuit(() => { - logger.info('Cleaning up tray before app quit'); + + this.disposables.push(() => { this.tray?.off('click', clickHandler); this.tray?.destroy(); appGroupsSubscription.unsubscribe(); @@ -311,12 +341,39 @@ class TrayState { } } -let _trayState: TrayState | undefined; +const TraySettingsState = { + $: globalStateStorage.watch(MenubarStateKey).pipe( + map(v => MenubarStateSchema.parse(v ?? {})), + shareReplay(1) + ), + + get value() { + return MenubarStateSchema.parse( + globalStateStorage.get(MenubarStateKey) ?? {} + ); + }, +}; export const setupTrayState = () => { - if (!_trayState) { + let _trayState: TrayState | undefined; + if (TraySettingsState.value.enabled) { _trayState = new TrayState(); - _trayState.init(); } - return _trayState; + + const updateTrayState = (state: MenubarStateSchema) => { + if (state.enabled) { + if (!_trayState) { + _trayState = new TrayState(); + } + } else { + _trayState?.[Symbol.dispose](); + _trayState = undefined; + } + }; + + const subscription = TraySettingsState.$.subscribe(updateTrayState); + + beforeAppQuit(() => { + subscription.unsubscribe(); + }); }; diff --git a/packages/frontend/component/src/components/setting-components/wrapper.tsx b/packages/frontend/component/src/components/setting-components/wrapper.tsx index bc602d1720..396b682af3 100644 --- a/packages/frontend/component/src/components/setting-components/wrapper.tsx +++ b/packages/frontend/component/src/components/setting-components/wrapper.tsx @@ -4,17 +4,19 @@ import type { PropsWithChildren, ReactNode } from 'react'; import { wrapper, wrapperDisabled } from './share.css'; interface SettingWrapperProps { + id?: string; title?: ReactNode; disabled?: boolean; } export const SettingWrapper = ({ + id, title, children, disabled, }: PropsWithChildren) => { return ( -
+
{title ?
{title}
: null} {children}
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx index a91a46221c..cc0b7fa59c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/appearance/index.tsx @@ -6,6 +6,7 @@ import { SettingWrapper, } from '@affine/component/setting-components'; import { LanguageMenu } from '@affine/core/components/affine/language-menu'; +import { TraySettingService } from '@affine/core/modules/editor-setting/services/tray-settings'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; @@ -58,6 +59,28 @@ export const ThemeSettings = () => { ); }; +const MenubarSetting = () => { + const t = useI18n(); + const traySettingService = useService(TraySettingService); + const { enabled } = useLiveData(traySettingService.setting$); + return ( + + + traySettingService.setEnabled(checked)} + /> + + + ); +}; + export const AppearanceSettings = () => { const t = useI18n(); @@ -150,6 +173,8 @@ export const AppearanceSettings = () => { )} ) : null} + + {BUILD_CONFIG.isElectron ? : null} ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx index 1972cfa4cc..890ee511e1 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx @@ -143,13 +143,11 @@ export const useGeneralSettingList = (): GeneralSettingList => { interface GeneralSettingProps { activeTab: SettingTab; - scrollAnchor?: string; onChangeSettingState: (settingState: SettingState) => void; } export const GeneralSetting = ({ activeTab, - scrollAnchor, onChangeSettingState, }: GeneralSettingProps) => { switch (activeTab) { @@ -166,7 +164,7 @@ export const GeneralSetting = ({ case 'about': return ; case 'plans': - return ; + return ; case 'billing': return ; case 'experimental-features': diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/index.tsx index d496a6b92a..b47be094d1 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/index.tsx @@ -11,7 +11,7 @@ import { CloudPlanLayout, PlanLayout } from './layout'; import { PlansSkeleton } from './skeleton'; import * as styles from './style.css'; -const Settings = ({ scrollAnchor }: { scrollAnchor?: string }) => { +const Settings = () => { const subscriptionService = useService(SubscriptionService); const prices = useLiveData(subscriptionService.prices.prices$); @@ -24,23 +24,13 @@ const Settings = ({ scrollAnchor }: { scrollAnchor?: string }) => { return ; } - return ( - } - ai={} - scrollAnchor={scrollAnchor} - /> - ); + return } ai={} />; }; -export const AFFiNEPricingPlans = ({ - scrollAnchor, -}: { - scrollAnchor?: string; -}) => { +export const AFFiNEPricingPlans = () => { return ( - + ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx index 4140dc4175..8596fb445d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx @@ -8,11 +8,9 @@ import { type HtmlHTMLAttributes, type ReactNode, useCallback, - useLayoutEffect, useRef, useState, } from 'react'; -import { flushSync } from 'react-dom'; import * as styles from './layout.css'; @@ -69,24 +67,12 @@ export const PricingCollapsible = ({ export interface PlanLayoutProps { cloud?: ReactNode; ai?: ReactNode; - scrollAnchor?: string; } -export const PlanLayout = ({ cloud, ai, scrollAnchor }: PlanLayoutProps) => { +export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { const t = useI18n(); const plansRootRef = useRef(null); - // TODO(@catsjuice): Need a better solution to handle this situation - useLayoutEffect(() => { - if (!scrollAnchor) return; - flushSync(() => { - const target = plansRootRef.current?.querySelector(`#${scrollAnchor}`); - if (target) { - target.scrollIntoView(); - } - }); - }, [scrollAnchor]); - return (
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */} diff --git a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx index 922d7cb35a..3e82527705 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx @@ -26,6 +26,7 @@ import { useRef, useState, } from 'react'; +import { flushSync } from 'react-dom'; import { AccountSetting } from './account-setting'; import { GeneralSetting } from './general-setting'; @@ -39,6 +40,7 @@ import { WorkspaceSetting } from './workspace-setting'; interface SettingProps extends ModalProps { activeTab?: SettingTab; onCloseSetting: () => void; + scrollAnchor?: string; } const isWorkspaceSetting = (key: string): boolean => @@ -55,10 +57,11 @@ const CenteredLoading = () => { const SettingModalInner = ({ activeTab: initialActiveTab = 'appearance', onCloseSetting, + scrollAnchor: initialScrollAnchor, }: SettingProps) => { const [settingState, setSettingState] = useState({ activeTab: initialActiveTab, - scrollAnchor: undefined, + scrollAnchor: initialScrollAnchor, }); const globalContextService = useService(GlobalContextService); @@ -150,6 +153,18 @@ const SettingModalInner = ({ } }, [isSelfhosted, settingState.activeTab]); + useEffect(() => { + if (settingState.scrollAnchor) { + flushSync(() => { + const target = modalContentRef.current?.querySelector( + `#${settingState.scrollAnchor}` + ); + if (target) { + target.scrollIntoView(); + } + }); + } + }, [settingState]); return (
}> - {} {settingState.activeTab === 'account' && loginStatus === 'authenticated' ? ( @@ -178,7 +192,6 @@ const SettingModalInner = ({ ) : !isWorkspaceSetting(settingState.activeTab) ? ( ) : null} @@ -223,6 +236,7 @@ const SettingModalInner = ({ export const SettingDialog = ({ close, activeTab, + scrollAnchor, }: DialogComponentProps) => { return ( close()} > }> - + ); diff --git a/packages/frontend/core/src/modules/dialogs/services/workspace-dialog.ts b/packages/frontend/core/src/modules/dialogs/services/workspace-dialog.ts index 8dccb2109f..58016e0ff5 100644 --- a/packages/frontend/core/src/modules/dialogs/services/workspace-dialog.ts +++ b/packages/frontend/core/src/modules/dialogs/services/workspace-dialog.ts @@ -38,4 +38,11 @@ export class WorkspaceDialogService extends Service { }) ); } + + closeAll() { + const dialogs = this.dialogs$.value; + dialogs.forEach(dialog => { + this.close(dialog.id); + }); + } } diff --git a/packages/frontend/core/src/modules/editor-setting/index.ts b/packages/frontend/core/src/modules/editor-setting/index.ts index 8095a04a8e..3a84354ae6 100644 --- a/packages/frontend/core/src/modules/editor-setting/index.ts +++ b/packages/frontend/core/src/modules/editor-setting/index.ts @@ -9,6 +9,7 @@ import { CurrentUserDBEditorSettingProvider } from './impls/user-db'; import { EditorSettingProvider } from './provider/editor-setting-provider'; import { EditorSettingService } from './services/editor-setting'; import { SpellCheckSettingService } from './services/spell-check-setting'; +import { TraySettingService } from './services/tray-settings'; export type { FontFamily } from './schema'; export { EditorSettingSchema, fontStyleOptions } from './schema'; export { EditorSettingService } from './services/editor-setting'; @@ -30,3 +31,7 @@ export function configureSpellCheckSettingModule(framework: Framework) { DesktopApiService, ]); } + +export function configureTraySettingModule(framework: Framework) { + framework.service(TraySettingService, [GlobalStateService]); +} diff --git a/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts b/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts new file mode 100644 index 0000000000..28797cc591 --- /dev/null +++ b/packages/frontend/core/src/modules/editor-setting/services/tray-settings.ts @@ -0,0 +1,28 @@ +import type { + MenubarStateKey, + MenubarStateSchema, +} from '@affine/electron/main/shared-state-schema'; +import { LiveData, Service } from '@toeverything/infra'; + +import type { GlobalStateService } from '../../storage'; + +const MENUBAR_SETTING_KEY: typeof MenubarStateKey = 'menubarState'; + +export class TraySettingService extends Service { + constructor(private readonly globalStateService: GlobalStateService) { + super(); + } + + setting$ = LiveData.from( + this.globalStateService.globalState.watch( + MENUBAR_SETTING_KEY + ), + null + ).map(v => v ?? { enabled: true }); + + setEnabled(enabled: boolean) { + this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, { + enabled, + }); + } +} diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index c11d1bd15e..82306b241b 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -952,9 +952,21 @@ export function useAFFiNEI18N(): { */ ["com.affine.appearanceSettings.sidebar.title"](): string; /** - * `Customise your AFFiNE appearance` + * `Customize your AFFiNE appearance` */ ["com.affine.appearanceSettings.subtitle"](): string; + /** + * `Menubar` + */ + ["com.affine.appearanceSettings.menubar.title"](): string; + /** + * `Enable menubar app` + */ + ["com.affine.appearanceSettings.menubar.toggle"](): string; + /** + * `Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.` + */ + ["com.affine.appearanceSettings.menubar.description"](): string; /** * `Theme` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 47e5d303bb..2a45421f5a 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -228,7 +228,10 @@ "com.affine.appearanceSettings.noisyBackground.description": "Use background noise effect on the sidebar.", "com.affine.appearanceSettings.noisyBackground.title": "Noise background on the sidebar", "com.affine.appearanceSettings.sidebar.title": "Sidebar", - "com.affine.appearanceSettings.subtitle": "Customise your AFFiNE appearance", + "com.affine.appearanceSettings.subtitle": "Customize your AFFiNE appearance", + "com.affine.appearanceSettings.menubar.title": "Menubar", + "com.affine.appearanceSettings.menubar.toggle": "Enable menubar app", + "com.affine.appearanceSettings.menubar.description": "Display the menubar app in the tray for quick access to AFFiNE or meeting recordings.", "com.affine.appearanceSettings.theme.title": "Theme", "com.affine.appearanceSettings.title": "Appearance settings", "com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",