feat(core): add notification list (#10480)

This commit is contained in:
EYHN
2025-03-11 06:23:33 +00:00
parent 06889295e0
commit ea07aa8607
30 changed files with 906 additions and 59 deletions

View File

@@ -115,7 +115,7 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
ref ref
) => { ) => {
const firstCharOfName = useMemo(() => { const firstCharOfName = useMemo(() => {
return name?.slice(0, 1) || 'A'; return name?.slice(0, 1);
}, [name]); }, [name]);
const [containerDom, setContainerDom] = useState<HTMLDivElement | null>( const [containerDom, setContainerDom] = useState<HTMLDivElement | null>(
null null
@@ -174,19 +174,33 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
/> />
)} )}
{!image /* no fallback on canvas mode */ && ( {!image /* no fallback on canvas mode */ &&
<AvatarFallback (firstCharOfName ? (
className={clsx(style.avatarFallback, fallbackClassName)} /* if name is not empty, use first char of name as fallback */
delayMs={url ? 600 : undefined} <AvatarFallback
{...fallbackProps} className={clsx(style.avatarFallback, fallbackClassName)}
> delayMs={url ? 600 : undefined}
{colorfulFallback ? ( {...fallbackProps}
<ColorfulFallback char={firstCharOfName} /> >
) : ( {colorfulFallback ? (
firstCharOfName.toUpperCase() <ColorfulFallback char={firstCharOfName} />
)} ) : (
</AvatarFallback> firstCharOfName.toUpperCase()
)} )}
</AvatarFallback>
) : (
/* if name is empty, use default fallback */
<AvatarFallback
className={clsx(
style.avatarDefaultFallback,
fallbackClassName
)}
delayMs={url ? 600 : undefined}
{...fallbackProps}
>
<DefaultFallbackSvg />
</AvatarFallback>
))}
{hoverIcon ? ( {hoverIcon ? (
<div <div
className={clsx(style.hoverWrapper, hoverWrapperClassName)} className={clsx(style.hoverWrapper, hoverWrapperClassName)}
@@ -220,3 +234,26 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
); );
Avatar.displayName = 'Avatar'; Avatar.displayName = 'Avatar';
const DefaultFallbackSvg = () => {
return (
<svg
width="100%"
height="100%"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 12.1C13.2912 12.1 15.1486 10.2285 15.1486 7.92C15.1486 5.61145 13.2912 3.74 11 3.74C8.70881 3.74 6.85143 5.61145 6.85143 7.92C6.85143 10.2285 8.70881 12.1 11 12.1Z"
fill="black"
fillOpacity="0.22"
/>
<path
d="M1.68 24.64C1.48118 24.64 1.31933 24.4782 1.32649 24.2795C1.51473 19.0599 5.77368 14.8867 11 14.8867C16.2263 14.8867 20.4853 19.0599 20.6735 24.2795C20.6807 24.4782 20.5188 24.64 20.32 24.64H1.68Z"
fill="black"
fillOpacity="0.22"
/>
</svg>
);
};

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, keyframes, style } from '@vanilla-extract/css'; import { createVar, keyframes, style } from '@vanilla-extract/css';
export const sizeVar = createVar('sizeVar'); export const sizeVar = createVar('sizeVar');
export const blurVar = createVar('blurVar'); export const blurVar = createVar('blurVar');
@@ -163,6 +164,12 @@ export const avatarFallback = style({
lineHeight: '1', lineHeight: '1',
fontWeight: '500', fontWeight: '500',
}); });
export const avatarDefaultFallback = style([
avatarFallback,
{
backgroundColor: cssVarV2('portrait/localPortraitBackground'),
},
]);
export const hoverWrapper = style({ export const hoverWrapper = style({
width: '100%', width: '100%',
height: '100%', height: '100%',

View File

@@ -1,20 +0,0 @@
import { noop } from 'lodash-es';
import { useEffect } from 'react';
export function useDocumentTitle(newTitle?: string | null) {
useEffect(() => {
if (BUILD_CONFIG.isElectron || !newTitle) {
return noop;
}
const oldTitle = document.title;
document.title = newTitle;
return () => {
document.title = oldTitle;
};
}, [newTitle]);
}
export function usePageDocumentTitle(pageTitle?: string) {
useDocumentTitle(pageTitle ? `${pageTitle} · AFFiNE` : null);
}

View File

@@ -0,0 +1,89 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const containerScrollViewport = style({
maxHeight: '272px',
width: '360px',
});
export const itemList = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const listEmpty = style({
color: cssVarV2('text/placeholder'),
fontSize: '14px',
lineHeight: '22px',
padding: '4px 2px',
});
export const error = style({
color: cssVarV2('status/error'),
fontSize: '14px',
lineHeight: '22px',
padding: '4px 2px',
});
export const itemContainer = style({
display: 'flex',
flexDirection: 'row',
borderRadius: '4px',
position: 'relative',
padding: '8px',
gap: '8px',
selectors: {
[`&:hover:not([data-disabled="true"])`]: {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
},
});
export const itemMain = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
fontSize: '14px',
lineHeight: '22px',
});
export const itemDate = style({
color: cssVarV2('text/secondary'),
fontSize: '12px',
lineHeight: '20px',
});
export const itemNotSupported = style({
color: cssVarV2('text/placeholder'),
fontSize: '12px',
lineHeight: '22px',
});
export const itemDeleteButton = style({
position: 'absolute',
right: '10px',
bottom: '8px',
width: '20px',
height: '20px',
backgroundColor: cssVarV2('button/iconButtonSolid'),
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
boxShadow: cssVar('buttonShadow'),
opacity: 0,
selectors: {
[`${itemContainer}:hover &`]: {
opacity: 1,
},
},
});
export const itemNameLabel = style({
fontWeight: 'bold',
color: cssVarV2('text/primary'),
selectors: {
[`&[data-inactived="true"]`]: {
color: cssVarV2('text/placeholder'),
},
},
});

View File

@@ -0,0 +1,171 @@
import { Avatar, IconButton, Scrollable, Skeleton } from '@affine/component';
import {
type Notification,
NotificationListService,
NotificationType,
} from '@affine/core/modules/notification';
import { UserFriendlyError } from '@affine/error';
import type { MentionNotificationBodyType } from '@affine/graphql';
import { i18nTime, Trans, useI18n } from '@affine/i18n';
import { DeleteIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import * as styles from './list.style.css';
export const NotificationList = () => {
const t = useI18n();
const notificationListService = useService(NotificationListService);
const notifications = useLiveData(notificationListService.notifications$);
const isLoading = useLiveData(notificationListService.isLoading$);
const error = useLiveData(notificationListService.error$);
const userFriendlyError = useMemo(() => {
return error && UserFriendlyError.fromAny(error);
}, [error]);
useEffect(() => {
// reset the notification list when the component is mounted
notificationListService.reset();
notificationListService.loadMore();
}, [notificationListService]);
const handleScrollEnd = useCallback(() => {
notificationListService.loadMore();
}, [notificationListService]);
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 1) {
handleScrollEnd();
}
},
[handleScrollEnd]
);
return (
<Scrollable.Root>
<Scrollable.Viewport
className={styles.containerScrollViewport}
onScroll={handleScroll}
>
{notifications.length > 0 ? (
<ul className={styles.itemList}>
{notifications.map(notification => (
<li key={notification.id}>
<NotificationItem notification={notification} />
</li>
))}
{userFriendlyError && (
<div className={styles.error}>{userFriendlyError.message}</div>
)}
</ul>
) : isLoading ? (
<NotificationItemSkeleton />
) : userFriendlyError ? (
<div className={styles.error}>{userFriendlyError.message}</div>
) : (
<div className={styles.listEmpty}>
{t['com.affine.notification.empty']()}
</div>
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
};
const NotificationItemSkeleton = () => {
return Array.from({ length: 3 }).map((_, i) => (
// oxlint-disable-next-line no-array-index-key
<div key={i} className={styles.itemContainer} data-disabled="true">
<Skeleton variant="circular" width={22} height={22} />
<div className={styles.itemMain}>
<Skeleton variant="text" width={150} />
<div className={styles.itemDate}>
<Skeleton variant="text" width={100} />
</div>
</div>
</div>
));
};
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 (
<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}
/>
</div>
);
};
const MentionNotificationItem = ({
notification,
}: {
notification: Notification;
}) => {
const t = useI18n();
const body = notification.body as MentionNotificationBodyType;
const memberInactived = !body.createdByUser;
const username =
body.createdByUser?.name ?? t['com.affine.inactive-member']();
return (
<>
<Avatar
size={22}
name={body.createdByUser?.name}
url={body.createdByUser?.avatarUrl}
/>
<div className={styles.itemMain}>
<span>
<Trans
i18nKey={'com.affine.notification.mention'}
components={{
1: (
<b
className={styles.itemNameLabel}
data-inactived={memberInactived}
/>
),
2: <b className={styles.itemNameLabel} />,
}}
values={{
username: username,
docTitle: body.doc.title ?? t['Untitled'](),
}}
/>
</span>
<div className={styles.itemDate}>
{i18nTime(notification.createdAt, {
relative: true,
})}
</div>
</div>
</>
);
};

View File

@@ -10,6 +10,7 @@ import {
SidebarScrollableContainer, SidebarScrollableContainer,
} from '@affine/core/modules/app-sidebar/views'; } from '@affine/core/modules/app-sidebar/views';
import { ExternalMenuLinkItem } from '@affine/core/modules/app-sidebar/views/menu-item/external-menu-link-item'; import { ExternalMenuLinkItem } from '@affine/core/modules/app-sidebar/views/menu-item/external-menu-link-item';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { import {
CollapsibleSection, CollapsibleSection,
@@ -43,6 +44,7 @@ import {
workspaceWrapper, workspaceWrapper,
} from './index.css'; } from './index.css';
import { AppSidebarJournalButton } from './journal-button'; import { AppSidebarJournalButton } from './journal-button';
import { NotificationButton } from './notification-button';
import { TemplateDocEntrance } from './template-doc-entrance'; import { TemplateDocEntrance } from './template-doc-entrance';
import { TrashButton } from './trash-button'; import { TrashButton } from './trash-button';
import { UpdaterButton } from './updater-button'; import { UpdaterButton } from './updater-button';
@@ -87,10 +89,15 @@ const AllDocsButton = () => {
* *
*/ */
export const RootAppSidebar = memo((): ReactElement => { export const RootAppSidebar = memo((): ReactElement => {
const { workbenchService, cMDKQuickSearchService } = useServices({ const { workbenchService, cMDKQuickSearchService, authService } = useServices(
WorkbenchService, {
CMDKQuickSearchService, WorkbenchService,
}); CMDKQuickSearchService,
AuthService,
}
);
const sessionStatus = useLiveData(authService.session.status$);
const t = useI18n(); const t = useI18n();
const workspaceDialogService = useService(WorkspaceDialogService); const workspaceDialogService = useService(WorkspaceDialogService);
const workbench = workbenchService.workbench; const workbench = workbenchService.workbench;
@@ -159,6 +166,7 @@ export const RootAppSidebar = memo((): ReactElement => {
</div> </div>
<AllDocsButton /> <AllDocsButton />
<AppSidebarJournalButton /> <AppSidebarJournalButton />
{sessionStatus === 'authenticated' && <NotificationButton />}
<MenuItem <MenuItem
data-testid="slider-bar-workspace-setting-button" data-testid="slider-bar-workspace-setting-button"
icon={<SettingsIcon />} icon={<SettingsIcon />}

View File

@@ -0,0 +1,15 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const badge = style({
backgroundColor: cssVarV2('button/primary'),
color: cssVarV2('text/pureWhite'),
minWidth: '16px',
height: '16px',
padding: '0px 4px',
borderRadius: '4px',
fontSize: '12px',
textAlign: 'center',
lineHeight: '16px',
fontWeight: 500,
});

View File

@@ -0,0 +1,59 @@
import { Menu } from '@affine/component';
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
import { NotificationCountService } from '@affine/core/modules/notification';
import { useI18n } from '@affine/i18n';
import { NotificationIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { NotificationList } from '../notification/list';
import * as styles from './notification-button.style.css';
const Badge = ({ count, onClick }: { count: number; onClick?: () => void }) => {
if (count === 0) {
return null;
}
return (
<div className={styles.badge} onClick={onClick}>
{count > 99 ? '99+' : count}
</div>
);
};
export const NotificationButton = () => {
const notificationCountService = useService(NotificationCountService);
const notificationCount = useLiveData(notificationCountService.count$);
const t = useI18n();
const [notificationListOpen, setNotificationListOpen] = useState(false);
const handleNotificationListOpenChange = useCallback((open: boolean) => {
setNotificationListOpen(open);
}, []);
return (
<Menu
rootOptions={{
open: notificationListOpen,
onOpenChange: handleNotificationListOpenChange,
}}
contentOptions={{
side: 'right',
sideOffset: -50,
}}
items={<NotificationList />}
>
<MenuItem
icon={<NotificationIcon />}
postfix={<Badge count={notificationCount} />}
active={notificationListOpen}
postfixDisplay="always"
>
<span data-testid="notification-button">
{t['com.affine.rootAppSidebar.notifications']()}
</span>
</MenuItem>
</Menu>
);
};

View File

@@ -0,0 +1,23 @@
import { NotificationCountService } from '@affine/core/modules/notification';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
export const DocumentTitle = () => {
const notificationCountService = useService(NotificationCountService);
const notificationCount = useLiveData(notificationCountService.count$);
const workbenchService = useService(WorkbenchService);
const workbenchView = useLiveData(workbenchService.workbench.activeView$);
const viewTitle = useLiveData(workbenchView.title$);
useEffect(() => {
const prefix = notificationCount > 0 ? `(${notificationCount}) ` : '';
document.title = prefix + (viewTitle ? `${viewTitle} · AFFiNE` : 'AFFiNE');
return () => {
document.title = 'AFFiNE';
};
}, [notificationCount, viewTitle]);
return null;
};

View File

@@ -44,7 +44,6 @@ import { AffineErrorBoundary } from '../../../../components/affine/affine-error-
import { GlobalPageHistoryModal } from '../../../../components/affine/page-history-modal'; import { GlobalPageHistoryModal } from '../../../../components/affine/page-history-modal';
import { useRegisterBlocksuiteEditorCommands } from '../../../../components/hooks/affine/use-register-blocksuite-editor-commands'; import { useRegisterBlocksuiteEditorCommands } from '../../../../components/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '../../../../components/hooks/use-block-suite-editor'; import { useActiveBlocksuiteEditor } from '../../../../components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../../components/hooks/use-global-state';
import { PageDetailEditor } from '../../../../components/page-detail-editor'; import { PageDetailEditor } from '../../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../../components/pure/trash-page-footer'; import { TrashPageFooter } from '../../../../components/pure/trash-page-footer';
import { TopTip } from '../../../../components/top-tip'; import { TopTip } from '../../../../components/top-tip';
@@ -160,8 +159,6 @@ const DetailPageImpl = memo(function DetailPageImpl() {
}, [globalContext, isActiveView, isInTrash]); }, [globalContext, isActiveView, isInTrash]);
useRegisterBlocksuiteEditorCommands(editor, isActiveView); useRegisterBlocksuiteEditorCommands(editor, isActiveView);
const title = useLiveData(doc.title$);
usePageDocumentTitle(title);
const onLoad = useCallback( const onLoad = useCallback(
(editorContainer: AffineEditorContainer) => { (editorContainer: AffineEditorContainer) => {

View File

@@ -8,6 +8,7 @@ import { SWRConfigProvider } from '@affine/core/components/providers/swr-config-
import { WorkspaceSideEffects } from '@affine/core/components/providers/workspace-side-effects'; import { WorkspaceSideEffects } from '@affine/core/components/providers/workspace-side-effects';
import { AIIsland } from '@affine/core/desktop/components/ai-island'; import { AIIsland } from '@affine/core/desktop/components/ai-island';
import { AppContainer } from '@affine/core/desktop/components/app-container'; import { AppContainer } from '@affine/core/desktop/components/app-container';
import { DocumentTitle } from '@affine/core/desktop/components/document-title';
import { WorkspaceDialogs } from '@affine/core/desktop/dialogs'; import { WorkspaceDialogs } from '@affine/core/desktop/dialogs';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view'; import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { QuotaCheck } from '@affine/core/modules/quota'; import { QuotaCheck } from '@affine/core/modules/quota';
@@ -36,6 +37,7 @@ export const WorkspaceLayout = function WorkspaceLayout({
<AiLoginRequiredModal /> <AiLoginRequiredModal />
<WorkspaceSideEffects /> <WorkspaceSideEffects />
<PeekViewManagerModal /> <PeekViewManagerModal />
<DocumentTitle />
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner> <WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
{/* should show after workspace loaded */} {/* should show after workspace loaded */}

View File

@@ -2,7 +2,6 @@ import { Scrollable } from '@affine/component';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor'; import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer'; import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor'; import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { AppContainer } from '@affine/core/desktop/components/app-container'; import { AppContainer } from '@affine/core/desktop/components/app-container';
@@ -189,8 +188,6 @@ const SharePageInner = ({
const pageTitle = useLiveData(page?.title$); const pageTitle = useLiveData(page?.title$);
const { jumpToPageBlock, openPage } = useNavigateHelper(); const { jumpToPageBlock, openPage } = useNavigateHelper();
usePageDocumentTitle(pageTitle);
const onEditorLoad = useCallback( const onEditorLoad = useCallback(
(editorContainer: AffineEditorContainer) => { (editorContainer: AffineEditorContainer) => {
setActiveBlocksuiteEditor(editorContainer); setActiveBlocksuiteEditor(editorContainer);

View File

@@ -3,7 +3,6 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor'; import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor'; import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper'; import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
@@ -132,9 +131,6 @@ const DetailPageImpl = () => {
}; };
}, [globalContext, isInTrash]); }, [globalContext, isInTrash]);
const title = useLiveData(doc.title$);
usePageDocumentTitle(title);
const server = useService(ServerService).server; const server = useService(ServerService).server;
const onLoad = useCallback( const onLoad = useCallback(

View File

@@ -63,7 +63,7 @@ export const postfix = style({
opacity: 0, opacity: 0,
pointerEvents: 'none', pointerEvents: 'none',
selectors: { selectors: {
[`${root}:hover &`]: { [`${root}:hover &, &[data-postfix-display="always"]`]: {
justifySelf: 'flex-end', justifySelf: 'flex-end',
position: 'initial', position: 'initial',
opacity: 1, opacity: 1,

View File

@@ -15,6 +15,7 @@ export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
// if onCollapsedChange is given, but collapsed is undefined, then we will render the collapse button as disabled // if onCollapsedChange is given, but collapsed is undefined, then we will render the collapse button as disabled
onCollapsedChange?: (collapsed: boolean) => void; onCollapsedChange?: (collapsed: boolean) => void;
postfix?: React.ReactElement; postfix?: React.ReactElement;
postfixDisplay?: 'always' | 'hover';
} }
export interface MenuLinkItemProps extends MenuItemProps { export interface MenuLinkItemProps extends MenuItemProps {
@@ -37,6 +38,7 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
collapsed, collapsed,
onCollapsedChange, onCollapsedChange,
postfix, postfix,
postfixDisplay = 'hover',
...props ...props
}, },
ref ref
@@ -80,7 +82,11 @@ export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
<div className={styles.content}>{children}</div> <div className={styles.content}>{children}</div>
{postfix ? ( {postfix ? (
<div className={styles.postfix} onClick={stopPropagation}> <div
className={styles.postfix}
data-postfix-display={postfixDisplay}
onClick={stopPropagation}
>
{postfix} {postfix}
</div> </div>
) : null} ) : null}

View File

@@ -1,5 +0,0 @@
import { createEvent } from '@toeverything/infra';
import type { Server } from '../entities/server';
export const ServerInitialized = createEvent<Server>('ServerInitialized');

View File

@@ -4,7 +4,6 @@ export type { AuthAccountInfo } from './entities/session';
export { AccountChanged } from './events/account-changed'; export { AccountChanged } from './events/account-changed';
export { AccountLoggedIn } from './events/account-logged-in'; export { AccountLoggedIn } from './events/account-logged-in';
export { AccountLoggedOut } from './events/account-logged-out'; export { AccountLoggedOut } from './events/account-logged-out';
export { ServerInitialized } from './events/server-initialized';
export { AuthProvider } from './provider/auth'; export { AuthProvider } from './provider/auth';
export { ValidatorProvider } from './provider/validator'; export { ValidatorProvider } from './provider/validator';
export { ServerScope } from './scopes/server'; export { ServerScope } from './scopes/server';

View File

@@ -4,7 +4,6 @@ import { nanoid } from 'nanoid';
import { Observable, switchMap } from 'rxjs'; import { Observable, switchMap } from 'rxjs';
import { Server } from '../entities/server'; import { Server } from '../entities/server';
import { ServerInitialized } from '../events/server-initialized';
import { ServerStarted } from '../events/server-started'; import { ServerStarted } from '../events/server-started';
import type { ServerConfigStore } from '../stores/server-config'; import type { ServerConfigStore } from '../stores/server-config';
import type { ServerListStore } from '../stores/server-list'; import type { ServerListStore } from '../stores/server-list';
@@ -30,7 +29,6 @@ export class ServersService extends Service {
serverMetadata: metadata, serverMetadata: metadata,
}); });
server.revalidateConfig(); server.revalidateConfig();
this.eventBus.emit(ServerInitialized, server);
server.scope.eventBus.emit(ServerStarted, server); server.scope.eventBus.emit(ServerStarted, server);
const ref = this.serverPool.put(metadata.id, server); const ref = this.serverPool.put(metadata.id, server);
return ref; return ref;

View File

@@ -31,6 +31,7 @@ import { configureImportTemplateModule } from './import-template';
import { configureJournalModule } from './journal'; import { configureJournalModule } from './journal';
import { configureLifecycleModule } from './lifecycle'; import { configureLifecycleModule } from './lifecycle';
import { configureNavigationModule } from './navigation'; import { configureNavigationModule } from './navigation';
import { configureNotificationModule } from './notification';
import { configureOpenInApp } from './open-in-app'; import { configureOpenInApp } from './open-in-app';
import { configureOrganizeModule } from './organize'; import { configureOrganizeModule } from './organize';
import { configurePDFModule } from './pdf'; import { configurePDFModule } from './pdf';
@@ -102,4 +103,5 @@ export function configureCommonModules(framework: Framework) {
configureTemplateDocModule(framework); configureTemplateDocModule(framework);
configureBlobManagementModule(framework); configureBlobManagementModule(framework);
configureImportClipperModule(framework); configureImportClipperModule(framework);
configureNotificationModule(framework);
} }

View File

@@ -0,0 +1,26 @@
export { NotificationCountService } from './services/count';
export { NotificationListService } from './services/list';
export type { Notification, NotificationBody } from './stores/notification';
export { NotificationType } from './stores/notification';
import type { Framework } from '@toeverything/infra';
import { GraphQLService, ServerScope, ServerService } from '../cloud';
import { GlobalSessionState } from '../storage';
import { NotificationCountService } from './services/count';
import { NotificationListService } from './services/list';
import { NotificationStore } from './stores/notification';
export function configureNotificationModule(framework: Framework) {
framework
.scope(ServerScope)
.service(NotificationCountService, [NotificationStore])
.service(NotificationListService, [
NotificationStore,
NotificationCountService,
])
.store(NotificationStore, [
GraphQLService,
ServerService,
GlobalSessionState,
]);
}

View File

@@ -0,0 +1,68 @@
import {
catchErrorInto,
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
OnEvent,
onStart,
Service,
smartRetry,
} from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap, timer } from 'rxjs';
import { ServerStarted } from '../../cloud/events/server-started';
import { ApplicationFocused } from '../../lifecycle';
import type { NotificationStore } from '../stores/notification';
@OnEvent(ApplicationFocused, s => s.handleApplicationFocused)
@OnEvent(ServerStarted, s => s.handleServerStarted)
export class NotificationCountService extends Service {
constructor(private readonly store: NotificationStore) {
super();
}
readonly count$ = LiveData.from(this.store.watchNotificationCountCache(), 0);
readonly isLoading$ = new LiveData(false);
readonly error$ = new LiveData<any>(null);
revalidate = effect(
switchMap(() => {
return timer(0, 30000); // revalidate every 30 seconds
}),
exhaustMapWithTrailing(() => {
return fromPromise(signal =>
this.store.getNotificationCount(signal)
).pipe(
mergeMap(result => {
this.setCount(result ?? 0);
return EMPTY;
}),
smartRetry(),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
handleApplicationFocused() {
this.revalidate();
}
handleServerStarted() {
this.revalidate();
}
setCount(count: number) {
this.store.setNotificationCountCache(count);
}
override dispose(): void {
super.dispose();
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,93 @@
import {
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
smartRetry,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import type { Notification, NotificationStore } from '../stores/notification';
import type { NotificationCountService } from './count';
export class NotificationListService extends Service {
isLoading$ = new LiveData(false);
notifications$ = new LiveData<Notification[]>([]);
nextCursor$ = new LiveData<string | undefined>(undefined);
hasMore$ = new LiveData(true);
error$ = new LiveData<any>(null);
readonly PAGE_SIZE = 8;
constructor(
private readonly store: NotificationStore,
private readonly notificationCount: NotificationCountService
) {
super();
}
readonly loadMore = effect(
exhaustMap(() => {
if (!this.hasMore$.value) {
return EMPTY;
}
return fromPromise(signal =>
this.store.listNotification(
{
first: this.PAGE_SIZE,
after: this.nextCursor$.value,
},
signal
)
).pipe(
mergeMap(result => {
if (!result) {
// If the user is not logged in, we just ignore the result.
return EMPTY;
}
const { edges, pageInfo, totalCount } = result;
this.notifications$.next([
...this.notifications$.value,
...edges.map(edge => edge.node),
]);
// keep the notification count in sync
this.notificationCount.setCount(totalCount);
this.hasMore$.next(pageInfo.hasNextPage);
this.nextCursor$.next(pageInfo.endCursor ?? undefined);
return EMPTY;
}),
smartRetry(),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
reset() {
this.notifications$.setValue([]);
this.hasMore$.setValue(true);
this.nextCursor$.setValue(undefined);
this.isLoading$.setValue(false);
this.error$.setValue(null);
this.loadMore.reset();
}
async readNotification(id: string) {
await this.store.readNotification(id);
this.notifications$.next(
this.notifications$.value.filter(notification => notification.id !== id)
);
this.notificationCount.setCount(
Math.max(this.notificationCount.count$.value - 1, 0)
);
}
}

View File

@@ -0,0 +1,85 @@
import {
type ListNotificationsQuery,
listNotificationsQuery,
notificationCountQuery,
type PaginationInput,
readNotificationMutation,
type UnionNotificationBodyType,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import { map } from 'rxjs';
import type { GraphQLService, ServerService } from '../../cloud';
import type { GlobalSessionState } from '../../storage';
export type Notification = NonNullable<
ListNotificationsQuery['currentUser']
>['notifications']['edges'][number]['node'];
export type NotificationBody = UnionNotificationBodyType;
export { NotificationType } from '@affine/graphql';
export class NotificationStore extends Store {
constructor(
private readonly gqlService: GraphQLService,
private readonly serverService: ServerService,
private readonly globalSessionState: GlobalSessionState
) {
super();
}
watchNotificationCountCache() {
return this.globalSessionState
.watch('notification-count:' + this.serverService.server.id)
.pipe(
map(count => {
if (typeof count === 'number') {
return count;
}
return 0;
})
);
}
setNotificationCountCache(count: number) {
this.globalSessionState.set(
'notification-count:' + this.serverService.server.id,
count
);
}
async getNotificationCount(signal?: AbortSignal) {
const result = await this.gqlService.gql({
query: notificationCountQuery,
context: {
signal,
},
});
return result.currentUser?.notificationCount;
}
async listNotification(pagination: PaginationInput, signal?: AbortSignal) {
const result = await this.gqlService.gql({
query: listNotificationsQuery,
variables: {
pagination: pagination,
},
context: {
signal,
},
});
return result.currentUser?.notifications;
}
readNotification(id: string) {
return this.gqlService.gql({
query: readNotificationMutation,
variables: {
id,
},
});
}
}

View File

@@ -932,6 +932,36 @@ export const leaveWorkspaceMutation = {
}`, }`,
}; };
export const listNotificationsQuery = {
id: 'listNotificationsQuery' as const,
op: 'listNotifications',
query: `query listNotifications($pagination: PaginationInput!) {
currentUser {
notifications(pagination: $pagination) {
totalCount
edges {
cursor
node {
id
type
level
read
createdAt
updatedAt
body
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
}`,
};
export const listUsersQuery = { export const listUsersQuery = {
id: 'listUsersQuery' as const, id: 'listUsersQuery' as const,
op: 'listUsers', op: 'listUsers',
@@ -948,6 +978,16 @@ export const listUsersQuery = {
}`, }`,
}; };
export const notificationCountQuery = {
id: 'notificationCountQuery' as const,
op: 'notificationCount',
query: `query notificationCount {
currentUser {
notificationCount
}
}`,
};
export const pricesQuery = { export const pricesQuery = {
id: 'pricesQuery' as const, id: 'pricesQuery' as const,
op: 'prices', op: 'prices',
@@ -1004,6 +1044,14 @@ export const quotaQuery = {
], ],
}; };
export const readNotificationMutation = {
id: 'readNotificationMutation' as const,
op: 'readNotification',
query: `mutation readNotification($id: String!) {
readNotification(id: $id)
}`,
};
export const recoverDocMutation = { export const recoverDocMutation = {
id: 'recoverDocMutation' as const, id: 'recoverDocMutation' as const,
op: 'recoverDoc', op: 'recoverDoc',

View File

@@ -0,0 +1,25 @@
query listNotifications($pagination: PaginationInput!) {
currentUser {
notifications(pagination: $pagination) {
totalCount
edges {
cursor
node {
id
type
level
read
createdAt
updatedAt
body
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
}

View File

@@ -0,0 +1,5 @@
query notificationCount {
currentUser {
notificationCount
}
}

View File

@@ -0,0 +1,3 @@
mutation readNotification($id: String!) {
readNotification(id: $id)
}

View File

@@ -3103,6 +3103,42 @@ export type LeaveWorkspaceMutation = {
leaveWorkspace: boolean; leaveWorkspace: boolean;
}; };
export type ListNotificationsQueryVariables = Exact<{
pagination: PaginationInput;
}>;
export type ListNotificationsQuery = {
__typename?: 'Query';
currentUser: {
__typename?: 'UserType';
notifications: {
__typename?: 'PaginatedNotificationObjectType';
totalCount: number;
edges: Array<{
__typename?: 'NotificationObjectTypeEdge';
cursor: string;
node: {
__typename?: 'NotificationObjectType';
id: string;
type: NotificationType;
level: NotificationLevel;
read: boolean;
createdAt: string;
updatedAt: string;
body: any;
};
}>;
pageInfo: {
__typename?: 'PageInfo';
startCursor: string | null;
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
};
} | null;
};
export type ListUsersQueryVariables = Exact<{ export type ListUsersQueryVariables = Exact<{
filter: ListUserInput; filter: ListUserInput;
}>; }>;
@@ -3121,6 +3157,13 @@ export type ListUsersQuery = {
}>; }>;
}; };
export type NotificationCountQueryVariables = Exact<{ [key: string]: never }>;
export type NotificationCountQuery = {
__typename?: 'Query';
currentUser: { __typename?: 'UserType'; notificationCount: number } | null;
};
export type PricesQueryVariables = Exact<{ [key: string]: never }>; export type PricesQueryVariables = Exact<{ [key: string]: never }>;
export type PricesQuery = { export type PricesQuery = {
@@ -3174,6 +3217,15 @@ export type QuotaQuery = {
} | null; } | null;
}; };
export type ReadNotificationMutationVariables = Exact<{
id: Scalars['String']['input'];
}>;
export type ReadNotificationMutation = {
__typename?: 'Mutation';
readNotification: boolean;
};
export type RecoverDocMutationVariables = Exact<{ export type RecoverDocMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input']; docId: Scalars['String']['input'];
@@ -3868,11 +3920,21 @@ export type Queries =
variables: InvoicesQueryVariables; variables: InvoicesQueryVariables;
response: InvoicesQuery; response: InvoicesQuery;
} }
| {
name: 'listNotificationsQuery';
variables: ListNotificationsQueryVariables;
response: ListNotificationsQuery;
}
| { | {
name: 'listUsersQuery'; name: 'listUsersQuery';
variables: ListUsersQueryVariables; variables: ListUsersQueryVariables;
response: ListUsersQuery; response: ListUsersQuery;
} }
| {
name: 'notificationCountQuery';
variables: NotificationCountQueryVariables;
response: NotificationCountQuery;
}
| { | {
name: 'pricesQuery'; name: 'pricesQuery';
variables: PricesQueryVariables; variables: PricesQueryVariables;
@@ -4065,6 +4127,11 @@ export type Mutations =
variables: PublishPageMutationVariables; variables: PublishPageMutationVariables;
response: PublishPageMutation; response: PublishPageMutation;
} }
| {
name: 'readNotificationMutation';
variables: ReadNotificationMutationVariables;
response: ReadNotificationMutation;
}
| { | {
name: 'recoverDocMutation'; name: 'recoverDocMutation';
variables: RecoverDocMutationVariables; variables: RecoverDocMutationVariables;

View File

@@ -4377,6 +4377,10 @@ export function useAFFiNEI18N(): {
* `Collections` * `Collections`
*/ */
["com.affine.rootAppSidebar.collections"](): string; ["com.affine.rootAppSidebar.collections"](): string;
/**
* `Notifications`
*/
["com.affine.rootAppSidebar.notifications"](): string;
/** /**
* `Only doc can be placed on here` * `Only doc can be placed on here`
*/ */
@@ -6975,6 +6979,17 @@ export function useAFFiNEI18N(): {
* `Edgeless` * `Edgeless`
*/ */
["com.affine.page-starter-bar.edgeless"](): string; ["com.affine.page-starter-bar.edgeless"](): string;
/**
* `Unsupported message`
*/
["com.affine.notification.unsupported"](): string;
/**
* `{{username}} mentioned you in {{docTitle}}`
*/
["com.affine.notification.mention"](options: Readonly<{
username: string;
docTitle: string;
}>): string;
/** /**
* `Tips` * `Tips`
*/ */
@@ -7594,6 +7609,26 @@ export function useAFFiNEI18N(): {
clientVersion: string; clientVersion: string;
requiredVersion: string; requiredVersion: string;
}>): string; }>): string;
/**
* `Notification not found.`
*/
["error.NOTIFICATION_NOT_FOUND"](): string;
/**
* `Mention user do not have permission to access space {{spaceId}}.`
*/
["error.MENTION_USER_SPACE_ACCESS_DENIED"](options: {
readonly spaceId: string;
}): string;
/**
* `You cannot mention yourself.`
*/
["error.MENTION_USER_ONESELF_DENIED"](): string;
/**
* `You do not have permission to access notification {{notificationId}}.`
*/
["error.NOTIFICATION_ACCESS_DENIED"](options: {
readonly notificationId: string;
}): string;
} { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); } } { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); }
function createComponent(i18nKey: string) { function createComponent(i18nKey: string) {
return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props }); return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props });

View File

@@ -1086,6 +1086,7 @@
"com.affine.resetSyncStatus.button": "Reset sync", "com.affine.resetSyncStatus.button": "Reset sync",
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
"com.affine.rootAppSidebar.collections": "Collections", "com.affine.rootAppSidebar.collections": "Collections",
"com.affine.rootAppSidebar.notifications": "Notifications",
"com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here", "com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here",
"com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs", "com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs",
"com.affine.rootAppSidebar.docs.references-loading": "Loading linked docs...", "com.affine.rootAppSidebar.docs.references-loading": "Loading linked docs...",
@@ -1630,6 +1631,9 @@
"com.affine.workspaceSubPath.trash.empty-description": "Deleted docs will appear here.", "com.affine.workspaceSubPath.trash.empty-description": "Deleted docs will appear here.",
"com.affine.write_with_a_blank_page": "Write with a blank page", "com.affine.write_with_a_blank_page": "Write with a blank page",
"com.affine.yesterday": "Yesterday", "com.affine.yesterday": "Yesterday",
"com.affine.inactive": "Inactive",
"com.affine.inactive-member": "Inactive member",
"com.affine.inactive-workspace": "Inactive workspace",
"core": "core", "core": "core",
"dark": "Dark", "dark": "Dark",
"invited you to join": "invited you to join", "invited you to join": "invited you to join",
@@ -1734,6 +1738,9 @@
"com.affine.page-starter-bar.template": "Template", "com.affine.page-starter-bar.template": "Template",
"com.affine.page-starter-bar.ai": "With AI", "com.affine.page-starter-bar.ai": "With AI",
"com.affine.page-starter-bar.edgeless": "Edgeless", "com.affine.page-starter-bar.edgeless": "Edgeless",
"com.affine.notification.unsupported": "Unsupported message",
"com.affine.notification.mention": "<1>{{username}}</1> mentioned you in <2>{{docTitle}}</2>",
"com.affine.notification.empty": "No new notifications",
"tips": "Tips", "tips": "Tips",
"Template": "Template", "Template": "Template",
"com.affine.template-list.delete": "Delete Template", "com.affine.template-list.delete": "Delete Template",
@@ -1864,5 +1871,9 @@
"error.INVALID_LICENSE_TO_ACTIVATE": "Invalid license to activate.", "error.INVALID_LICENSE_TO_ACTIVATE": "Invalid license to activate.",
"error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}", "error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}",
"error.WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE": "You cannot downgrade the workspace from team workspace because there are more than {{limit}} members that are currently active.", "error.WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE": "You cannot downgrade the workspace from team workspace because there are more than {{limit}} members that are currently active.",
"error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}]." "error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].",
"error.NOTIFICATION_NOT_FOUND": "Notification not found.",
"error.MENTION_USER_SPACE_ACCESS_DENIED": "Mention user do not have permission to access space {{spaceId}}.",
"error.MENTION_USER_ONESELF_DENIED": "You cannot mention yourself.",
"error.NOTIFICATION_ACCESS_DENIED": "You do not have permission to access notification {{notificationId}}."
} }