diff --git a/packages/common/graphql/src/graphql/get-user-settings.gql b/packages/common/graphql/src/graphql/get-user-settings.gql
new file mode 100644
index 0000000000..7dc59e57fa
--- /dev/null
+++ b/packages/common/graphql/src/graphql/get-user-settings.gql
@@ -0,0 +1,8 @@
+query getUserSettings {
+ currentUser {
+ settings {
+ receiveInvitationEmail
+ receiveMentionEmail
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts
index 5a8312bd0f..713c99b488 100644
--- a/packages/common/graphql/src/graphql/index.ts
+++ b/packages/common/graphql/src/graphql/index.ts
@@ -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 = {
id: 'getUserQuery' as const,
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 = {
id: 'uploadAvatarMutation' as const,
op: 'uploadAvatar',
diff --git a/packages/common/graphql/src/graphql/update-user-settings.gql b/packages/common/graphql/src/graphql/update-user-settings.gql
new file mode 100644
index 0000000000..6641496feb
--- /dev/null
+++ b/packages/common/graphql/src/graphql/update-user-settings.gql
@@ -0,0 +1,3 @@
+mutation updateUserSettings($input: UpdateSettingsInput!) {
+ updateSettings(input: $input)
+}
\ No newline at end of file
diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts
index abc1313c59..f5b6d8d993 100644
--- a/packages/common/graphql/src/schema.ts
+++ b/packages/common/graphql/src/schema.ts
@@ -3213,6 +3213,20 @@ export type GetUserFeaturesQuery = {
} | 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<{
email: Scalars['String']['input'];
}>;
@@ -3839,6 +3853,15 @@ export type UpdateUserProfileMutation = {
updateProfile: { __typename?: 'UserType'; id: string; name: string };
};
+export type UpdateUserSettingsMutationVariables = Exact<{
+ input: UpdateSettingsInput;
+}>;
+
+export type UpdateUserSettingsMutation = {
+ __typename?: 'Mutation';
+ updateSettings: boolean;
+};
+
export type UploadAvatarMutationVariables = Exact<{
avatar: Scalars['Upload']['input'];
}>;
@@ -4230,6 +4253,11 @@ export type Queries =
variables: GetUserFeaturesQueryVariables;
response: GetUserFeaturesQuery;
}
+ | {
+ name: 'getUserSettingsQuery';
+ variables: GetUserSettingsQueryVariables;
+ response: GetUserSettingsQuery;
+ }
| {
name: 'getUserQuery';
variables: GetUserQueryVariables;
@@ -4647,6 +4675,11 @@ export type Mutations =
variables: UpdateUserProfileMutationVariables;
response: UpdateUserProfileMutation;
}
+ | {
+ name: 'updateUserSettingsMutation';
+ variables: UpdateUserSettingsMutationVariables;
+ response: UpdateUserSettingsMutation;
+ }
| {
name: 'uploadAvatarMutation';
variables: UploadAvatarMutationVariables;
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 0aa6e2d935..871c74b5b1 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
@@ -8,6 +8,7 @@ import {
FolderIcon,
InformationIcon,
KeyboardIcon,
+ NotificationIcon,
PenIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
@@ -22,6 +23,7 @@ import { BillingSettings } from './billing';
import { EditorSettings } from './editor';
import { ExperimentalFeatures } from './experimental-features';
import { PaymentIcon, UpgradeIcon } from './icons';
+import { NotificationSettings } from './notifications';
import { AFFiNEPricingPlans } from './plans';
import { Shortcuts } from './shortcuts';
@@ -37,6 +39,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
FeatureFlagService,
});
const status = useLiveData(authService.session.status$);
+ const loggedIn = status === 'authenticated';
const hasPaymentFeature = useLiveData(
serverService.server.features$.map(f => f?.payment)
);
@@ -62,6 +65,14 @@ export const useGeneralSettingList = (): GeneralSettingList => {
testId: 'shortcuts-panel-trigger',
},
];
+ if (loggedIn) {
+ settings.push({
+ key: 'notifications',
+ title: t['com.affine.setting.notifications'](),
+ icon: ,
+ testId: 'notifications-panel-trigger',
+ });
+ }
if (enableEditorSettings) {
// add editor settings to second position
settings.splice(1, 0, {
@@ -73,14 +84,14 @@ export const useGeneralSettingList = (): GeneralSettingList => {
}
if (hasPaymentFeature) {
- settings.splice(3, 0, {
+ settings.splice(4, 0, {
key: 'plans',
title: t['com.affine.payment.title'](),
icon: ,
testId: 'plans-panel-trigger',
});
- if (status === 'authenticated') {
- settings.splice(3, 0, {
+ if (loggedIn) {
+ settings.splice(4, 0, {
key: 'billing',
title: t['com.affine.payment.billing-setting.title'](),
icon: ,
@@ -130,6 +141,8 @@ export const GeneralSetting = ({
switch (activeTab) {
case 'shortcuts':
return ;
+ case 'notifications':
+ return ;
case 'editor':
return ;
case 'appearance':
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/index.tsx
new file mode 100644
index 0000000000..1ea805b420
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/index.tsx
@@ -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 (
+ <>
+
+
+ {errorMessage && (
+ <>
+ {errorMessage}
+
+ >
+ )}
+
+ handleUpdate('receiveMentionEmail', checked)}
+ />
+
+
+
+ handleUpdate('receiveInvitationEmail', checked)
+ }
+ />
+
+
+ >
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/style.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/style.css.ts
new file mode 100644
index 0000000000..704e8be448
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/notifications/style.css.ts
@@ -0,0 +1,6 @@
+import { cssVar } from '@toeverything/theme';
+import { style } from '@vanilla-extract/css';
+
+export const errorMessage = style({
+ color: cssVar('errorColor'),
+});
diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts
index 195e70ae82..fa11f67815 100644
--- a/packages/frontend/core/src/modules/cloud/index.ts
+++ b/packages/frontend/core/src/modules/cloud/index.ts
@@ -24,6 +24,10 @@ export { SubscriptionService } from './services/subscription';
export { UserCopilotQuotaService } from './services/user-copilot-quota';
export { UserFeatureService } from './services/user-feature';
export { UserQuotaService } from './services/user-quota';
+export {
+ type UserSettings,
+ UserSettingsService,
+} from './services/user-settings';
export { WorkspaceInvoicesService } from './services/workspace-invoices';
export { WorkspaceServerService } from './services/workspace-server';
export { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -71,6 +75,7 @@ import { SubscriptionService } from './services/subscription';
import { UserCopilotQuotaService } from './services/user-copilot-quota';
import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota';
+import { UserSettingsService } from './services/user-settings';
import { WorkspaceInvoicesService } from './services/workspace-invoices';
import { WorkspaceServerService } from './services/workspace-server';
import { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -88,6 +93,7 @@ import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
import { UserFeatureStore } from './stores/user-feature';
import { UserQuotaStore } from './stores/user-quota';
+import { UserSettingsStore } from './stores/user-settings';
export function configureCloudModule(framework: Framework) {
configureDefaultAuthProvider(framework);
@@ -150,7 +156,9 @@ export function configureCloudModule(framework: Framework) {
.service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore])
.store(AcceptInviteStore, [GraphQLService])
.service(PublicUserService, [PublicUserStore])
- .store(PublicUserStore, [GraphQLService]);
+ .store(PublicUserStore, [GraphQLService])
+ .service(UserSettingsService, [UserSettingsStore])
+ .store(UserSettingsStore, [GraphQLService]);
framework
.scope(WorkspaceScope)
diff --git a/packages/frontend/core/src/modules/cloud/services/user-settings.ts b/packages/frontend/core/src/modules/cloud/services/user-settings.ts
new file mode 100644
index 0000000000..15cd26b9c2
--- /dev/null
+++ b/packages/frontend/core/src/modules/cloud/services/user-settings.ts
@@ -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(undefined);
+ isLoading$ = new LiveData(false);
+ error$ = new LiveData(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();
+ }
+}
diff --git a/packages/frontend/core/src/modules/cloud/stores/user-settings.ts b/packages/frontend/core/src/modules/cloud/stores/user-settings.ts
new file mode 100644
index 0000000000..29c4ca73e3
--- /dev/null
+++ b/packages/frontend/core/src/modules/cloud/stores/user-settings.ts
@@ -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 {
+ 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,
+ },
+ });
+ }
+}
diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts
index 02173d069e..96e5d35b87 100644
--- a/packages/frontend/core/src/modules/dialogs/constant.ts
+++ b/packages/frontend/core/src/modules/dialogs/constant.ts
@@ -4,6 +4,7 @@ import type { WorkspaceMetadata } from '../workspace';
export type SettingTab =
| 'shortcuts'
+ | 'notifications'
| 'appearance'
| 'about'
| 'plans'
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index 61ca20f799..3d2b8a9925 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -6,7 +6,7 @@
"el-GR": 96,
"en": 100,
"es-AR": 96,
- "es-CL": 98,
+ "es-CL": 97,
"es": 96,
"fa": 96,
"fr": 96,
@@ -14,7 +14,7 @@
"it-IT": 96,
"it": 1,
"ja": 96,
- "ko": 61,
+ "ko": 60,
"pl": 96,
"pt-BR": 96,
"ru": 96,
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index 1e4cae42c1..313a01a2bd 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -4651,6 +4651,34 @@ export function useAFFiNEI18N(): {
* `Search tags...`
*/
["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`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index aa661bddaf..f96453a24c 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -1156,6 +1156,13 @@
"com.affine.selectPage.title": "Add include doc",
"com.affine.selector-collection.search.placeholder": "Search collections...",
"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.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.",