mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
feat(core): add notification list (#10480)
This commit is contained in:
@@ -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 />}
|
||||
|
||||
+15
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user