L-Sun
2025-04-23 05:41:14 +00:00
parent 9b2cf5cafa
commit 27ff9ab9f4
21 changed files with 304 additions and 339 deletions

View File

@@ -4,7 +4,8 @@ import clsx from 'clsx';
import { useCallback } from 'react';
import { Button, IconButton } from '../../button';
import type { NotificationCardProps } from '../types';
import { FlexWrapper } from '../../layout/wrapper';
import type { NotificationActionProps, NotificationCardProps } from '../types';
import { getCardVars } from '../utils';
import * as styles from './styles.css';
@@ -18,10 +19,9 @@ export const DesktopNotificationCard = ({
icon = <InformationFillDuotoneIcon />,
iconColor,
thumb,
action,
actions,
error,
title,
footer,
alignMessage = 'title',
onDismiss,
rootAttrs,
@@ -33,13 +33,6 @@ export const DesktopNotificationCard = ({
? t[errorI18nKey](error?.data)
: undefined;
const onActionClicked = useCallback(() => {
action?.onClick()?.catch(console.error);
if (action?.autoClose !== false) {
onDismiss?.();
}
}, [action, onDismiss]);
return (
<div
style={getCardVars(style, theme, iconColor)}
@@ -56,18 +49,6 @@ export const DesktopNotificationCard = ({
</div>
) : null}
<div className={styles.title}>{title || errorTitle}</div>
{action ? (
<div className={clsx(styles.headAlignWrapper, styles.action)}>
<Button
className={styles.actionButton}
onClick={onActionClicked}
{...action.buttonProps}
>
{action.label}
</Button>
</div>
) : null}
<div
data-float={!!thumb}
className={clsx(styles.headAlignWrapper, styles.closeButton)}
@@ -83,8 +64,42 @@ export const DesktopNotificationCard = ({
<main data-align={alignMessage} className={styles.main}>
{notification.message}
</main>
<footer>{footer}</footer>
<footer>
<FlexWrapper marginTop={8} justifyContent="flex-end" gap="12px">
{actions?.map(action => (
<NotificationCardAction
key={action.key}
action={action}
onDismiss={onDismiss}
/>
))}
</FlexWrapper>
</footer>
</div>
</div>
);
};
const NotificationCardAction = ({
action,
onDismiss,
}: NotificationActionProps) => {
const onActionClicked = useCallback(() => {
action.onClick()?.catch(console.error);
if (action.autoClose !== false) {
onDismiss?.();
}
}, [action, onDismiss]);
return (
<Button
variant="plain"
data-testid={action.key}
className={styles.actionButton}
onClick={onActionClicked}
{...action.buttonProps}
>
{action.label}
</Button>
);
};

View File

@@ -55,14 +55,14 @@ export const title = style({
fontSize: 15,
marginRight: 10,
});
export const action = style({
marginRight: 16,
});
export const actionButton = style({
color: actionTextColor,
position: 'relative',
background: 'transparent',
border: 'none',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
});
export const closeButton = style({
selectors: {

View File

@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
import { Button, IconButton } from '../../button';
import { Modal } from '../../modal';
import type { NotificationCardProps } from '../types';
import type { NotificationActionProps, NotificationCardProps } from '../types';
import { getCardVars } from '../utils';
import * as styles from './styles.css';
@@ -70,8 +70,7 @@ const MobileNotifyDetail = ({
iconColor,
title,
message,
footer,
action,
actions,
error,
} = notification;
const t = useI18n();
@@ -87,12 +86,6 @@ const MobileNotifyDetail = ({
},
[onClose]
);
const onActionClicked = useCallback(() => {
action?.onClick()?.catch(console.error);
if (action?.autoClose !== false) {
onClose?.();
}
}, [action, onClose]);
return (
<Modal
@@ -114,14 +107,33 @@ const MobileNotifyDetail = ({
<main className={styles.detailContent}>{message}</main>
{/* actions */}
<div className={styles.detailActions}>
{action ? (
<Button onClick={onActionClicked} {...action.buttonProps}>
{action.label}
</Button>
) : null}
{footer}
{actions?.map(action => (
<NotificationCardAction
key={action.key}
action={action}
onDismiss={onClose}
/>
))}
</div>
</div>
</Modal>
);
};
const NotificationCardAction = ({
action,
onDismiss,
}: NotificationActionProps) => {
const onActionClicked = useCallback(() => {
action.onClick()?.catch(console.error);
if (action.autoClose !== false) {
onDismiss?.();
}
}, [action, onDismiss]);
return (
<Button onClick={onActionClicked} {...action.buttonProps}>
{action.label}
</Button>
);
};

View File

@@ -181,10 +181,13 @@ export const WithAction: StoryFn = () => {
),
style,
theme,
action: {
label: 'UNDO',
onClick: () => console.log('undo'),
},
actions: [
{
key: 'undo',
label: 'UNDO',
onClick: () => console.log('undo'),
},
],
})
}
>
@@ -204,11 +207,14 @@ export const WithAction: StoryFn = () => {
{
title: 'Disable auto close',
message: 'Test with disable auto close',
action: {
label: 'UNDO',
onClick: () => console.log('undo'),
autoClose: false,
},
actions: [
{
key: 'undo',
label: 'UNDO',
onClick: () => console.log('undo'),
autoClose: false,
},
],
},
{ duration: 22222222 }
);
@@ -296,25 +302,12 @@ export const DifferentSize: StoryFn = () => {
{ duration: 60000 }
);
};
const openWithFooter = () => {
notify(
{
title: 'With footer',
message: 'With basic title and one line message',
footer: (
<Button onClick={() => console.log('clicked')}>Click me</Button>
),
},
{ duration: 60000 }
);
};
return (
<Root style={{ display: 'flex', gap: 8 }}>
<Button onClick={openTiny}>Open Tiny</Button>
<Button onClick={openNormal}>Open Normal</Button>
<Button onClick={openLarge}>Open Large</Button>
<Button onClick={openWithThumb}>Open with thumb</Button>
<Button onClick={openWithFooter}>Open with footer</Button>
</Root>
);
};

View File

@@ -14,7 +14,8 @@ export interface Notification {
background?: string;
foreground?: string;
alignMessage?: 'title' | 'icon';
action?: {
actions?: {
key: string;
label: ReactNode;
onClick: (() => void) | (() => Promise<void>);
buttonProps?: ButtonProps;
@@ -22,7 +23,7 @@ export interface Notification {
* @default true
*/
autoClose?: boolean;
};
}[];
rootAttrs?: HTMLAttributes<HTMLDivElement>;
@@ -33,7 +34,6 @@ export interface Notification {
error?: UserFriendlyError;
icon?: ReactNode;
iconColor?: string;
footer?: ReactNode;
// events
onDismiss?: () => void;
@@ -50,3 +50,8 @@ export interface NotificationCustomRendererProps {
export interface NotificationCardProps extends HTMLAttributes<HTMLDivElement> {
notification: Notification;
}
export interface NotificationActionProps {
action: NonNullable<Notification['actions']>[number];
onDismiss: Notification['onDismiss'];
}

View File

@@ -109,12 +109,15 @@ export function copyAsImage(std: BlockStdScope) {
notify.error({
title: I18n.t('com.affine.copy.asImage.notAvailable.title'),
message: I18n.t('com.affine.copy.asImage.notAvailable.message'),
action: {
label: I18n.t('com.affine.copy.asImage.notAvailable.action'),
onClick: () => {
window.open('https://affine.pro/download');
actions: [
{
key: 'download',
label: I18n.t('com.affine.copy.asImage.notAvailable.action'),
onClick: () => {
window.open('https://affine.pro/download');
},
},
},
],
});
return;
}

View File

@@ -1,5 +1,6 @@
import {
Input,
type Notification,
notify,
toast,
type ToastOptions,
@@ -96,17 +97,25 @@ export function patchNotificationService({
throw new Error('Invalid notification accent');
}
const toAffineNotificationActions = (
actions: (typeof notification)['actions']
): Notification['actions'] => {
if (!actions) return undefined;
return actions.map(({ label, onClick, key }) => {
return {
key,
label: toReactNode(label),
onClick,
};
});
};
const toastId = fn(
{
title: toReactNode(notification.title),
message: toReactNode(notification.message),
footer: toReactNode(notification.footer),
action: notification.action?.onClick
? {
label: toReactNode(notification.action?.label),
onClick: notification.action.onClick,
}
: undefined,
actions: toAffineNotificationActions(notification.actions),
onDismiss: notification.onClose,
},
{

View File

@@ -20,10 +20,6 @@ export const thumbContent = style({
height: 'calc(100% + 4px)',
});
export const actionButton = style({
fontSize: cssVar('fontSm'),
lineHeight: '22px',
});
export const getStartedButtonText = style({
color: cssVar('textSecondaryColor'),
});

View File

@@ -1,4 +1,5 @@
import { Button, FlexWrapper, notify } from '@affine/component';
import { notify } from '@affine/component';
import { type Notification } from '@affine/component/ui/notification';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
@@ -63,6 +64,38 @@ export const AIOnboardingEdgeless = () => {
});
}, [workspaceDialogService]);
const actions = useMemo(() => {
const result: NonNullable<Notification['actions']> = [
{
key: 'get-started',
label: (
<span className={styles.getStartedButtonText}>
{t['com.affine.ai-onboarding.edgeless.get-started']()}
</span>
),
onClick: () => {
toggleEdgelessAIOnboarding(false);
},
},
];
if (!aiSubscription) {
result.push({
key: 'purchase',
label: (
<span className={styles.purchaseButtonText}>
{t['com.affine.ai-onboarding.edgeless.purchase']()}
</span>
),
onClick: () => {
goToPricingPlans();
toggleEdgelessAIOnboarding(false);
},
});
}
return result;
}, [aiSubscription, goToPricingPlans, t]);
useEffect(() => {
if (generalAIOnboardingOpened) return;
if (notifyId) return;
@@ -83,50 +116,13 @@ export const AIOnboardingEdgeless = () => {
thumb: <EdgelessOnboardingAnimation />,
alignMessage: 'icon',
onDismiss: () => toggleEdgelessAIOnboarding(false),
footer: (
<FlexWrapper marginTop={8} justifyContent="flex-end" gap="12px">
<Button
onClick={() => {
notify.dismiss(id);
toggleEdgelessAIOnboarding(false);
}}
variant="plain"
className={styles.actionButton}
>
<span className={styles.getStartedButtonText}>
{t['com.affine.ai-onboarding.edgeless.get-started']()}
</span>
</Button>
{aiSubscription ? null : (
<Button
className={styles.actionButton}
variant="plain"
onClick={() => {
goToPricingPlans();
notify.dismiss(id);
toggleEdgelessAIOnboarding(false);
}}
>
<span className={styles.purchaseButtonText}>
{t['com.affine.ai-onboarding.edgeless.purchase']()}
</span>
</Button>
)}
</FlexWrapper>
),
actions,
},
{ duration: 1000 * 60 * 10 }
);
edgelessNotifyId$.next(id);
}, 1000);
}, [
aiSubscription,
generalAIOnboardingOpened,
goToPricingPlans,
mode,
notifyId,
t,
]);
}, [actions, generalAIOnboardingOpened, mode, notifyId, t]);
return null;
};

View File

@@ -1,4 +1,4 @@
import { Button, notify } from '@affine/component';
import { type Notification, notify } from '@affine/component';
import {
RouteLogic,
useNavigateHelper,
@@ -8,7 +8,7 @@ import { useI18n } from '@affine/i18n';
import { AiIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { toggleLocalAIOnboarding } from './apis';
import * as styles from './local.dialog.css';
@@ -29,51 +29,41 @@ const LocalOnboardingAnimation = () => {
);
};
const FooterActions = ({ onDismiss }: { onDismiss: () => void }) => {
const t = useI18n();
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
const loggedIn = loginStatus === 'authenticated';
const { jumpToSignIn } = useNavigateHelper();
return (
<div className={styles.footerActions}>
<a href="https://ai.affine.pro" target="_blank" rel="noreferrer">
<Button
className={styles.actionButton}
variant="plain"
onClick={onDismiss}
>
{t['com.affine.ai-onboarding.local.action-learn-more']()}
</Button>
</a>
{loggedIn ? null : (
<Button
className={styles.actionButton}
variant="plain"
onClick={() => {
onDismiss();
jumpToSignIn('', RouteLogic.REPLACE, {}, { initCloud: 'true' });
}}
>
{t['com.affine.ai-onboarding.local.action-get-started']()}
</Button>
)}
</div>
);
};
export const AIOnboardingLocal = () => {
const t = useI18n();
const authService = useService(AuthService);
const notifyId = useLiveData(localNotifyId$);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { jumpToSignIn } = useNavigateHelper();
const loginStatus = useLiveData(authService.session.status$);
const notSignedIn = loginStatus !== 'authenticated';
const actions = useMemo(() => {
const result: NonNullable<Notification['actions']> = [
{
key: 'learn-more',
label: t['com.affine.ai-onboarding.local.action-learn-more'](),
onClick: () => {
window.open('https://ai.affine.pro', '_blank', 'noreferrer');
},
},
];
if (notSignedIn) {
result.push({
key: 'get-started',
label: t['com.affine.ai-onboarding.local.action-get-started'](),
onClick: () => {
jumpToSignIn('', RouteLogic.REPLACE, {}, { initCloud: 'true' });
},
});
}
return result;
}, [t, jumpToSignIn, notSignedIn]);
useEffect(() => {
if (!notSignedIn) return;
// if (!notSignedIn) return;
if (notifyId) return;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
@@ -95,21 +85,14 @@ export const AIOnboardingLocal = () => {
thumb: <LocalOnboardingAnimation />,
alignMessage: 'icon',
onDismiss: () => toggleLocalAIOnboarding(false),
footer: (
<FooterActions
onDismiss={() => {
toggleLocalAIOnboarding(false);
notify.dismiss(id);
}}
/>
),
actions,
rootAttrs: { className: styles.card },
},
{ duration: 1000 * 60 * 10 }
);
localNotifyId$.next(id);
}, 1000);
}, [notSignedIn, notifyId, t]);
}, [actions, notSignedIn, notifyId, t]);
return null;
};

View File

@@ -6,13 +6,6 @@ export const notifyHeader = style({
fontSize: 15,
});
export const notifyFooter = style({
display: 'flex',
justifyContent: 'end',
gap: 12,
paddingTop: 8,
});
export const actionButton = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,

View File

@@ -1,4 +1,4 @@
import { Button, notify } from '@affine/component';
import { type Notification, notify } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import { useCallback, useRef } from 'react';
@@ -7,48 +7,9 @@ import {
actionButton,
cancelButton,
confirmButton,
notifyFooter,
notifyHeader,
} from './notify.css';
interface SubscriptionChangedNotifyFooterProps {
onCancel: () => void;
onConfirm?: () => void;
to: string;
okText: string;
cancelText: string;
}
const SubscriptionChangedNotifyFooter = ({
to,
okText,
cancelText,
onCancel,
onConfirm,
}: SubscriptionChangedNotifyFooterProps) => {
return (
<div className={notifyFooter}>
<Button
className={clsx(actionButton, cancelButton)}
size={'default'}
onClick={onCancel}
variant="plain"
>
{cancelText}
</Button>
<a href={to} target="_blank" rel="noreferrer">
<Button
onClick={onConfirm}
className={clsx(actionButton, confirmButton)}
variant="plain"
>
{okText}
</Button>
</a>
</div>
);
};
export const useDowngradeNotify = () => {
const t = useI18n();
const prevNotifyIdRef = useRef<string | number | null>(null);
@@ -56,6 +17,30 @@ export const useDowngradeNotify = () => {
return useCallback(
(link: string) => {
prevNotifyIdRef.current && notify.dismiss(prevNotifyIdRef.current);
const actions: Notification['actions'] = [
{
key: 'later',
label: t['com.affine.payment.downgraded-notify.later'](),
onClick: () => {},
buttonProps: {
className: clsx(actionButton, cancelButton),
},
},
{
key: 'ok',
label: BUILD_CONFIG.isElectron
? t['com.affine.payment.downgraded-notify.ok-client']()
: t['com.affine.payment.downgraded-notify.ok-web'](),
onClick: () => {
window.open(link, '_blank', 'noreferrer');
},
buttonProps: {
className: clsx(actionButton, confirmButton),
},
},
];
const id = notify(
{
title: (
@@ -66,19 +51,7 @@ export const useDowngradeNotify = () => {
message: t['com.affine.payment.downgraded-notify.content'](),
alignMessage: 'title',
icon: null,
footer: (
<SubscriptionChangedNotifyFooter
to={link}
okText={
BUILD_CONFIG.isElectron
? t['com.affine.payment.downgraded-notify.ok-client']()
: t['com.affine.payment.downgraded-notify.ok-web']()
}
cancelText={t['com.affine.payment.downgraded-notify.later']()}
onCancel={() => notify.dismiss(id)}
onConfirm={() => notify.dismiss(id)}
/>
),
actions,
},
{ duration: 24 * 60 * 60 * 1000 }
);

View File

@@ -43,10 +43,13 @@ export const OverCapacityNotification = () => {
title: t['com.affine.payment.storage-limit.new-title'](),
message:
t['com.affine.payment.storage-limit.new-description.owner'](),
action: {
label: t['com.affine.payment.upgrade'](),
onClick: jumpToPricePlan,
},
actions: [
{
key: 'upgrade',
label: t['com.affine.payment.upgrade'](),
onClick: jumpToPricePlan,
},
],
});
} else {
notify.warning({

View File

@@ -87,14 +87,17 @@ const BackupWorkspaceItem = ({ item }: { item: BackupWorkspaceItem }) => {
}
notify.success({
title: t['com.affine.settings.workspace.backup.import.success'](),
action: {
label:
t['com.affine.settings.workspace.backup.import.success.action'](),
onClick: () => {
jumpToPage(workspaceId, 'all');
actions: [
{
key: 'open',
label:
t['com.affine.settings.workspace.backup.import.success.action'](),
onClick: () => {
jumpToPage(workspaceId, 'all');
},
autoClose: false,
},
autoClose: false,
},
],
});
setMenuOpen(false);
setImporting(false);

View File

@@ -459,45 +459,49 @@ export class AtMenuConfigService extends Service {
]({
username,
}),
action: {
label: 'Invite',
onClick: async () => {
track.$.sharePanel.$.inviteUserDocRole({
control: 'member list',
role: 'reader',
});
try {
await this.docGrantedUsersService.updateUserRole(
id,
DocRole.Reader
);
await notificationService.mentionUser(
id,
workspaceId,
{
id: docId,
title:
this.docDisplayMetaService.title$(docId).value,
blockId: block.blockId,
mode: mode as GraphqlDocMode,
}
);
notify.success({
title: I18n.t(
'com.affine.editor.at-menu.invited-and-notified'
),
actions: [
{
key: 'invite',
label: 'Invite',
onClick: async () => {
track.$.sharePanel.$.inviteUserDocRole({
control: 'member list',
role: 'reader',
});
} catch (error) {
const err = UserFriendlyError.fromAny(error);
notify.error({
title: I18n[`error.${err.name}`](err.data),
});
}
try {
await this.docGrantedUsersService.updateUserRole(
id,
DocRole.Reader
);
await notificationService.mentionUser(
id,
workspaceId,
{
id: docId,
title:
this.docDisplayMetaService.title$(docId)
.value,
blockId: block.blockId,
mode: mode as GraphqlDocMode,
}
);
notify.success({
title: I18n.t(
'com.affine.editor.at-menu.invited-and-notified'
),
});
} catch (error) {
const err = UserFriendlyError.fromAny(error);
notify.error({
title: I18n[`error.${err.name}`](err.data),
});
}
},
},
},
],
});
} else {
notify.error({