feat(core): add more notification types (#11156)

This commit is contained in:
EYHN
2025-03-25 14:51:08 +08:00
committed by GitHub
parent a2e3d318ba
commit 36eb4991c9
16 changed files with 510 additions and 57 deletions

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@affine/i18n';
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useCallback } from 'react';
@@ -10,6 +11,7 @@ import * as styles from './styles.css';
export const DesktopNotificationCard = ({
notification,
}: NotificationCardProps) => {
const t = useI18n();
const {
theme = 'info',
style = 'normal',
@@ -17,6 +19,7 @@ export const DesktopNotificationCard = ({
iconColor,
thumb,
action,
error,
title,
footer,
alignMessage = 'title',
@@ -24,6 +27,12 @@ export const DesktopNotificationCard = ({
rootAttrs,
} = notification;
const errorI18nKey = error ? (`error.${error.name}` as const) : undefined;
const errorTitle =
errorI18nKey && errorI18nKey in t
? t[errorI18nKey](error?.data)
: undefined;
const onActionClicked = useCallback(() => {
action?.onClick()?.catch(console.error);
if (action?.autoClose !== false) {
@@ -46,7 +55,7 @@ export const DesktopNotificationCard = ({
{icon}
</div>
) : null}
<div className={styles.title}>{title}</div>
<div className={styles.title}>{title ?? errorTitle}</div>
{action ? (
<div className={clsx(styles.headAlignWrapper, styles.action)}>

View File

@@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import {
InformationFillDuotoneIcon,
SingleSelectCheckSolidIcon,
@@ -35,7 +36,16 @@ export function notify(notification: Notification, options?: ExternalToast) {
}, options);
}
notify.error = (notification: Notification, options?: ExternalToast) => {
notify.error = (
notification: Notification | UserFriendlyError,
options?: ExternalToast
) => {
if (notification instanceof UserFriendlyError) {
notification = {
error: notification,
};
}
return notify(
{
icon: <InformationFillDuotoneIcon />,

View File

@@ -1,3 +1,4 @@
import type { UserFriendlyError } from '@affine/error';
import type { HTMLAttributes, ReactNode } from 'react';
import type { ButtonProps } from '../button';
@@ -29,6 +30,7 @@ export interface Notification {
thumb?: ReactNode;
title?: ReactNode;
message?: ReactNode;
error?: UserFriendlyError;
icon?: ReactNode;
iconColor?: string;
footer?: ReactNode;

View File

@@ -1,3 +1,4 @@
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { toURLSearchParams } from '@affine/core/modules/navigation';
import { getOpenUrlInDesktopAppLink } from '@affine/core/modules/open-in-app';
import type { DocMode } from '@blocksuite/affine/model';
@@ -183,6 +184,25 @@ export function useNavigateHelper() {
[navigate]
);
const jumpToWorkspaceSettings = useCallback(
(
workspaceId: string,
tab?: SettingTab,
logic: RouteLogic = RouteLogic.PUSH
) => {
const searchParams = new URLSearchParams();
if (tab) {
searchParams.set('tab', tab);
}
return navigate(
`/workspace/${workspaceId}/settings?${searchParams.toString()}`,
{
replace: logic === RouteLogic.REPLACE,
}
);
},
[navigate]
);
return useMemo(
() => ({
jumpToPage,
@@ -198,6 +218,7 @@ export function useNavigateHelper() {
jumpToTag,
jumpToOpenInApp,
jumpToImportTemplate,
jumpToWorkspaceSettings,
}),
[
jumpToPage,
@@ -213,6 +234,7 @@ export function useNavigateHelper() {
jumpToTag,
jumpToOpenInApp,
jumpToImportTemplate,
jumpToWorkspaceSettings,
]
);
}

View File

@@ -34,6 +34,7 @@ export const itemContainer = style({
position: 'relative',
padding: '8px',
gap: '8px',
cursor: 'pointer',
selectors: {
[`&:hover:not([data-disabled="true"])`]: {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
@@ -81,9 +82,17 @@ export const itemDeleteButton = style({
export const itemNameLabel = style({
fontWeight: 'bold',
color: cssVarV2('text/primary'),
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
lineHeight: '22px',
selectors: {
[`&[data-inactived="true"]`]: {
color: cssVarV2('text/placeholder'),
},
},
});
export const itemActionButton = style({
width: 'fit-content',
});

View File

@@ -1,16 +1,31 @@
import { Avatar, IconButton, Scrollable, Skeleton } from '@affine/component';
import {
Avatar,
Button,
IconButton,
notify,
Scrollable,
Skeleton,
} from '@affine/component';
import { AcceptInviteService } from '@affine/core/modules/cloud';
import {
type Notification,
NotificationListService,
NotificationType,
} from '@affine/core/modules/notification';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import type { MentionNotificationBodyType } from '@affine/graphql';
import type {
InvitationAcceptedNotificationBodyType,
InvitationBlockedNotificationBodyType,
InvitationNotificationBodyType,
MentionNotificationBodyType,
} from '@affine/graphql';
import { i18nTime, Trans, useI18n } from '@affine/i18n';
import { DeleteIcon } from '@blocksuite/icons/rc';
import { CollaborationIcon, DeleteIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import * as styles from './list.style.css';
export const NotificationList = () => {
@@ -92,34 +107,24 @@ const NotificationItemSkeleton = () => {
};
const NotificationItem = ({ notification }: { notification: Notification }) => {
const notificationListService = useService(NotificationListService);
const t = useI18n();
const type = notification.type;
const handleDelete = useCallback(() => {
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
}, [notificationListService, notification.id]);
return (
return type === NotificationType.Mention ? (
<MentionNotificationItem notification={notification} />
) : type === NotificationType.InvitationAccepted ? (
<InvitationAcceptedNotificationItem notification={notification} />
) : type === NotificationType.Invitation ? (
<InvitationNotificationItem notification={notification} />
) : type === NotificationType.InvitationBlocked ? (
<InvitationBlockedNotificationItem notification={notification} />
) : (
<div className={styles.itemContainer}>
{type === NotificationType.Mention ? (
<MentionNotificationItem notification={notification} />
) : (
<>
<Avatar size={22} />
<div className={styles.itemNotSupported}>
{t['com.affine.notification.unsupported']()} ({type})
</div>
</>
)}
<IconButton
size={16}
className={styles.itemDeleteButton}
icon={<DeleteIcon />}
onClick={handleDelete}
/>
<Avatar size={22} />
<div className={styles.itemNotSupported}>
{t['com.affine.notification.unsupported']()} ({type})
</div>
<DeleteButton notification={notification} />
</div>
);
};
@@ -129,13 +134,30 @@ const MentionNotificationItem = ({
}: {
notification: Notification;
}) => {
const notificationListService = useService(NotificationListService);
const { jumpToPageBlock } = useNavigateHelper();
const t = useI18n();
const body = notification.body as MentionNotificationBodyType;
const memberInactived = !body.createdByUser;
const username =
body.createdByUser?.name ?? t['com.affine.inactive-member']();
const handleClick = useCallback(() => {
if (!body.workspace?.id) {
return;
}
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
jumpToPageBlock(
body.workspace.id,
body.doc.id,
body.doc.mode,
body.doc.blockId ? [body.doc.blockId] : undefined,
body.doc.elementId ? [body.doc.elementId] : undefined
);
}, [body, jumpToPageBlock, notificationListService, notification.id]);
return (
<>
<div className={styles.itemContainer} onClick={handleClick}>
<Avatar
size={22}
name={body.createdByUser?.name}
@@ -155,8 +177,9 @@ const MentionNotificationItem = ({
2: <b className={styles.itemNameLabel} />,
}}
values={{
username: username,
docTitle: body.doc.title ?? t['Untitled'](),
username:
body.createdByUser?.name ?? t['com.affine.inactive-member'](),
docTitle: body.doc.title || t['Untitled'](),
}}
/>
</span>
@@ -166,6 +189,286 @@ const MentionNotificationItem = ({
})}
</div>
</div>
</>
<DeleteButton notification={notification} />
</div>
);
};
const InvitationAcceptedNotificationItem = ({
notification,
}: {
notification: Notification;
}) => {
const notificationListService = useService(NotificationListService);
const { jumpToWorkspaceSettings } = useNavigateHelper();
const t = useI18n();
const body = notification.body as InvitationAcceptedNotificationBodyType;
const memberInactived = !body.createdByUser;
const handleClick = useCallback(() => {
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
if (!body.workspace?.id) {
return;
}
jumpToWorkspaceSettings(body.workspace.id, 'workspace:members');
}, [body, jumpToWorkspaceSettings, notification, notificationListService]);
return (
<div className={styles.itemContainer} onClick={handleClick}>
<Avatar
size={22}
name={body.createdByUser?.name}
url={body.createdByUser?.avatarUrl}
/>
<div className={styles.itemMain}>
<span>
<Trans
i18nKey={'com.affine.notification.invitation-accepted'}
components={{
1: (
<b
className={styles.itemNameLabel}
data-inactived={memberInactived}
/>
),
}}
values={{
username:
body.createdByUser?.name ?? t['com.affine.inactive-member'](),
}}
/>
</span>
<div className={styles.itemDate}>
{i18nTime(notification.createdAt, {
relative: true,
})}
</div>
</div>
<DeleteButton notification={notification} />
</div>
);
};
const InvitationBlockedNotificationItem = ({
notification,
}: {
notification: Notification;
}) => {
const notificationListService = useService(NotificationListService);
const { jumpToWorkspaceSettings } = useNavigateHelper();
const t = useI18n();
const body = notification.body as InvitationBlockedNotificationBodyType;
const workspaceInactived = !body.workspace;
const handleClick = useCallback(() => {
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
if (!body.workspace?.id) {
return;
}
jumpToWorkspaceSettings(body.workspace.id, 'workspace:members');
}, [body, jumpToWorkspaceSettings, notification, notificationListService]);
return (
<div className={styles.itemContainer} onClick={handleClick}>
<CollaborationIcon width={22} height={22} />
<div className={styles.itemMain}>
<span>
<Trans
i18nKey={'com.affine.notification.invitation-blocked'}
components={{
1: (
<b
className={styles.itemNameLabel}
data-inactived={workspaceInactived}
/>
),
}}
values={{
workspaceName:
body.workspace?.name ?? t['com.affine.inactive-workspace'](),
}}
/>
</span>
<div className={styles.itemDate}>
{i18nTime(notification.createdAt, {
relative: true,
})}
</div>
</div>
<DeleteButton notification={notification} />
</div>
);
};
const InvitationNotificationItem = ({
notification,
}: {
notification: Notification;
}) => {
const t = useI18n();
const body = notification.body as InvitationNotificationBodyType;
const memberInactived = !body.createdByUser;
const workspaceInactived = !body.workspace;
const workspacesService = useService(WorkspacesService);
const acceptInviteService = useService(AcceptInviteService);
const notificationListService = useService(NotificationListService);
const inviteId = body.inviteId;
const [isAccepting, setIsAccepting] = useState(false);
const { jumpToPage } = useNavigateHelper();
const WorkspaceNameWithIcon = useCallback(
({
children,
...props
}: React.PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>) => {
return (
<b className={styles.itemNameLabel} {...props}>
<CollaborationIcon width={20} height={20} />
{children}
</b>
);
},
[]
);
const handleReadAndOpenWorkspace = useCallback(() => {
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
if (!body.workspace?.id) {
return; // should never happen
}
jumpToPage(body.workspace.id, 'all');
}, [body, jumpToPage, notification.id, notificationListService]);
const handleAcceptInvite = useCallback(() => {
setIsAccepting(true);
acceptInviteService
.waitForAcceptInvite(inviteId)
.catch(err => {
const userFriendlyError = UserFriendlyError.fromAny(err);
if (userFriendlyError.is('ALREADY_IN_SPACE')) {
// ignore if the user is already in the workspace
return true;
}
throw err;
})
.then(async value => {
if (value === false) {
// invite is expired
notify.error({
title: t['com.affine.expired.page.title'](),
message: t['com.affine.expired.page.new-subtitle'](),
});
notificationListService
.readNotification(notification.id)
.catch(err => {
console.error(err);
});
return;
} else {
// invite is accepted
await workspacesService.list.waitForRevalidation();
handleReadAndOpenWorkspace();
}
})
.catch(err => {
const userFriendlyError = UserFriendlyError.fromAny(err);
notify.error(userFriendlyError);
})
.finally(() => {
setIsAccepting(false);
});
}, [
acceptInviteService,
handleReadAndOpenWorkspace,
inviteId,
notification,
notificationListService,
t,
workspacesService,
]);
return (
<div className={styles.itemContainer}>
<Avatar
size={22}
name={body.createdByUser?.name}
url={body.createdByUser?.avatarUrl}
/>
<div className={styles.itemMain}>
<span>
<Trans
i18nKey={'com.affine.notification.invitation'}
components={{
1: (
<b
className={styles.itemNameLabel}
data-inactived={memberInactived}
/>
),
2: <WorkspaceNameWithIcon data-inactived={workspaceInactived} />,
}}
values={{
username:
body.createdByUser?.name ?? t['com.affine.inactive-member'](),
workspaceName:
body.workspace?.name ?? t['com.affine.inactive-workspace'](),
}}
/>
</span>
{!workspaceInactived && (
<Button
variant="secondary"
className={styles.itemActionButton}
onClick={handleAcceptInvite}
loading={isAccepting}
>
{t['com.affine.notification.invitation.accept']()}
</Button>
)}
<div className={styles.itemDate}>
{i18nTime(notification.createdAt, {
relative: true,
})}
</div>
</div>
<DeleteButton notification={notification} />
</div>
);
};
const DeleteButton = ({
notification,
onClick,
}: {
notification: Notification;
onClick?: () => void;
}) => {
const notificationListService = useService(NotificationListService);
const handleDelete = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // prevent trigger the click event of the parent element
notificationListService.readNotification(notification.id).catch(err => {
console.error(err);
});
onClick?.();
},
[notificationListService, notification.id, onClick]
);
return (
<IconButton
size={16}
className={styles.itemDeleteButton}
icon={<DeleteIcon />}
onClick={handleDelete}
/>
);
};

View File

@@ -21,7 +21,7 @@ import {
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useState } from 'react';
import { useEffect, useState } from 'react';
export const ChangePasswordDialog = ({
close,
@@ -52,10 +52,12 @@ export const ChangePasswordDialog = ({
);
const serverName = useLiveData(server.config$.selector(c => c.serverName));
if (!email) {
// should not happen
throw new Unreachable();
}
useEffect(() => {
if (!account) {
// we are logged out, close the dialog
close();
}
}, [account, close]);
const onSendEmail = useAsyncCallback(async () => {
setLoading(true);

View File

@@ -3,6 +3,8 @@ import {
ExpiredPage,
JoinFailedPage,
} from '@affine/component/member-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
@@ -17,6 +19,7 @@ import { AcceptInviteService, AuthService } from '../../../modules/cloud';
const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
const { jumpToPage } = useNavigateHelper();
const acceptInviteService = useService(AcceptInviteService);
const workspacesService = useService(WorkspacesService);
const error = useLiveData(acceptInviteService.error$);
const inviteId = useLiveData(acceptInviteService.inviteId$);
const inviteInfo = useLiveData(acceptInviteService.inviteInfo$);
@@ -24,19 +27,20 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
const loading = useLiveData(acceptInviteService.loading$);
const navigateHelper = useNavigateHelper();
const openWorkspace = useCallback(() => {
const openWorkspace = useAsyncCallback(async () => {
if (!inviteInfo?.workspace.id) {
return;
}
await workspacesService.list.waitForRevalidation();
jumpToPage(inviteInfo.workspace.id, 'all', RouteLogic.REPLACE);
}, [jumpToPage, inviteInfo]);
}, [inviteInfo, workspacesService, jumpToPage]);
const onOpenAffine = useCallback(() => {
navigateHelper.jumpToIndex();
}, [navigateHelper]);
useEffect(() => {
acceptInviteService.revalidate({
acceptInviteService.acceptInvite({
inviteId: targetInviteId,
});
}, [acceptInviteService, targetInviteId]);
@@ -45,10 +49,10 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
if (error && inviteId === targetInviteId) {
const err = UserFriendlyError.fromAny(error);
if (err.is('ALREADY_IN_SPACE')) {
return navigateHelper.jumpToIndex();
return openWorkspace();
}
}
}, [error, inviteId, navigateHelper, targetInviteId]);
}, [error, inviteId, navigateHelper, openWorkspace, targetInviteId]);
if (loading || inviteId !== targetInviteId) {
return null;

View File

@@ -123,17 +123,20 @@ export const Component = (): ReactElement => {
// if workspace is not found, we should retry
const retryTimesRef = useRef(3);
useEffect(() => {
retryTimesRef.current = 3; // reset retry times
}, [params.workspaceId]);
if (params.workspaceId) {
retryTimesRef.current = 3; // reset retry times
workspacesService.list.revalidate();
}
}, [params.workspaceId, workspacesService]);
useEffect(() => {
if (listLoading === false && meta === undefined) {
const timer = setInterval(() => {
const timer = setTimeout(() => {
if (retryTimesRef.current > 0) {
workspacesService.list.revalidate();
retryTimesRef.current--;
}
}, 5000);
return () => clearInterval(timer);
return () => clearTimeout(timer);
}
return;
}, [listLoading, meta, workspaceNotFound, workspacesService]);

View File

@@ -0,0 +1,29 @@
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useService } from '@toeverything/infra';
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
export const Component = () => {
const workbenchService = useService(WorkbenchService);
const workspaceDialogService = useService(WorkspaceDialogService);
const workbench = workbenchService.workbench;
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? undefined;
const isOpened = useRef(false);
useEffect(() => {
if (isOpened.current) {
return;
}
isOpened.current = true; // prevent open multiple times
workbench.openAll();
workspaceDialogService.open('setting', {
activeTab: tab as SettingTab,
});
}, [tab, workbench, workspaceDialogService]);
return null;
};

View File

@@ -37,6 +37,10 @@ export const workbenchRoutes = [
path: '/journals',
lazy: () => import('./pages/journals'),
},
{
path: '/settings',
lazy: () => import('./pages/workspace/settings'),
},
{
path: '*',
lazy: () => import('./pages/404'),

View File

@@ -9,7 +9,7 @@ import {
Service,
smartRetry,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import type { AcceptInviteStore } from '../stores/accept-invite';
import type { InviteInfoStore } from '../stores/invite-info';
@@ -29,8 +29,8 @@ export class AcceptInviteService extends Service {
loading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly revalidate = effect(
exhaustMap(({ inviteId }: { inviteId: string }) => {
readonly acceptInvite = effect(
switchMap(({ inviteId }: { inviteId: string }) => {
if (!inviteId) {
return EMPTY;
}
@@ -51,7 +51,9 @@ export class AcceptInviteService extends Service {
this.accepted$.next(res);
return EMPTY;
}),
smartRetry(),
smartRetry({
count: 1,
}),
catchErrorInto(this.error$),
onStart(() => {
this.inviteId$.setValue(inviteId);
@@ -66,7 +68,21 @@ export class AcceptInviteService extends Service {
})
);
async waitForAcceptInvite(inviteId: string) {
this.acceptInvite({ inviteId });
await this.loading$.waitFor(f => !f);
if (this.accepted$.value) {
return true; // invite is accepted
}
if (this.error$.value) {
throw this.error$.value;
}
return false; // invite is expired
}
override dispose(): void {
this.revalidate.unsubscribe();
this.acceptInvite.unsubscribe();
}
}

View File

@@ -14,7 +14,7 @@
"it-IT": 94,
"it": 1,
"ja": 94,
"ko": 60,
"ko": 59,
"pl": 94,
"pt-BR": 94,
"ru": 94,

View File

@@ -1114,6 +1114,10 @@ export function useAFFiNEI18N(): {
["com.affine.auth.sign.auth.code.resend.hint"](options: {
readonly second: string;
}): string;
/**
* `Sent`
*/
["com.affine.auth.sent"](): string;
/**
* `The verification link failed to be sent, please try again later.`
*/
@@ -7417,6 +7421,10 @@ export function useAFFiNEI18N(): {
* `Transcribing`
*/
["com.affine.attachmentViewer.audio.transcribing"](): string;
/**
* `Accept & Join`
*/
["com.affine.notification.invitation.accept"](): string;
/**
* `An internal error occurred.`
*/
@@ -8451,4 +8459,30 @@ export const TypedTrans: {
}, {
a: JSX.Element;
}>>;
/**
* `<1>{{username}}</1> has accept your invitation`
*/
["com.affine.notification.invitation-accepted"]: ComponentType<TypedTransProps<{
readonly username: string;
}, {
["1"]: JSX.Element;
}>>;
/**
* `There is an issue regarding your invitation to <1>{{workspaceName}}</1> `
*/
["com.affine.notification.invitation-blocked"]: ComponentType<TypedTransProps<{
readonly workspaceName: string;
}, {
["1"]: JSX.Element;
}>>;
/**
* `<1>{{username}}</1> invited you to join <2>{{workspaceName}}</2>`
*/
["com.affine.notification.invitation"]: ComponentType<TypedTransProps<Readonly<{
username: string;
workspaceName: string;
}>, {
["1"]: JSX.Element;
["2"]: JSX.Element;
}>>;
} = /*#__PURE__*/ createProxy(createComponent);

View File

@@ -270,6 +270,7 @@
"com.affine.auth.sign.auth.code.continue": "Continue with code",
"com.affine.auth.sign.auth.code.resend": "Resend code",
"com.affine.auth.sign.auth.code.resend.hint": "Resend in {{second}}s",
"com.affine.auth.sent": "Sent",
"com.affine.auth.sent.change.email.fail": "The verification link failed to be sent, please try again later.",
"com.affine.auth.sent.change.email.hint": "Verification link has been sent.",
"com.affine.auth.sent.change.password.hint": "Reset password link has been sent.",
@@ -1847,6 +1848,10 @@
"com.affine.integration.properties": "Integration properties",
"com.affine.attachmentViewer.audio.notes": "Notes",
"com.affine.attachmentViewer.audio.transcribing": "Transcribing",
"com.affine.notification.invitation-accepted": "<1>{{username}}</1> has accept your invitation",
"com.affine.notification.invitation-blocked": "There is an issue regarding your invitation to <1>{{workspaceName}}</1> ",
"com.affine.notification.invitation": "<1>{{username}}</1> invited you to join <2>{{workspaceName}}</2>",
"com.affine.notification.invitation.accept": "Accept & Join",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",