feat(core): add notifications settings (#11004)

This commit is contained in:
EYHN
2025-03-20 03:52:56 +00:00
parent 46ed76ecb0
commit 120e193c58
14 changed files with 334 additions and 6 deletions

View File

@@ -0,0 +1,8 @@
query getUserSettings {
currentUser {
settings {
receiveInvitationEmail
receiveMentionEmail
}
}
}

View File

@@ -850,6 +850,19 @@ export const getUserFeaturesQuery = {
}`, }`,
}; };
export const getUserSettingsQuery = {
id: 'getUserSettingsQuery' as const,
op: 'getUserSettings',
query: `query getUserSettings {
currentUser {
settings {
receiveInvitationEmail
receiveMentionEmail
}
}
}`,
};
export const getUserQuery = { export const getUserQuery = {
id: 'getUserQuery' as const, id: 'getUserQuery' as const,
op: 'getUser', op: 'getUser',
@@ -1436,6 +1449,14 @@ export const updateUserProfileMutation = {
}`, }`,
}; };
export const updateUserSettingsMutation = {
id: 'updateUserSettingsMutation' as const,
op: 'updateUserSettings',
query: `mutation updateUserSettings($input: UpdateSettingsInput!) {
updateSettings(input: $input)
}`,
};
export const uploadAvatarMutation = { export const uploadAvatarMutation = {
id: 'uploadAvatarMutation' as const, id: 'uploadAvatarMutation' as const,
op: 'uploadAvatar', op: 'uploadAvatar',

View File

@@ -0,0 +1,3 @@
mutation updateUserSettings($input: UpdateSettingsInput!) {
updateSettings(input: $input)
}

View File

@@ -3213,6 +3213,20 @@ export type GetUserFeaturesQuery = {
} | null; } | null;
}; };
export type GetUserSettingsQueryVariables = Exact<{ [key: string]: never }>;
export type GetUserSettingsQuery = {
__typename?: 'Query';
currentUser: {
__typename?: 'UserType';
settings: {
__typename?: 'SettingsType';
receiveInvitationEmail: boolean;
receiveMentionEmail: boolean;
};
} | null;
};
export type GetUserQueryVariables = Exact<{ export type GetUserQueryVariables = Exact<{
email: Scalars['String']['input']; email: Scalars['String']['input'];
}>; }>;
@@ -3839,6 +3853,15 @@ export type UpdateUserProfileMutation = {
updateProfile: { __typename?: 'UserType'; id: string; name: string }; updateProfile: { __typename?: 'UserType'; id: string; name: string };
}; };
export type UpdateUserSettingsMutationVariables = Exact<{
input: UpdateSettingsInput;
}>;
export type UpdateUserSettingsMutation = {
__typename?: 'Mutation';
updateSettings: boolean;
};
export type UploadAvatarMutationVariables = Exact<{ export type UploadAvatarMutationVariables = Exact<{
avatar: Scalars['Upload']['input']; avatar: Scalars['Upload']['input'];
}>; }>;
@@ -4230,6 +4253,11 @@ export type Queries =
variables: GetUserFeaturesQueryVariables; variables: GetUserFeaturesQueryVariables;
response: GetUserFeaturesQuery; response: GetUserFeaturesQuery;
} }
| {
name: 'getUserSettingsQuery';
variables: GetUserSettingsQueryVariables;
response: GetUserSettingsQuery;
}
| { | {
name: 'getUserQuery'; name: 'getUserQuery';
variables: GetUserQueryVariables; variables: GetUserQueryVariables;
@@ -4647,6 +4675,11 @@ export type Mutations =
variables: UpdateUserProfileMutationVariables; variables: UpdateUserProfileMutationVariables;
response: UpdateUserProfileMutation; response: UpdateUserProfileMutation;
} }
| {
name: 'updateUserSettingsMutation';
variables: UpdateUserSettingsMutationVariables;
response: UpdateUserSettingsMutation;
}
| { | {
name: 'uploadAvatarMutation'; name: 'uploadAvatarMutation';
variables: UploadAvatarMutationVariables; variables: UploadAvatarMutationVariables;

View File

@@ -8,6 +8,7 @@ import {
FolderIcon, FolderIcon,
InformationIcon, InformationIcon,
KeyboardIcon, KeyboardIcon,
NotificationIcon,
PenIcon, PenIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
@@ -22,6 +23,7 @@ import { BillingSettings } from './billing';
import { EditorSettings } from './editor'; import { EditorSettings } from './editor';
import { ExperimentalFeatures } from './experimental-features'; import { ExperimentalFeatures } from './experimental-features';
import { PaymentIcon, UpgradeIcon } from './icons'; import { PaymentIcon, UpgradeIcon } from './icons';
import { NotificationSettings } from './notifications';
import { AFFiNEPricingPlans } from './plans'; import { AFFiNEPricingPlans } from './plans';
import { Shortcuts } from './shortcuts'; import { Shortcuts } from './shortcuts';
@@ -37,6 +39,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
FeatureFlagService, FeatureFlagService,
}); });
const status = useLiveData(authService.session.status$); const status = useLiveData(authService.session.status$);
const loggedIn = status === 'authenticated';
const hasPaymentFeature = useLiveData( const hasPaymentFeature = useLiveData(
serverService.server.features$.map(f => f?.payment) serverService.server.features$.map(f => f?.payment)
); );
@@ -62,6 +65,14 @@ export const useGeneralSettingList = (): GeneralSettingList => {
testId: 'shortcuts-panel-trigger', testId: 'shortcuts-panel-trigger',
}, },
]; ];
if (loggedIn) {
settings.push({
key: 'notifications',
title: t['com.affine.setting.notifications'](),
icon: <NotificationIcon />,
testId: 'notifications-panel-trigger',
});
}
if (enableEditorSettings) { if (enableEditorSettings) {
// add editor settings to second position // add editor settings to second position
settings.splice(1, 0, { settings.splice(1, 0, {
@@ -73,14 +84,14 @@ export const useGeneralSettingList = (): GeneralSettingList => {
} }
if (hasPaymentFeature) { if (hasPaymentFeature) {
settings.splice(3, 0, { settings.splice(4, 0, {
key: 'plans', key: 'plans',
title: t['com.affine.payment.title'](), title: t['com.affine.payment.title'](),
icon: <UpgradeIcon />, icon: <UpgradeIcon />,
testId: 'plans-panel-trigger', testId: 'plans-panel-trigger',
}); });
if (status === 'authenticated') { if (loggedIn) {
settings.splice(3, 0, { settings.splice(4, 0, {
key: 'billing', key: 'billing',
title: t['com.affine.payment.billing-setting.title'](), title: t['com.affine.payment.billing-setting.title'](),
icon: <PaymentIcon />, icon: <PaymentIcon />,
@@ -130,6 +141,8 @@ export const GeneralSetting = ({
switch (activeTab) { switch (activeTab) {
case 'shortcuts': case 'shortcuts':
return <Shortcuts />; return <Shortcuts />;
case 'notifications':
return <NotificationSettings />;
case 'editor': case 'editor':
return <EditorSettings />; return <EditorSettings />;
case 'appearance': case 'appearance':

View File

@@ -0,0 +1,101 @@
import { notify, Switch } from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import {
type UserSettings,
UserSettingsService,
} from '@affine/core/modules/cloud';
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './style.css';
export const NotificationSettings = () => {
const t = useI18n();
const userSettingsService = useService(UserSettingsService);
useEffect(() => {
userSettingsService.revalidate();
}, [userSettingsService]);
const userSettings = useLiveData(userSettingsService.userSettings$);
const [isMutating, setIsMutating] = useState(false);
const error = useLiveData(userSettingsService.error$);
const errorMessage = useMemo(() => {
if (error) {
console.log('error', error);
const userFriendlyError = UserFriendlyError.fromAny(error);
return t[`error.${userFriendlyError.name}`](userFriendlyError.data);
}
return null;
}, [error, t]);
const disable = !userSettings || isMutating;
const handleUpdate = useCallback(
(key: keyof UserSettings, value: boolean) => {
setIsMutating(true);
userSettingsService
.updateUserSettings({
[key]: value,
})
.catch(err => {
const userFriendlyError = UserFriendlyError.fromAny(err);
notify.error({
title: t[`error.${userFriendlyError.name}`](userFriendlyError.data),
});
})
.finally(() => {
setIsMutating(false);
});
},
[userSettingsService, t]
);
return (
<>
<SettingHeader
title={t['com.affine.setting.notifications.header.title']()}
/>
<SettingWrapper
title={t['com.affine.setting.notifications.email.title']()}
>
{errorMessage && (
<>
<div className={styles.errorMessage}>{errorMessage}</div>
<br />
</>
)}
<SettingRow
name={t['com.affine.setting.notifications.email.mention.title']()}
desc={t['com.affine.setting.notifications.email.mention.subtitle']()}
>
<Switch
data-testid="notification-email-mention-trigger"
checked={userSettings?.receiveMentionEmail ?? false}
disabled={disable}
onChange={checked => handleUpdate('receiveMentionEmail', checked)}
/>
</SettingRow>
<SettingRow
name={t['com.affine.setting.notifications.email.invites.title']()}
desc={t['com.affine.setting.notifications.email.invites.subtitle']()}
>
<Switch
data-testid="notification-email-invites-trigger"
checked={userSettings?.receiveInvitationEmail ?? false}
disabled={disable}
onChange={checked =>
handleUpdate('receiveInvitationEmail', checked)
}
/>
</SettingRow>
</SettingWrapper>
</>
);
};

View File

@@ -0,0 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const errorMessage = style({
color: cssVar('errorColor'),
});

View File

@@ -24,6 +24,10 @@ export { SubscriptionService } from './services/subscription';
export { UserCopilotQuotaService } from './services/user-copilot-quota'; export { UserCopilotQuotaService } from './services/user-copilot-quota';
export { UserFeatureService } from './services/user-feature'; export { UserFeatureService } from './services/user-feature';
export { UserQuotaService } from './services/user-quota'; export { UserQuotaService } from './services/user-quota';
export {
type UserSettings,
UserSettingsService,
} from './services/user-settings';
export { WorkspaceInvoicesService } from './services/workspace-invoices'; export { WorkspaceInvoicesService } from './services/workspace-invoices';
export { WorkspaceServerService } from './services/workspace-server'; export { WorkspaceServerService } from './services/workspace-server';
export { WorkspaceSubscriptionService } from './services/workspace-subscription'; export { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -71,6 +75,7 @@ import { SubscriptionService } from './services/subscription';
import { UserCopilotQuotaService } from './services/user-copilot-quota'; import { UserCopilotQuotaService } from './services/user-copilot-quota';
import { UserFeatureService } from './services/user-feature'; import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota'; import { UserQuotaService } from './services/user-quota';
import { UserSettingsService } from './services/user-settings';
import { WorkspaceInvoicesService } from './services/workspace-invoices'; import { WorkspaceInvoicesService } from './services/workspace-invoices';
import { WorkspaceServerService } from './services/workspace-server'; import { WorkspaceServerService } from './services/workspace-server';
import { WorkspaceSubscriptionService } from './services/workspace-subscription'; import { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -88,6 +93,7 @@ import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota'; import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
import { UserFeatureStore } from './stores/user-feature'; import { UserFeatureStore } from './stores/user-feature';
import { UserQuotaStore } from './stores/user-quota'; import { UserQuotaStore } from './stores/user-quota';
import { UserSettingsStore } from './stores/user-settings';
export function configureCloudModule(framework: Framework) { export function configureCloudModule(framework: Framework) {
configureDefaultAuthProvider(framework); configureDefaultAuthProvider(framework);
@@ -150,7 +156,9 @@ export function configureCloudModule(framework: Framework) {
.service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore]) .service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore])
.store(AcceptInviteStore, [GraphQLService]) .store(AcceptInviteStore, [GraphQLService])
.service(PublicUserService, [PublicUserStore]) .service(PublicUserService, [PublicUserStore])
.store(PublicUserStore, [GraphQLService]); .store(PublicUserStore, [GraphQLService])
.service(UserSettingsService, [UserSettingsStore])
.store(UserSettingsStore, [GraphQLService]);
framework framework
.scope(WorkspaceScope) .scope(WorkspaceScope)

View File

@@ -0,0 +1,62 @@
import {
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
smartRetry,
} from '@toeverything/infra';
import { catchError, EMPTY, mergeMap } from 'rxjs';
import type {
UpdateUserSettingsInput,
UserSettings,
UserSettingsStore,
} from '../stores/user-settings';
export type { UserSettings };
export class UserSettingsService extends Service {
constructor(private readonly store: UserSettingsStore) {
super();
}
userSettings$ = new LiveData<UserSettings | undefined>(undefined);
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<any | undefined>(undefined);
revalidate = effect(
exhaustMapWithTrailing(() => {
return fromPromise(() => {
return this.store.getUserSettings();
}).pipe(
smartRetry(),
mergeMap(settings => {
this.userSettings$.value = settings;
return EMPTY;
}),
catchError(error => {
this.error$.value = error;
return EMPTY;
}),
onStart(() => {
this.isLoading$.value = true;
}),
onComplete(() => {
this.isLoading$.value = false;
})
);
})
);
async updateUserSettings(settings: UpdateUserSettingsInput) {
await this.store.updateUserSettings(settings);
this.userSettings$.value = {
...this.userSettings$.value,
...(settings as UserSettings),
};
this.revalidate();
}
}

View File

@@ -0,0 +1,37 @@
import {
type GetUserSettingsQuery,
getUserSettingsQuery,
type UpdateSettingsInput,
updateUserSettingsMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export type UserSettings = NonNullable<
GetUserSettingsQuery['currentUser']
>['settings'];
export type UpdateUserSettingsInput = UpdateSettingsInput;
export class UserSettingsStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async getUserSettings(): Promise<UserSettings | undefined> {
const result = await this.gqlService.gql({
query: getUserSettingsQuery,
});
return result.currentUser?.settings;
}
async updateUserSettings(settings: UpdateUserSettingsInput) {
await this.gqlService.gql({
query: updateUserSettingsMutation,
variables: {
input: settings,
},
});
}
}

View File

@@ -4,6 +4,7 @@ import type { WorkspaceMetadata } from '../workspace';
export type SettingTab = export type SettingTab =
| 'shortcuts' | 'shortcuts'
| 'notifications'
| 'appearance' | 'appearance'
| 'about' | 'about'
| 'plans' | 'plans'

View File

@@ -6,7 +6,7 @@
"el-GR": 96, "el-GR": 96,
"en": 100, "en": 100,
"es-AR": 96, "es-AR": 96,
"es-CL": 98, "es-CL": 97,
"es": 96, "es": 96,
"fa": 96, "fa": 96,
"fr": 96, "fr": 96,
@@ -14,7 +14,7 @@
"it-IT": 96, "it-IT": 96,
"it": 1, "it": 1,
"ja": 96, "ja": 96,
"ko": 61, "ko": 60,
"pl": 96, "pl": 96,
"pt-BR": 96, "pt-BR": 96,
"ru": 96, "ru": 96,

View File

@@ -4651,6 +4651,34 @@ export function useAFFiNEI18N(): {
* `Search tags...` * `Search tags...`
*/ */
["com.affine.selector-tag.search.placeholder"](): string; ["com.affine.selector-tag.search.placeholder"](): string;
/**
* `Notifications`
*/
["com.affine.setting.notifications"](): string;
/**
* `Notifications`
*/
["com.affine.setting.notifications.header.title"](): string;
/**
* `Email notifications`
*/
["com.affine.setting.notifications.email.title"](): string;
/**
* `Mention`
*/
["com.affine.setting.notifications.email.mention.title"](): string;
/**
* `You will be notified through email when other members of the workspace @ you.`
*/
["com.affine.setting.notifications.email.mention.subtitle"](): string;
/**
* `Invites`
*/
["com.affine.setting.notifications.email.invites.title"](): string;
/**
* `Invitation related messages will be sent through emails.`
*/
["com.affine.setting.notifications.email.invites.subtitle"](): string;
/** /**
* `Account settings` * `Account settings`
*/ */

View File

@@ -1156,6 +1156,13 @@
"com.affine.selectPage.title": "Add include doc", "com.affine.selectPage.title": "Add include doc",
"com.affine.selector-collection.search.placeholder": "Search collections...", "com.affine.selector-collection.search.placeholder": "Search collections...",
"com.affine.selector-tag.search.placeholder": "Search tags...", "com.affine.selector-tag.search.placeholder": "Search tags...",
"com.affine.setting.notifications": "Notifications",
"com.affine.setting.notifications.header.title": "Notifications",
"com.affine.setting.notifications.email.title": "Email notifications",
"com.affine.setting.notifications.email.mention.title": "Mention",
"com.affine.setting.notifications.email.mention.subtitle": "You will be notified through email when other members of the workspace @ you.",
"com.affine.setting.notifications.email.invites.title": "Invites",
"com.affine.setting.notifications.email.invites.subtitle": "Invitation related messages will be sent through emails.",
"com.affine.setting.account": "Account settings", "com.affine.setting.account": "Account settings",
"com.affine.setting.account.delete": "Delete account", "com.affine.setting.account.delete": "Delete account",
"com.affine.setting.account.delete.message": "Permanently delete this account and the Workspace data backup in AFFiNE Cloud. This action can not be undone.", "com.affine.setting.account.delete.message": "Permanently delete this account and the Workspace data backup in AFFiNE Cloud. This action can not be undone.",