feat(electron): audio capture permissions and settings (#11185)

fix AF-2420, AF-2391, AF-2265
This commit is contained in:
pengx17
2025-03-28 09:12:25 +00:00
parent 8c582122a8
commit 6c125d9a38
59 changed files with 2661 additions and 1699 deletions
@@ -8,6 +8,7 @@ import {
FolderIcon,
InformationIcon,
KeyboardIcon,
MeetingIcon,
NotificationIcon,
PenIcon,
} from '@blocksuite/icons/rc';
@@ -23,6 +24,7 @@ import { BillingSettings } from './billing';
import { EditorSettings } from './editor';
import { ExperimentalFeatures } from './experimental-features';
import { PaymentIcon, UpgradeIcon } from './icons';
import { MeetingsSettings } from './meetings';
import { NotificationSettings } from './notifications';
import { AFFiNEPricingPlans } from './plans';
import { Shortcuts } from './shortcuts';
@@ -46,6 +48,9 @@ export const useGeneralSettingList = (): GeneralSettingList => {
const enableEditorSettings = useLiveData(
featureFlagService.flags.enable_editor_settings.$
);
const enableMeetings = useLiveData(
featureFlagService.flags.enable_meetings.$
);
useEffect(() => {
userFeatureService.userFeature.revalidate();
@@ -83,6 +88,15 @@ export const useGeneralSettingList = (): GeneralSettingList => {
});
}
if (enableMeetings) {
settings.push({
key: 'meetings',
title: t['com.affine.settings.meetings'](),
icon: <MeetingIcon />,
testId: 'meetings-panel-trigger',
});
}
if (hasPaymentFeature) {
settings.splice(4, 0, {
key: 'plans',
@@ -147,6 +161,8 @@ export const GeneralSetting = ({
return <EditorSettings />;
case 'appearance':
return <AppearanceSettings />;
case 'meetings':
return <MeetingsSettings />;
case 'about':
return <AboutAffine />;
case 'plans':
@@ -0,0 +1,254 @@
import {
IconButton,
Menu,
MenuItem,
MenuTrigger,
Switch,
useConfirmModal,
} from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
import type { MeetingSettingsSchema } from '@affine/electron/main/shared-state-schema';
import { useI18n } from '@affine/i18n';
import {
ArrowRightSmallIcon,
DoneIcon,
InformationFillDuotoneIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './styles.css';
const RecordingModes: MeetingSettingsSchema['recordingMode'][] = [
'prompt',
'auto-start',
'none',
];
const RecordingModeMenu = () => {
const meetingSettingsService = useService(MeetingSettingsService);
const settings = useLiveData(meetingSettingsService.settings$);
const t = useI18n();
const options = useMemo(() => {
return RecordingModes.map(mode => ({
label: t[`com.affine.settings.meetings.record.recording-mode.${mode}`](),
value: mode,
}));
}, [t]);
const currentMode = settings.recordingMode;
const handleRecordingModeChange = useCallback(
(mode: MeetingSettingsSchema['recordingMode']) => {
meetingSettingsService.setRecordingMode(mode);
},
[meetingSettingsService]
);
return (
<Menu
items={options.map(option => {
return (
<MenuItem
key={option.value}
title={option.label}
onSelect={() => handleRecordingModeChange(option.value)}
data-selected={currentMode === option.value}
>
{option.label}
</MenuItem>
);
})}
>
<MenuTrigger style={{ fontWeight: 600, width: '250px' }} block={true}>
{options.find(option => option.value === currentMode)?.label}
</MenuTrigger>
</Menu>
);
};
export const MeetingsSettings = () => {
const t = useI18n();
const meetingSettingsService = useService(MeetingSettingsService);
const settings = useLiveData(meetingSettingsService.settings$);
const [recordingFeatureAvailable, setRecordingFeatureAvailable] =
useState(false);
const [screenRecordingPermission, setScreenRecordingPermission] =
useState(false);
const confirmModal = useConfirmModal();
useEffect(() => {
meetingSettingsService
.isRecordingFeatureAvailable()
.then(available => {
setRecordingFeatureAvailable(available ?? false);
})
.catch(() => {
setRecordingFeatureAvailable(false);
});
meetingSettingsService
.checkScreenRecordingPermission()
.then(permission => {
setScreenRecordingPermission(permission ?? false);
})
.catch(err => console.log(err));
}, [meetingSettingsService]);
const handleEnabledChange = useAsyncCallback(
async (checked: boolean) => {
try {
await meetingSettingsService.setEnabled(checked);
} catch {
confirmModal.openConfirmModal({
title:
t['com.affine.settings.meetings.record.permission-modal.title'](),
description:
t[
'com.affine.settings.meetings.record.permission-modal.description'
](),
onConfirm: async () => {
await meetingSettingsService.showScreenRecordingPermissionSetting();
},
cancelText: t['com.affine.recording.dismiss'](),
confirmButtonOptions: {
variant: 'primary',
},
confirmText:
t[
'com.affine.settings.meetings.record.permission-modal.open-setting'
](),
});
}
},
[confirmModal, meetingSettingsService, t]
);
const handleAutoTranscriptionChange = useCallback(
(checked: boolean) => {
meetingSettingsService.setAutoTranscription(checked);
},
[meetingSettingsService]
);
const handleOpenScreenRecordingPermissionSetting =
useAsyncCallback(async () => {
await meetingSettingsService.showScreenRecordingPermissionSetting();
}, [meetingSettingsService]);
const handleOpenSavedRecordings = useAsyncCallback(async () => {
await meetingSettingsService.openSavedRecordings();
}, [meetingSettingsService]);
return (
<div className={styles.meetingWrapper}>
<SettingHeader title={t['com.affine.settings.meetings']()} />
<SettingRow
name={t['com.affine.settings.meetings.enable.title']()}
desc={t['com.affine.settings.meetings.enable.description']()}
>
<Switch
checked={settings.enabled}
onChange={handleEnabledChange}
data-testid="meetings-enable-switch"
/>
</SettingRow>
{recordingFeatureAvailable && (
<>
<SettingWrapper
disabled={!settings.enabled}
title={t['com.affine.settings.meetings.record.header']()}
>
<SettingRow
name={t['com.affine.settings.meetings.record.recording-mode']()}
desc={t[
'com.affine.settings.meetings.record.recording-mode.description'
]()}
>
<RecordingModeMenu />
</SettingRow>
<SettingRow
name={t['com.affine.settings.meetings.record.open-saved-file']()}
desc={t[
'com.affine.settings.meetings.record.open-saved-file.description'
]()}
>
<IconButton
icon={<ArrowRightSmallIcon />}
onClick={handleOpenSavedRecordings}
/>
</SettingRow>
</SettingWrapper>
<SettingWrapper
disabled={!settings.enabled}
title={t['com.affine.settings.meetings.transcription.header']()}
>
<SettingRow
name={t[
'com.affine.settings.meetings.transcription.auto-transcription'
]()}
desc={t[
'com.affine.settings.meetings.transcription.auto-transcription.description'
]()}
>
<Switch
checked={settings.autoTranscription}
onChange={handleAutoTranscriptionChange}
data-testid="meetings-auto-transcription-switch"
/>
</SettingRow>
</SettingWrapper>
<SettingWrapper
title={t['com.affine.settings.meetings.privacy.header']()}
>
<SettingRow
name={t[
'com.affine.settings.meetings.privacy.screen-system-audio-recording'
]()}
desc={
<>
{t[
'com.affine.settings.meetings.privacy.screen-system-audio-recording.description'
]()}
{!screenRecordingPermission && (
<span
onClick={handleOpenScreenRecordingPermissionSetting}
className={styles.permissionSetting}
>
{t[
'com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting'
]()}
</span>
)}
</>
}
>
<IconButton
icon={
screenRecordingPermission ? (
<DoneIcon />
) : (
<InformationFillDuotoneIcon
className={styles.noPermissionIcon}
/>
)
}
onClick={handleOpenScreenRecordingPermissionSetting}
/>
</SettingRow>
</SettingWrapper>
</>
)}
</div>
);
};
@@ -0,0 +1,18 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const meetingWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
});
export const permissionSetting = style({
color: cssVarV2('text/link'),
cursor: 'pointer',
marginLeft: 4,
});
export const noPermissionIcon = style({
color: cssVarV2('button/error'),
});