mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): add notification list (#10480)
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
171
packages/frontend/core/src/components/notification/list.tsx
Normal file
171
packages/frontend/core/src/components/notification/list.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createEvent } from '@toeverything/infra';
|
|
||||||
|
|
||||||
import type { Server } from '../entities/server';
|
|
||||||
|
|
||||||
export const ServerInitialized = createEvent<Server>('ServerInitialized');
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/frontend/core/src/modules/notification/index.ts
Normal file
26
packages/frontend/core/src/modules/notification/index.ts
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
25
packages/frontend/graphql/src/graphql/list-notifications.gql
Normal file
25
packages/frontend/graphql/src/graphql/list-notifications.gql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
query notificationCount {
|
||||||
|
currentUser {
|
||||||
|
notificationCount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mutation readNotification($id: String!) {
|
||||||
|
readNotification(id: $id)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}}."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user