From ea07aa8607bf741834d09b9ecf735f76b4224e3a Mon Sep 17 00:00:00 2001 From: EYHN Date: Tue, 11 Mar 2025 06:23:33 +0000 Subject: [PATCH] feat(core): add notification list (#10480) --- .../component/src/ui/avatar/avatar.tsx | 65 +++++-- .../component/src/ui/avatar/style.css.ts | 7 + .../src/components/hooks/use-global-state.ts | 20 -- .../components/notification/list.style.css.ts | 89 +++++++++ .../core/src/components/notification/list.tsx | 171 ++++++++++++++++++ .../src/components/root-app-sidebar/index.tsx | 16 +- .../notification-button.style.css.ts | 15 ++ .../root-app-sidebar/notification-button.tsx | 59 ++++++ .../components/document-title/index.tsx | 23 +++ .../workspace/detail-page/detail-page.tsx | 3 - .../workspace/layouts/workspace-layout.tsx | 2 + .../pages/workspace/share/share-page.tsx | 3 - .../workspace/detail/mobile-detail-page.tsx | 4 - .../app-sidebar/views/menu-item/index.css.ts | 2 +- .../app-sidebar/views/menu-item/index.tsx | 8 +- .../cloud/events/server-initialized.ts | 5 - .../frontend/core/src/modules/cloud/index.ts | 1 - .../src/modules/cloud/services/servers.ts | 2 - packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/notification/index.ts | 26 +++ .../modules/notification/services/count.ts | 68 +++++++ .../src/modules/notification/services/list.ts | 93 ++++++++++ .../notification/stores/notification.ts | 85 +++++++++ .../frontend/graphql/src/graphql/index.ts | 48 +++++ .../src/graphql/list-notifications.gql | 25 +++ .../src/graphql/notification-count.gql | 5 + .../graphql/src/graphql/read-notification.gql | 3 + packages/frontend/graphql/src/schema.ts | 67 +++++++ packages/frontend/i18n/src/i18n.gen.ts | 35 ++++ packages/frontend/i18n/src/resources/en.json | 13 +- 30 files changed, 906 insertions(+), 59 deletions(-) delete mode 100644 packages/frontend/core/src/components/hooks/use-global-state.ts create mode 100644 packages/frontend/core/src/components/notification/list.style.css.ts create mode 100644 packages/frontend/core/src/components/notification/list.tsx create mode 100644 packages/frontend/core/src/components/root-app-sidebar/notification-button.style.css.ts create mode 100644 packages/frontend/core/src/components/root-app-sidebar/notification-button.tsx create mode 100644 packages/frontend/core/src/desktop/components/document-title/index.tsx delete mode 100644 packages/frontend/core/src/modules/cloud/events/server-initialized.ts create mode 100644 packages/frontend/core/src/modules/notification/index.ts create mode 100644 packages/frontend/core/src/modules/notification/services/count.ts create mode 100644 packages/frontend/core/src/modules/notification/services/list.ts create mode 100644 packages/frontend/core/src/modules/notification/stores/notification.ts create mode 100644 packages/frontend/graphql/src/graphql/list-notifications.gql create mode 100644 packages/frontend/graphql/src/graphql/notification-count.gql create mode 100644 packages/frontend/graphql/src/graphql/read-notification.gql diff --git a/packages/frontend/component/src/ui/avatar/avatar.tsx b/packages/frontend/component/src/ui/avatar/avatar.tsx index f59a044dc..008626bfa 100644 --- a/packages/frontend/component/src/ui/avatar/avatar.tsx +++ b/packages/frontend/component/src/ui/avatar/avatar.tsx @@ -115,7 +115,7 @@ export const Avatar = forwardRef( ref ) => { const firstCharOfName = useMemo(() => { - return name?.slice(0, 1) || 'A'; + return name?.slice(0, 1); }, [name]); const [containerDom, setContainerDom] = useState( null @@ -174,19 +174,33 @@ export const Avatar = forwardRef( /> )} - {!image /* no fallback on canvas mode */ && ( - - {colorfulFallback ? ( - - ) : ( - firstCharOfName.toUpperCase() - )} - - )} + {!image /* no fallback on canvas mode */ && + (firstCharOfName ? ( + /* if name is not empty, use first char of name as fallback */ + + {colorfulFallback ? ( + + ) : ( + firstCharOfName.toUpperCase() + )} + + ) : ( + /* if name is empty, use default fallback */ + + + + ))} {hoverIcon ? (
( ); Avatar.displayName = 'Avatar'; + +const DefaultFallbackSvg = () => { + return ( + + + + + ); +}; diff --git a/packages/frontend/component/src/ui/avatar/style.css.ts b/packages/frontend/component/src/ui/avatar/style.css.ts index da67380c2..14c81bacb 100644 --- a/packages/frontend/component/src/ui/avatar/style.css.ts +++ b/packages/frontend/component/src/ui/avatar/style.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { createVar, keyframes, style } from '@vanilla-extract/css'; export const sizeVar = createVar('sizeVar'); export const blurVar = createVar('blurVar'); @@ -163,6 +164,12 @@ export const avatarFallback = style({ lineHeight: '1', fontWeight: '500', }); +export const avatarDefaultFallback = style([ + avatarFallback, + { + backgroundColor: cssVarV2('portrait/localPortraitBackground'), + }, +]); export const hoverWrapper = style({ width: '100%', height: '100%', diff --git a/packages/frontend/core/src/components/hooks/use-global-state.ts b/packages/frontend/core/src/components/hooks/use-global-state.ts deleted file mode 100644 index 214c30174..000000000 --- a/packages/frontend/core/src/components/hooks/use-global-state.ts +++ /dev/null @@ -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); -} diff --git a/packages/frontend/core/src/components/notification/list.style.css.ts b/packages/frontend/core/src/components/notification/list.style.css.ts new file mode 100644 index 000000000..8f9280d58 --- /dev/null +++ b/packages/frontend/core/src/components/notification/list.style.css.ts @@ -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'), + }, + }, +}); diff --git a/packages/frontend/core/src/components/notification/list.tsx b/packages/frontend/core/src/components/notification/list.tsx new file mode 100644 index 000000000..31657fbbb --- /dev/null +++ b/packages/frontend/core/src/components/notification/list.tsx @@ -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) => { + const target = e.currentTarget; + if (target.scrollHeight - target.scrollTop <= target.clientHeight + 1) { + handleScrollEnd(); + } + }, + [handleScrollEnd] + ); + + return ( + + + {notifications.length > 0 ? ( +
    + {notifications.map(notification => ( +
  • + +
  • + ))} + {userFriendlyError && ( +
    {userFriendlyError.message}
    + )} +
+ ) : isLoading ? ( + + ) : userFriendlyError ? ( +
{userFriendlyError.message}
+ ) : ( +
+ {t['com.affine.notification.empty']()} +
+ )} +
+ +
+ ); +}; + +const NotificationItemSkeleton = () => { + return Array.from({ length: 3 }).map((_, i) => ( + // oxlint-disable-next-line no-array-index-key +
+ +
+ +
+ +
+
+
+ )); +}; + +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 ( +
+ {type === NotificationType.Mention ? ( + + ) : ( + <> + +
+ {t['com.affine.notification.unsupported']()} ({type}) +
+ + )} + } + onClick={handleDelete} + /> +
+ ); +}; + +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 ( + <> + +
+ + + ), + 2: , + }} + values={{ + username: username, + docTitle: body.doc.title ?? t['Untitled'](), + }} + /> + +
+ {i18nTime(notification.createdAt, { + relative: true, + })} +
+
+ + ); +}; diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 95d32ca2b..831cc2a7b 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -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 => {
+ {sessionStatus === 'authenticated' && } } diff --git a/packages/frontend/core/src/components/root-app-sidebar/notification-button.style.css.ts b/packages/frontend/core/src/components/root-app-sidebar/notification-button.style.css.ts new file mode 100644 index 000000000..e509a8140 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/notification-button.style.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/components/root-app-sidebar/notification-button.tsx b/packages/frontend/core/src/components/root-app-sidebar/notification-button.tsx new file mode 100644 index 000000000..b67e63756 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/notification-button.tsx @@ -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 ( +
+ {count > 99 ? '99+' : count} +
+ ); +}; + +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 ( + } + > + } + postfix={} + active={notificationListOpen} + postfixDisplay="always" + > + + {t['com.affine.rootAppSidebar.notifications']()} + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/components/document-title/index.tsx b/packages/frontend/core/src/desktop/components/document-title/index.tsx new file mode 100644 index 000000000..7b229862f --- /dev/null +++ b/packages/frontend/core/src/desktop/components/document-title/index.tsx @@ -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; +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index 4b87dc59d..ad1a3f6c3 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -44,7 +44,6 @@ import { AffineErrorBoundary } from '../../../../components/affine/affine-error- import { GlobalPageHistoryModal } from '../../../../components/affine/page-history-modal'; import { useRegisterBlocksuiteEditorCommands } from '../../../../components/hooks/affine/use-register-blocksuite-editor-commands'; 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 { TrashPageFooter } from '../../../../components/pure/trash-page-footer'; import { TopTip } from '../../../../components/top-tip'; @@ -160,8 +159,6 @@ const DetailPageImpl = memo(function DetailPageImpl() { }, [globalContext, isActiveView, isInTrash]); useRegisterBlocksuiteEditorCommands(editor, isActiveView); - const title = useLiveData(doc.title$); - usePageDocumentTitle(title); const onLoad = useCallback( (editorContainer: AffineEditorContainer) => { diff --git a/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx b/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx index ee6471e65..3b4d57098 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx @@ -8,6 +8,7 @@ import { SWRConfigProvider } from '@affine/core/components/providers/swr-config- import { WorkspaceSideEffects } from '@affine/core/components/providers/workspace-side-effects'; import { AIIsland } from '@affine/core/desktop/components/ai-island'; 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 { PeekViewManagerModal } from '@affine/core/modules/peek-view'; import { QuotaCheck } from '@affine/core/modules/quota'; @@ -36,6 +37,7 @@ export const WorkspaceLayout = function WorkspaceLayout({ + {children} {/* should show after workspace loaded */} diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx index 479114fe9..1c977fc60 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx @@ -2,7 +2,6 @@ import { Scrollable } from '@affine/component'; import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor'; import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer'; 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 { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { AppContainer } from '@affine/core/desktop/components/app-container'; @@ -189,8 +188,6 @@ const SharePageInner = ({ const pageTitle = useLiveData(page?.title$); const { jumpToPageBlock, openPage } = useNavigateHelper(); - usePageDocumentTitle(pageTitle); - const onEditorLoad = useCallback( (editorContainer: AffineEditorContainer) => { setActiveBlocksuiteEditor(editorContainer); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index 5f12358b3..a3f2ccf9b 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -3,7 +3,6 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; 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 { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper'; @@ -132,9 +131,6 @@ const DetailPageImpl = () => { }; }, [globalContext, isInTrash]); - const title = useLiveData(doc.title$); - usePageDocumentTitle(title); - const server = useService(ServerService).server; const onLoad = useCallback( diff --git a/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.css.ts index f85bf4fc4..cdca08bd1 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.css.ts +++ b/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.css.ts @@ -63,7 +63,7 @@ export const postfix = style({ opacity: 0, pointerEvents: 'none', selectors: { - [`${root}:hover &`]: { + [`${root}:hover &, &[data-postfix-display="always"]`]: { justifySelf: 'flex-end', position: 'initial', opacity: 1, diff --git a/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.tsx index 264d5c384..ed8e44721 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.tsx +++ b/packages/frontend/core/src/modules/app-sidebar/views/menu-item/index.tsx @@ -15,6 +15,7 @@ export interface MenuItemProps extends React.HTMLAttributes { // if onCollapsedChange is given, but collapsed is undefined, then we will render the collapse button as disabled onCollapsedChange?: (collapsed: boolean) => void; postfix?: React.ReactElement; + postfixDisplay?: 'always' | 'hover'; } export interface MenuLinkItemProps extends MenuItemProps { @@ -37,6 +38,7 @@ export const MenuItem = React.forwardRef( collapsed, onCollapsedChange, postfix, + postfixDisplay = 'hover', ...props }, ref @@ -80,7 +82,11 @@ export const MenuItem = React.forwardRef(
{children}
{postfix ? ( -
+
{postfix}
) : null} diff --git a/packages/frontend/core/src/modules/cloud/events/server-initialized.ts b/packages/frontend/core/src/modules/cloud/events/server-initialized.ts deleted file mode 100644 index 804701ef7..000000000 --- a/packages/frontend/core/src/modules/cloud/events/server-initialized.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createEvent } from '@toeverything/infra'; - -import type { Server } from '../entities/server'; - -export const ServerInitialized = createEvent('ServerInitialized'); diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 60aa41663..19303e0f9 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -4,7 +4,6 @@ export type { AuthAccountInfo } from './entities/session'; export { AccountChanged } from './events/account-changed'; export { AccountLoggedIn } from './events/account-logged-in'; export { AccountLoggedOut } from './events/account-logged-out'; -export { ServerInitialized } from './events/server-initialized'; export { AuthProvider } from './provider/auth'; export { ValidatorProvider } from './provider/validator'; export { ServerScope } from './scopes/server'; diff --git a/packages/frontend/core/src/modules/cloud/services/servers.ts b/packages/frontend/core/src/modules/cloud/services/servers.ts index 9fcbd5c17..757390d99 100644 --- a/packages/frontend/core/src/modules/cloud/services/servers.ts +++ b/packages/frontend/core/src/modules/cloud/services/servers.ts @@ -4,7 +4,6 @@ import { nanoid } from 'nanoid'; import { Observable, switchMap } from 'rxjs'; import { Server } from '../entities/server'; -import { ServerInitialized } from '../events/server-initialized'; import { ServerStarted } from '../events/server-started'; import type { ServerConfigStore } from '../stores/server-config'; import type { ServerListStore } from '../stores/server-list'; @@ -30,7 +29,6 @@ export class ServersService extends Service { serverMetadata: metadata, }); server.revalidateConfig(); - this.eventBus.emit(ServerInitialized, server); server.scope.eventBus.emit(ServerStarted, server); const ref = this.serverPool.put(metadata.id, server); return ref; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 55cf43d7e..0311f0795 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -31,6 +31,7 @@ import { configureImportTemplateModule } from './import-template'; import { configureJournalModule } from './journal'; import { configureLifecycleModule } from './lifecycle'; import { configureNavigationModule } from './navigation'; +import { configureNotificationModule } from './notification'; import { configureOpenInApp } from './open-in-app'; import { configureOrganizeModule } from './organize'; import { configurePDFModule } from './pdf'; @@ -102,4 +103,5 @@ export function configureCommonModules(framework: Framework) { configureTemplateDocModule(framework); configureBlobManagementModule(framework); configureImportClipperModule(framework); + configureNotificationModule(framework); } diff --git a/packages/frontend/core/src/modules/notification/index.ts b/packages/frontend/core/src/modules/notification/index.ts new file mode 100644 index 000000000..27b6f6d25 --- /dev/null +++ b/packages/frontend/core/src/modules/notification/index.ts @@ -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, + ]); +} diff --git a/packages/frontend/core/src/modules/notification/services/count.ts b/packages/frontend/core/src/modules/notification/services/count.ts new file mode 100644 index 000000000..8c57aff08 --- /dev/null +++ b/packages/frontend/core/src/modules/notification/services/count.ts @@ -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(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(); + } +} diff --git a/packages/frontend/core/src/modules/notification/services/list.ts b/packages/frontend/core/src/modules/notification/services/list.ts new file mode 100644 index 000000000..293a226f9 --- /dev/null +++ b/packages/frontend/core/src/modules/notification/services/list.ts @@ -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([]); + nextCursor$ = new LiveData(undefined); + hasMore$ = new LiveData(true); + error$ = new LiveData(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) + ); + } +} diff --git a/packages/frontend/core/src/modules/notification/stores/notification.ts b/packages/frontend/core/src/modules/notification/stores/notification.ts new file mode 100644 index 000000000..b0257808c --- /dev/null +++ b/packages/frontend/core/src/modules/notification/stores/notification.ts @@ -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, + }, + }); + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 13faf7cb3..c9cbe4dc3 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -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 = { id: 'listUsersQuery' as const, 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 = { id: 'pricesQuery' as const, 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 = { id: 'recoverDocMutation' as const, op: 'recoverDoc', diff --git a/packages/frontend/graphql/src/graphql/list-notifications.gql b/packages/frontend/graphql/src/graphql/list-notifications.gql new file mode 100644 index 000000000..30f153f6b --- /dev/null +++ b/packages/frontend/graphql/src/graphql/list-notifications.gql @@ -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 + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/notification-count.gql b/packages/frontend/graphql/src/graphql/notification-count.gql new file mode 100644 index 000000000..42813d01d --- /dev/null +++ b/packages/frontend/graphql/src/graphql/notification-count.gql @@ -0,0 +1,5 @@ +query notificationCount { + currentUser { + notificationCount + } +} diff --git a/packages/frontend/graphql/src/graphql/read-notification.gql b/packages/frontend/graphql/src/graphql/read-notification.gql new file mode 100644 index 000000000..d46a43828 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/read-notification.gql @@ -0,0 +1,3 @@ +mutation readNotification($id: String!) { + readNotification(id: $id) +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index e4049ffe7..4c9e1774e 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -3103,6 +3103,42 @@ export type LeaveWorkspaceMutation = { 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<{ 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 PricesQuery = { @@ -3174,6 +3217,15 @@ export type QuotaQuery = { } | null; }; +export type ReadNotificationMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type ReadNotificationMutation = { + __typename?: 'Mutation'; + readNotification: boolean; +}; + export type RecoverDocMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; docId: Scalars['String']['input']; @@ -3868,11 +3920,21 @@ export type Queries = variables: InvoicesQueryVariables; response: InvoicesQuery; } + | { + name: 'listNotificationsQuery'; + variables: ListNotificationsQueryVariables; + response: ListNotificationsQuery; + } | { name: 'listUsersQuery'; variables: ListUsersQueryVariables; response: ListUsersQuery; } + | { + name: 'notificationCountQuery'; + variables: NotificationCountQueryVariables; + response: NotificationCountQuery; + } | { name: 'pricesQuery'; variables: PricesQueryVariables; @@ -4065,6 +4127,11 @@ export type Mutations = variables: PublishPageMutationVariables; response: PublishPageMutation; } + | { + name: 'readNotificationMutation'; + variables: ReadNotificationMutationVariables; + response: ReadNotificationMutation; + } | { name: 'recoverDocMutation'; variables: RecoverDocMutationVariables; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 6895d6098..e4b1666ef 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4377,6 +4377,10 @@ export function useAFFiNEI18N(): { * `Collections` */ ["com.affine.rootAppSidebar.collections"](): string; + /** + * `Notifications` + */ + ["com.affine.rootAppSidebar.notifications"](): string; /** * `Only doc can be placed on here` */ @@ -6975,6 +6979,17 @@ export function useAFFiNEI18N(): { * `Edgeless` */ ["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` */ @@ -7594,6 +7609,26 @@ export function useAFFiNEI18N(): { clientVersion: string; requiredVersion: 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]); } function createComponent(i18nKey: string) { return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props }); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index e45b181f9..e3055b2dc 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1086,6 +1086,7 @@ "com.affine.resetSyncStatus.button": "Reset sync", "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "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.docs.no-subdoc": "No 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.write_with_a_blank_page": "Write with a blank page", "com.affine.yesterday": "Yesterday", + "com.affine.inactive": "Inactive", + "com.affine.inactive-member": "Inactive member", + "com.affine.inactive-workspace": "Inactive workspace", "core": "core", "dark": "Dark", "invited you to join": "invited you to join", @@ -1734,6 +1738,9 @@ "com.affine.page-starter-bar.template": "Template", "com.affine.page-starter-bar.ai": "With AI", "com.affine.page-starter-bar.edgeless": "Edgeless", + "com.affine.notification.unsupported": "Unsupported message", + "com.affine.notification.mention": "<1>{{username}} mentioned you in <2>{{docTitle}}", + "com.affine.notification.empty": "No new notifications", "tips": "Tips", "Template": "Template", "com.affine.template-list.delete": "Delete Template", @@ -1864,5 +1871,9 @@ "error.INVALID_LICENSE_TO_ACTIVATE": "Invalid license to activate.", "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.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}}." }