feat(electron): add tray menu settings (#11437)

fix AF-2447
This commit is contained in:
pengx17
2025-04-03 15:56:52 +00:00
parent 0aeb3041b5
commit 8ce10e6d0a
19 changed files with 330 additions and 150 deletions

View File

@@ -25,14 +25,18 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
.catch(console.error); .catch(console.error);
}); });
events?.applicationMenu.openInSettingModal(activeTab => { events?.applicationMenu.openInSettingModal(({ activeTab, scrollAnchor }) => {
using currentWorkspace = getCurrentWorkspace(frameworkProvider); using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) { if (!currentWorkspace) {
return; return;
} }
const { workspace } = currentWorkspace; 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, activeTab: activeTab as unknown as SettingTab,
scrollAnchor,
}); });
}); });

View File

@@ -6,7 +6,10 @@ import {
configureDesktopApiModule, configureDesktopApiModule,
DesktopApiService, DesktopApiService,
} from '@affine/core/modules/desktop-api'; } 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 { configureFindInPageModule } from '@affine/core/modules/find-in-page';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage'; import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import { import {
@@ -27,6 +30,7 @@ export function setupModules() {
configureFindInPageModule(framework); configureFindInPageModule(framework);
configureDesktopApiModule(framework); configureDesktopApiModule(framework);
configureSpellCheckSettingModule(framework); configureSpellCheckSettingModule(framework);
configureTraySettingModule(framework);
configureDesktopBackupModule(framework); configureDesktopBackupModule(framework);
framework.impl(PopupWindowProvider, p => { framework.impl(PopupWindowProvider, p => {

View File

@@ -39,7 +39,9 @@ export function createApplicationMenu() {
label: `About ${app.getName()}`, label: `About ${app.getName()}`,
click: async () => { click: async () => {
await showMainWindow(); await showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('about'); applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
}, },
}, },
{ type: 'separator' }, { type: 'separator' },

View File

@@ -18,7 +18,9 @@ export const applicationMenuEvents = {
}; };
}, },
// todo: properly define the active tab type // 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); const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();

View File

@@ -3,5 +3,8 @@ import { Subject } from 'rxjs';
export const applicationMenuSubjects = { export const applicationMenuSubjects = {
newPageAction$: new Subject<'page' | 'edgeless'>(), newPageAction$: new Subject<'page' | 'edgeless'>(),
openJournal$: new Subject<void>(), openJournal$: new Subject<void>(),
openInSettingModal$: new Subject<string>(), openInSettingModal$: new Subject<{
activeTab: string;
scrollAnchor?: string;
}>(),
}; };

View File

@@ -342,18 +342,21 @@ function setupRecordingListeners() {
status?.status === 'create-block-failed' status?.status === 'create-block-failed'
) { ) {
// show the popup for 10s // show the popup for 10s
setTimeout(() => { setTimeout(
// check again if current status is still ready () => {
if ( // check again if current status is still ready
(recordingStatus$.value?.status === 'create-block-success' || if (
recordingStatus$.value?.status === 'create-block-failed') && (recordingStatus$.value?.status === 'create-block-success' ||
recordingStatus$.value.id === status.id recordingStatus$.value?.status === 'create-block-failed') &&
) { recordingStatus$.value.id === status.id
popup.hide().catch(err => { ) {
logger.error('failed to hide recording popup', err); popup.hide().catch(err => {
}); logger.error('failed to hide recording popup', err);
} });
}, 10_000); }
},
status?.status === 'create-block-failed' ? 30_000 : 10_000
);
} else if (!status) { } else if (!status) {
// status is removed, we should hide the popup // status is removed, we should hide the popup
popupManager popupManager
@@ -550,22 +553,40 @@ export async function stopRecording(id: number) {
return; 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(); file.end();
// Wait for file to finish writing
try { try {
await new Promise<void>((resolve, reject) => { await Promise.race([
file.on('finish', () => { new Promise<void>((resolve, reject) => {
// check if the file is empty file.on('finish', () => {
const stats = fs.statSync(file.path); // check if the file is empty
if (stats.size === 0) { const stats = fs.statSync(file.path);
logger.error(`Recording ${id} is empty`); if (stats.size === 0) {
reject(new Error('Recording is empty')); reject(new Error('Recording is empty'));
} return;
resolve(); }
}); resolve();
}); });
file.on('error', err => {
reject(err);
});
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('File writing timeout')), 10000)
),
]);
const recordingStatus = recordingStateMachine.dispatch({ const recordingStatus = recordingStateMachine.dispatch({
type: 'STOP_RECORDING', type: 'STOP_RECORDING',
id, id,
@@ -591,6 +612,11 @@ export async function stopRecording(id: number) {
return; return;
} }
return serializeRecordingStatus(recordingStatus); return serializeRecordingStatus(recordingStatus);
} finally {
// Clean up the file stream if it's still open
if (!file.closed) {
file.destroy();
}
} }
} }

View File

@@ -51,9 +51,17 @@ export const SpellCheckStateSchema = z.object({
}); });
export const SpellCheckStateKey = 'spellCheckState' as const; export const SpellCheckStateKey = 'spellCheckState' as const;
// eslint-disable-next-line no-redeclare // oxlint-disable-next-line no-redeclare
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>; export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
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<typeof MenubarStateSchema>;
export const MeetingSettingsKey = 'meetingSettings' as const; export const MeetingSettingsKey = 'meetingSettings' as const;
export const MeetingSettingsSchema = z.object({ export const MeetingSettingsSchema = z.object({
// global meeting feature control // global meeting feature control

View File

@@ -7,6 +7,7 @@ import {
nativeImage, nativeImage,
Tray, Tray,
} from 'electron'; } from 'electron';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils'; import { isMacOS } from '../../shared/utils';
import { applicationMenuSubjects } from '../application-menu'; import { applicationMenuSubjects } from '../application-menu';
@@ -22,9 +23,10 @@ import {
stopRecording, stopRecording,
updateApplicationsPing$, updateApplicationsPing$,
} from '../recording/feature'; } from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager'; import { getMainWindow } from '../windows-manager';
import { icons } from './icons'; import { icons } from './icons';
export interface TrayMenuConfigItem { export interface TrayMenuConfigItem {
label: string; label: string;
click?: () => void; click?: () => void;
@@ -81,7 +83,7 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
return menuConfig; return menuConfig;
} }
class TrayState { class TrayState implements Disposable {
tray: Tray | null = null; tray: Tray | null = null;
// tray's icon // tray's icon
@@ -94,6 +96,7 @@ class TrayState {
constructor() { constructor() {
this.icon.setTemplateImage(true); this.icon.setTemplateImage(true);
this.init();
} }
// sorry, no idea on better naming // sorry, no idea on better naming
@@ -133,85 +136,94 @@ class TrayState {
} }
getRecordingMenuProvider(): TrayMenuProvider | null { getRecordingMenuProvider(): TrayMenuProvider | null {
if ( if (!checkRecordingAvailable()) {
!checkRecordingAvailable() ||
!checkScreenRecordingPermission() ||
!MeetingsSettingsState.value.enabled
) {
return null; return null;
} }
const getConfig = () => { const getConfig = () => {
const appGroups = appGroups$.value; const items: TrayMenuConfig = [];
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
const recordingStatus = recordingStatus$.value;
if ( if (
!recordingStatus || checkScreenRecordingPermission() &&
(recordingStatus?.status !== 'paused' && MeetingsSettingsState.value.enabled
recordingStatus?.status !== 'recording')
) { ) {
const appMenuItems = runningAppGroups.map(appGroup => ({ const appGroups = appGroups$.value;
label: appGroup.name, const runningAppGroups = appGroups.filter(
icon: appGroup.icon || undefined, appGroup => appGroup.isRunning
click: () => { );
logger.info(
`User action: Start Recording Meeting (${appGroup.name})` const recordingStatus = recordingStatus$.value;
);
startRecording(appGroup); if (
}, !recordingStatus ||
})); (recordingStatus?.status !== 'paused' &&
return [ recordingStatus?.status !== 'recording')
{ ) {
label: 'Start Recording Meeting', const appMenuItems = runningAppGroups.map(appGroup => ({
icon: icons.record, label: appGroup.name,
submenu: [ icon: appGroup.icon || undefined,
{ click: () => {
label: 'System audio (all audio will be recorded)', logger.info(
icon: icons.monitor, `User action: Start Recording Meeting (${appGroup.name})`
click: () => { );
logger.info( startRecording(appGroup);
'User action: Start Recording Meeting (System audio)'
);
startRecording();
},
},
...appMenuItems,
],
},
...appMenuItems,
{
label: `Meetings Settings...`,
click: async () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('meetings');
}, },
}, }));
];
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);
});
},
}
);
}
} }
items.push({
const recordingLabel = recordingStatus.appGroup?.name label: `Meetings Settings...`,
? `Recording (${recordingStatus.appGroup?.name})` click: () => {
: 'Recording'; showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
// recording is either started or paused activeTab: 'meetings',
return [ });
{
label: recordingLabel,
icon: icons.recording,
disabled: true,
}, },
{ });
label: 'Stop',
click: () => { return items;
logger.info('User action: Stop Recording');
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
},
},
];
}; };
return { return {
@@ -237,11 +249,23 @@ class TrayState {
}); });
}, },
}, },
{
label: 'Menubar settings...',
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
});
},
},
{ {
label: `About ${app.getName()}`, label: `About ${app.getName()}`,
click: () => { click: () => {
showMainWindow(); showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('about'); applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
}, },
}, },
'separator', 'separator',
@@ -270,6 +294,12 @@ class TrayState {
return menu; return menu;
} }
disposables: (() => void)[] = [];
[Symbol.dispose]() {
this.disposables.forEach(d => d());
}
update() { update() {
if (!this.tray) { if (!this.tray) {
this.tray = new Tray(this.icon); this.tray = new Tray(this.icon);
@@ -287,8 +317,8 @@ class TrayState {
logger.debug('App groups updated, refreshing tray menu'); logger.debug('App groups updated, refreshing tray menu');
this.update(); this.update();
}); });
beforeAppQuit(() => {
logger.info('Cleaning up tray before app quit'); this.disposables.push(() => {
this.tray?.off('click', clickHandler); this.tray?.off('click', clickHandler);
this.tray?.destroy(); this.tray?.destroy();
appGroupsSubscription.unsubscribe(); appGroupsSubscription.unsubscribe();
@@ -311,12 +341,39 @@ class TrayState {
} }
} }
let _trayState: TrayState | undefined; const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
export const setupTrayState = () => { export const setupTrayState = () => {
if (!_trayState) { let _trayState: TrayState | undefined;
if (TraySettingsState.value.enabled) {
_trayState = new TrayState(); _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();
});
}; };

View File

@@ -4,17 +4,19 @@ import type { PropsWithChildren, ReactNode } from 'react';
import { wrapper, wrapperDisabled } from './share.css'; import { wrapper, wrapperDisabled } from './share.css';
interface SettingWrapperProps { interface SettingWrapperProps {
id?: string;
title?: ReactNode; title?: ReactNode;
disabled?: boolean; disabled?: boolean;
} }
export const SettingWrapper = ({ export const SettingWrapper = ({
id,
title, title,
children, children,
disabled, disabled,
}: PropsWithChildren<SettingWrapperProps>) => { }: PropsWithChildren<SettingWrapperProps>) => {
return ( return (
<div className={clsx(wrapper, disabled && wrapperDisabled)}> <div id={id} className={clsx(wrapper, disabled && wrapperDisabled)}>
{title ? <div className="title">{title}</div> : null} {title ? <div className="title">{title}</div> : null}
{children} {children}
</div> </div>

View File

@@ -6,6 +6,7 @@ import {
SettingWrapper, SettingWrapper,
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { LanguageMenu } from '@affine/core/components/affine/language-menu'; 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 { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; 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 (
<SettingWrapper
id="menubar"
title={t['com.affine.appearanceSettings.menubar.title']()}
>
<SettingRow
name={t['com.affine.appearanceSettings.menubar.toggle']()}
desc={t['com.affine.appearanceSettings.menubar.description']()}
>
<Switch
checked={enabled}
onChange={checked => traySettingService.setEnabled(checked)}
/>
</SettingRow>
</SettingWrapper>
);
};
export const AppearanceSettings = () => { export const AppearanceSettings = () => {
const t = useI18n(); const t = useI18n();
@@ -150,6 +173,8 @@ export const AppearanceSettings = () => {
)} )}
</SettingWrapper> </SettingWrapper>
) : null} ) : null}
{BUILD_CONFIG.isElectron ? <MenubarSetting /> : null}
</> </>
); );
}; };

View File

@@ -143,13 +143,11 @@ export const useGeneralSettingList = (): GeneralSettingList => {
interface GeneralSettingProps { interface GeneralSettingProps {
activeTab: SettingTab; activeTab: SettingTab;
scrollAnchor?: string;
onChangeSettingState: (settingState: SettingState) => void; onChangeSettingState: (settingState: SettingState) => void;
} }
export const GeneralSetting = ({ export const GeneralSetting = ({
activeTab, activeTab,
scrollAnchor,
onChangeSettingState, onChangeSettingState,
}: GeneralSettingProps) => { }: GeneralSettingProps) => {
switch (activeTab) { switch (activeTab) {
@@ -166,7 +164,7 @@ export const GeneralSetting = ({
case 'about': case 'about':
return <AboutAffine />; return <AboutAffine />;
case 'plans': case 'plans':
return <AFFiNEPricingPlans scrollAnchor={scrollAnchor} />; return <AFFiNEPricingPlans />;
case 'billing': case 'billing':
return <BillingSettings onChangeSettingState={onChangeSettingState} />; return <BillingSettings onChangeSettingState={onChangeSettingState} />;
case 'experimental-features': case 'experimental-features':

View File

@@ -11,7 +11,7 @@ import { CloudPlanLayout, PlanLayout } from './layout';
import { PlansSkeleton } from './skeleton'; import { PlansSkeleton } from './skeleton';
import * as styles from './style.css'; import * as styles from './style.css';
const Settings = ({ scrollAnchor }: { scrollAnchor?: string }) => { const Settings = () => {
const subscriptionService = useService(SubscriptionService); const subscriptionService = useService(SubscriptionService);
const prices = useLiveData(subscriptionService.prices.prices$); const prices = useLiveData(subscriptionService.prices.prices$);
@@ -24,23 +24,13 @@ const Settings = ({ scrollAnchor }: { scrollAnchor?: string }) => {
return <PlansSkeleton />; return <PlansSkeleton />;
} }
return ( return <PlanLayout cloud={<CloudPlans />} ai={<AIPlan />} />;
<PlanLayout
cloud={<CloudPlans />}
ai={<AIPlan />}
scrollAnchor={scrollAnchor}
/>
);
}; };
export const AFFiNEPricingPlans = ({ export const AFFiNEPricingPlans = () => {
scrollAnchor,
}: {
scrollAnchor?: string;
}) => {
return ( return (
<SWRErrorBoundary FallbackComponent={PlansErrorBoundary}> <SWRErrorBoundary FallbackComponent={PlansErrorBoundary}>
<Settings scrollAnchor={scrollAnchor} /> <Settings />
</SWRErrorBoundary> </SWRErrorBoundary>
); );
}; };

View File

@@ -8,11 +8,9 @@ import {
type HtmlHTMLAttributes, type HtmlHTMLAttributes,
type ReactNode, type ReactNode,
useCallback, useCallback,
useLayoutEffect,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom';
import * as styles from './layout.css'; import * as styles from './layout.css';
@@ -69,24 +67,12 @@ export const PricingCollapsible = ({
export interface PlanLayoutProps { export interface PlanLayoutProps {
cloud?: ReactNode; cloud?: ReactNode;
ai?: ReactNode; ai?: ReactNode;
scrollAnchor?: string;
} }
export const PlanLayout = ({ cloud, ai, scrollAnchor }: PlanLayoutProps) => { export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
const t = useI18n(); const t = useI18n();
const plansRootRef = useRef<HTMLDivElement>(null); const plansRootRef = useRef<HTMLDivElement>(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 ( return (
<div className={styles.plansLayoutRoot} ref={plansRootRef}> <div className={styles.plansLayoutRoot} ref={plansRootRef}>
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */} {/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */}

View File

@@ -26,6 +26,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom';
import { AccountSetting } from './account-setting'; import { AccountSetting } from './account-setting';
import { GeneralSetting } from './general-setting'; import { GeneralSetting } from './general-setting';
@@ -39,6 +40,7 @@ import { WorkspaceSetting } from './workspace-setting';
interface SettingProps extends ModalProps { interface SettingProps extends ModalProps {
activeTab?: SettingTab; activeTab?: SettingTab;
onCloseSetting: () => void; onCloseSetting: () => void;
scrollAnchor?: string;
} }
const isWorkspaceSetting = (key: string): boolean => const isWorkspaceSetting = (key: string): boolean =>
@@ -55,10 +57,11 @@ const CenteredLoading = () => {
const SettingModalInner = ({ const SettingModalInner = ({
activeTab: initialActiveTab = 'appearance', activeTab: initialActiveTab = 'appearance',
onCloseSetting, onCloseSetting,
scrollAnchor: initialScrollAnchor,
}: SettingProps) => { }: SettingProps) => {
const [settingState, setSettingState] = useState<SettingState>({ const [settingState, setSettingState] = useState<SettingState>({
activeTab: initialActiveTab, activeTab: initialActiveTab,
scrollAnchor: undefined, scrollAnchor: initialScrollAnchor,
}); });
const globalContextService = useService(GlobalContextService); const globalContextService = useService(GlobalContextService);
@@ -150,6 +153,18 @@ const SettingModalInner = ({
} }
}, [isSelfhosted, settingState.activeTab]); }, [isSelfhosted, settingState.activeTab]);
useEffect(() => {
if (settingState.scrollAnchor) {
flushSync(() => {
const target = modalContentRef.current?.querySelector(
`#${settingState.scrollAnchor}`
);
if (target) {
target.scrollIntoView();
}
});
}
}, [settingState]);
return ( return (
<FrameworkScope scope={currentServer.scope}> <FrameworkScope scope={currentServer.scope}>
<SettingSidebar <SettingSidebar
@@ -165,7 +180,6 @@ const SettingModalInner = ({
<div className={style.centerContainer}> <div className={style.centerContainer}>
<div ref={modalContentRef} className={style.content}> <div ref={modalContentRef} className={style.content}>
<Suspense fallback={<WorkspaceDetailSkeleton />}> <Suspense fallback={<WorkspaceDetailSkeleton />}>
{}
{settingState.activeTab === 'account' && {settingState.activeTab === 'account' &&
loginStatus === 'authenticated' ? ( loginStatus === 'authenticated' ? (
<AccountSetting onChangeSettingState={setSettingState} /> <AccountSetting onChangeSettingState={setSettingState} />
@@ -178,7 +192,6 @@ const SettingModalInner = ({
) : !isWorkspaceSetting(settingState.activeTab) ? ( ) : !isWorkspaceSetting(settingState.activeTab) ? (
<GeneralSetting <GeneralSetting
activeTab={settingState.activeTab} activeTab={settingState.activeTab}
scrollAnchor={settingState.scrollAnchor}
onChangeSettingState={setSettingState} onChangeSettingState={setSettingState}
/> />
) : null} ) : null}
@@ -223,6 +236,7 @@ const SettingModalInner = ({
export const SettingDialog = ({ export const SettingDialog = ({
close, close,
activeTab, activeTab,
scrollAnchor,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['setting']>) => { }: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['setting']>) => {
return ( return (
<Modal <Modal
@@ -242,7 +256,11 @@ export const SettingDialog = ({
onOpenChange={() => close()} onOpenChange={() => close()}
> >
<Suspense fallback={<CenteredLoading />}> <Suspense fallback={<CenteredLoading />}>
<SettingModalInner activeTab={activeTab} onCloseSetting={close} /> <SettingModalInner
activeTab={activeTab}
onCloseSetting={close}
scrollAnchor={scrollAnchor}
/>
</Suspense> </Suspense>
</Modal> </Modal>
); );

View File

@@ -38,4 +38,11 @@ export class WorkspaceDialogService extends Service {
}) })
); );
} }
closeAll() {
const dialogs = this.dialogs$.value;
dialogs.forEach(dialog => {
this.close(dialog.id);
});
}
} }

View File

@@ -9,6 +9,7 @@ import { CurrentUserDBEditorSettingProvider } from './impls/user-db';
import { EditorSettingProvider } from './provider/editor-setting-provider'; import { EditorSettingProvider } from './provider/editor-setting-provider';
import { EditorSettingService } from './services/editor-setting'; import { EditorSettingService } from './services/editor-setting';
import { SpellCheckSettingService } from './services/spell-check-setting'; import { SpellCheckSettingService } from './services/spell-check-setting';
import { TraySettingService } from './services/tray-settings';
export type { FontFamily } from './schema'; export type { FontFamily } from './schema';
export { EditorSettingSchema, fontStyleOptions } from './schema'; export { EditorSettingSchema, fontStyleOptions } from './schema';
export { EditorSettingService } from './services/editor-setting'; export { EditorSettingService } from './services/editor-setting';
@@ -30,3 +31,7 @@ export function configureSpellCheckSettingModule(framework: Framework) {
DesktopApiService, DesktopApiService,
]); ]);
} }
export function configureTraySettingModule(framework: Framework) {
framework.service(TraySettingService, [GlobalStateService]);
}

View File

@@ -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<MenubarStateSchema>(
MENUBAR_SETTING_KEY
),
null
).map(v => v ?? { enabled: true });
setEnabled(enabled: boolean) {
this.globalStateService.globalState.set(MENUBAR_SETTING_KEY, {
enabled,
});
}
}

View File

@@ -952,9 +952,21 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.appearanceSettings.sidebar.title"](): string; ["com.affine.appearanceSettings.sidebar.title"](): string;
/** /**
* `Customise your AFFiNE appearance` * `Customize your AFFiNE appearance`
*/ */
["com.affine.appearanceSettings.subtitle"](): string; ["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` * `Theme`
*/ */

View File

@@ -228,7 +228,10 @@
"com.affine.appearanceSettings.noisyBackground.description": "Use background noise effect on the sidebar.", "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.noisyBackground.title": "Noise background on the sidebar",
"com.affine.appearanceSettings.sidebar.title": "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.theme.title": "Theme",
"com.affine.appearanceSettings.title": "Appearance settings", "com.affine.appearanceSettings.title": "Appearance settings",
"com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.", "com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",