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