From cc368949fff48d79a1647ae5d53f4570c95c0975 Mon Sep 17 00:00:00 2001
From: Ciaran Morinan <37743469+CiaranMn@users.noreply.github.com>
Date: Wed, 11 Dec 2024 11:44:11 +0000
Subject: [PATCH] H-3609: Reduce cost of polling for notification and draft
entities counts (#5838)
---
.../queries/knowledge/entity.queries.ts | 1 +
.../entity-page-header.tsx | 25 +-
apps/hash-frontend/src/pages/_app.page.tsx | 12 +-
apps/hash-frontend/src/pages/actions.page.tsx | 24 +-
.../draft-entities-bulk-actions-dropdown.tsx | 75 ++--
.../actions.page}/draft-entities-context.tsx | 28 +-
.../src/pages/actions.page/draft-entities.tsx | 2 +-
.../src/pages/actions.page/draft-entity.tsx | 2 +-
.../draft-entity-action-buttons.tsx | 6 +-
.../draft-entity/draft-entity-chip.tsx | 4 +-
.../draft-entity/draft-entity-type.tsx | 10 +-
.../src/pages/notifications.page.tsx | 366 +----------------
.../notifications-table.tsx | 374 ++++++++++++++++++
.../notifications-with-links-context.tsx | 120 +++---
.../shared/accept-draft-entity-button.tsx | 45 +--
.../shared/discard-draft-entity-button.tsx | 40 +-
.../shared/draft-entities-count-context.tsx | 92 +++++
.../notifications-dropdown.tsx | 4 +-
.../layout/layout-with-header/page-header.tsx | 6 +-
.../layout/layout-with-sidebar/sidebar.tsx | 12 +-
.../src/shared/notification-count-context.tsx | 330 ++++++++++++++++
.../shared/notification-entities-context.tsx | 334 ----------------
.../graphql/type-defs/generation.typedef.ts | 2 -
.../type-defs/knowledge/entity.typedef.ts | 1 +
24 files changed, 998 insertions(+), 917 deletions(-)
rename apps/hash-frontend/src/{shared => pages/actions.page}/draft-entities-context.tsx (80%)
create mode 100644 apps/hash-frontend/src/pages/notifications.page/notifications-table.tsx
rename apps/hash-frontend/src/pages/{shared => notifications.page}/notifications-with-links-context.tsx (84%)
create mode 100644 apps/hash-frontend/src/shared/draft-entities-count-context.tsx
create mode 100644 apps/hash-frontend/src/shared/notification-count-context.tsx
delete mode 100644 apps/hash-frontend/src/shared/notification-entities-context.tsx
diff --git a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts
index f690f36727c..6b55c04f0d9 100644
--- a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts
+++ b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts
@@ -62,6 +62,7 @@ export const getEntitySubgraphQuery = gql`
) {
getEntitySubgraph(request: $request) {
closedMultiEntityTypes
+ count
definitions
userPermissionsOnEntities @include(if: $includePermissions)
subgraph {
diff --git a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-page-wrapper/entity-page-header.tsx b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-page-wrapper/entity-page-header.tsx
index 182a3076b7c..db189df4fa1 100644
--- a/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-page-wrapper/entity-page-header.tsx
+++ b/apps/hash-frontend/src/pages/[shortname]/entities/[entity-uuid].page/entity-page-wrapper/entity-page-header.tsx
@@ -10,7 +10,6 @@ import { useRouter } from "next/router";
import type { ReactNode } from "react";
import { useContext } from "react";
-import { NotificationsWithLinksContextProvider } from "../../../../shared/notifications-with-links-context";
import { TopContextBar } from "../../../../shared/top-context-bar";
import { WorkspaceContext } from "../../../../shared/workspace-context";
import { EntityEditorTabs } from "../shared/entity-editor-tabs";
@@ -84,19 +83,17 @@ export const EntityPageHeader = ({
/>
{entity && entitySubgraph ? (
-
-
-
-
-
+
+
+
) : null}
{editBar}
diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx
index 7686fa7fb13..e0c64901c1e 100644
--- a/apps/hash-frontend/src/pages/_app.page.tsx
+++ b/apps/hash-frontend/src/pages/_app.page.tsx
@@ -35,14 +35,14 @@ import { hasAccessToHashQuery, meQuery } from "../graphql/queries/user.queries";
import { apolloClient } from "../lib/apollo-client";
import type { MinimalUser } from "../lib/user-and-org";
import { constructMinimalUser } from "../lib/user-and-org";
-import { DraftEntitiesContextProvider } from "../shared/draft-entities-context";
+import { DraftEntitiesCountContextProvider } from "../shared/draft-entities-count-context";
import { EntityTypesContextProvider } from "../shared/entity-types-context/provider";
import { FileUploadsProvider } from "../shared/file-upload-context";
import { KeyboardShortcutsContextProvider } from "../shared/keyboard-shortcuts-context";
import type { NextPageWithLayout } from "../shared/layout";
import { getLayoutWithSidebar, getPlainLayout } from "../shared/layout";
import { SidebarContextProvider } from "../shared/layout/layout-with-sidebar/sidebar-context";
-import { NotificationEntitiesContextProvider } from "../shared/notification-entities-context";
+import { NotificationCountContextProvider } from "../shared/notification-count-context";
import { PropertyTypesContextProvider } from "../shared/property-types-context";
import { RoutePageInfoProvider } from "../shared/routing";
import { ErrorFallback } from "./_app.page/error-fallback";
@@ -108,8 +108,8 @@ const App: FunctionComponent = ({
-
-
+
+
@@ -132,8 +132,8 @@ const App: FunctionComponent = ({
-
-
+
+
diff --git a/apps/hash-frontend/src/pages/actions.page.tsx b/apps/hash-frontend/src/pages/actions.page.tsx
index 2750b6b362a..0f00c38cdec 100644
--- a/apps/hash-frontend/src/pages/actions.page.tsx
+++ b/apps/hash-frontend/src/pages/actions.page.tsx
@@ -24,7 +24,6 @@ import type {
GetEntitySubgraphQueryVariables,
} from "../graphql/api-types.gen";
import { getEntitySubgraphQuery } from "../graphql/queries/knowledge/entity.queries";
-import { useDraftEntities } from "../shared/draft-entities-context";
import { BarsSortRegularIcon } from "../shared/icons/bars-sort-regular-icon";
import type { NextPageWithLayout } from "../shared/layout";
import { getLayoutWithSidebar } from "../shared/layout";
@@ -32,8 +31,11 @@ import { MenuItem } from "../shared/ui";
import type { SortOrder } from "./actions.page/draft-entities";
import { DraftEntities } from "./actions.page/draft-entities";
import { DraftEntitiesBulkActionsDropdown } from "./actions.page/draft-entities-bulk-actions-dropdown";
+import {
+ DraftEntitiesContextProvider,
+ useDraftEntities,
+} from "./actions.page/draft-entities-context";
import { InlineSelect } from "./shared/inline-select";
-import { NotificationsWithLinksContextProvider } from "./shared/notifications-with-links-context";
import { TopContextBar } from "./shared/top-context-bar";
const sortOrderHumanReadable: Record = {
@@ -41,7 +43,7 @@ const sortOrderHumanReadable: Record = {
"created-at-desc": "creation date/time (newest first)",
};
-const ActionsPage: NextPageWithLayout = () => {
+const ActionsPage = () => {
const [selectedDraftEntityIds, setSelectedDraftEntityIds] = useState<
EntityId[]
>([]);
@@ -117,7 +119,7 @@ const ActionsPage: NextPageWithLayout = () => {
);
return (
-
+ <>
{
draftEntitiesWithLinkedDataSubgraph
}
/>
-
+ >
+ );
+};
+
+const ActionsPageOuter: NextPageWithLayout = () => {
+ return (
+
+
+
);
};
-ActionsPage.getLayout = (page) =>
+ActionsPageOuter.getLayout = (page) =>
getLayoutWithSidebar(page, {
fullWidth: true,
});
-export default ActionsPage;
+export default ActionsPageOuter;
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entities-bulk-actions-dropdown.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entities-bulk-actions-dropdown.tsx
index 67a2634d9d6..1f58a9b6351 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entities-bulk-actions-dropdown.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entities-bulk-actions-dropdown.tsx
@@ -28,11 +28,10 @@ import {
archiveEntitiesMutation,
updateEntitiesMutation,
} from "../../graphql/queries/knowledge/entity.queries";
-import { useDraftEntities } from "../../shared/draft-entities-context";
import { LayerGroupLightIcon } from "../../shared/icons/layer-group-light-icon";
-import { useNotificationEntities } from "../../shared/notification-entities-context";
+import { useNotificationCount } from "../../shared/notification-count-context";
import { Button, MenuItem } from "../../shared/ui";
-import { useNotificationsWithLinks } from "../shared/notifications-with-links-context";
+import { useDraftEntities } from "./draft-entities-context";
export const DraftEntitiesBulkActionsDropdown: FunctionComponent<{
selectedDraftEntityIds: EntityId[];
@@ -44,9 +43,8 @@ export const DraftEntitiesBulkActionsDropdown: FunctionComponent<{
deselectAllDraftEntities,
}) => {
const { draftEntities, refetch: refetchDraftEntities } = useDraftEntities();
- const { notifications } = useNotificationsWithLinks();
- const { archiveNotifications, markNotificationsAsRead } =
- useNotificationEntities();
+ const { archiveNotificationsForEntity, markNotificationsAsReadForEntity } =
+ useNotificationCount();
const popupState = usePopupState({
variant: "popover",
@@ -108,41 +106,37 @@ export const DraftEntitiesBulkActionsDropdown: FunctionComponent<{
>(archiveEntitiesMutation);
const ignoreAllSelectedDraftEntities = useCallback(async () => {
- if (!notifications) {
+ if (!selectedDraftEntities.length) {
return;
}
- const relatedNotifications = notifications.filter((notification) =>
- selectedDraftEntityIds.includes(
- notification.occurredInEntity.metadata.recordId.entityId,
- ),
- );
-
await archiveEntities({
variables: {
entityIds: [
- ...selectedDraftEntities,
- ...(incomingOrOutgoingDraftLinksToIgnore ?? []),
- ].map(({ metadata }) => metadata.recordId.entityId),
+ ...selectedDraftEntityIds,
+ ...(incomingOrOutgoingDraftLinksToIgnore ?? []).map(
+ ({ metadata }) => metadata.recordId.entityId,
+ ),
+ ],
},
});
- await archiveNotifications({
- notificationEntities: relatedNotifications.map(({ entity }) => entity),
- });
-
+ await Promise.all(
+ selectedDraftEntityIds.map((entityId) =>
+ archiveNotificationsForEntity({ targetEntityId: entityId }),
+ ),
+ );
await refetchDraftEntities();
deselectAllDraftEntities();
}, [
- notifications,
- archiveNotifications,
archiveEntities,
- selectedDraftEntityIds,
- selectedDraftEntities,
- refetchDraftEntities,
- incomingOrOutgoingDraftLinksToIgnore,
+ archiveNotificationsForEntity,
deselectAllDraftEntities,
+ incomingOrOutgoingDraftLinksToIgnore,
+ refetchDraftEntities,
+ selectedDraftEntities,
+ selectedDraftEntityIds,
]);
const [
@@ -222,15 +216,6 @@ export const DraftEntitiesBulkActionsDropdown: FunctionComponent<{
>(updateEntitiesMutation);
const acceptAllSelectedDraftEntities = useCallback(async () => {
- const relatedGraphChangeNotifications =
- notifications?.filter(
- ({ kind, occurredInEntity }) =>
- kind === "graph-change" &&
- selectedDraftEntityIds.includes(
- occurredInEntity.metadata.recordId.entityId,
- ),
- ) ?? [];
-
await updateEntities({
variables: {
entityUpdates: [
@@ -244,24 +229,24 @@ export const DraftEntitiesBulkActionsDropdown: FunctionComponent<{
},
});
- await markNotificationsAsRead({
- notificationEntities: relatedGraphChangeNotifications.map(
- ({ entity }) => entity,
+ await Promise.all(
+ selectedDraftEntities.map((entity) =>
+ markNotificationsAsReadForEntity({
+ targetEntityId: entity.entityId,
+ }),
),
- });
+ );
await refetchDraftEntities();
deselectAllDraftEntities();
}, [
- notifications,
- markNotificationsAsRead,
- selectedDraftEntityIds,
- selectedDraftEntities,
+ deselectAllDraftEntities,
leftOrRightDraftEntitiesToAccept,
- updateEntities,
+ markNotificationsAsReadForEntity,
refetchDraftEntities,
- deselectAllDraftEntities,
+ selectedDraftEntities,
+ updateEntities,
]);
const [
diff --git a/apps/hash-frontend/src/shared/draft-entities-context.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entities-context.tsx
similarity index 80%
rename from apps/hash-frontend/src/shared/draft-entities-context.tsx
rename to apps/hash-frontend/src/pages/actions.page/draft-entities-context.tsx
index 9b44cd09240..aaf27a4e267 100644
--- a/apps/hash-frontend/src/shared/draft-entities-context.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entities-context.tsx
@@ -13,10 +13,11 @@ import { createContext, useContext, useMemo, useState } from "react";
import type {
GetEntitySubgraphQuery,
GetEntitySubgraphQueryVariables,
-} from "../graphql/api-types.gen";
-import { getEntitySubgraphQuery } from "../graphql/queries/knowledge/entity.queries";
-import { useAuthInfo } from "../pages/shared/auth-info-context";
-import { pollInterval } from "./poll-interval";
+} from "../../graphql/api-types.gen";
+import { getEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity.queries";
+import { useDraftEntitiesCount } from "../../shared/draft-entities-count-context";
+import { pollInterval } from "../../shared/poll-interval";
+import { useAuthInfo } from "../shared/auth-info-context";
export type DraftEntitiesContextValue = {
draftEntities?: Entity[];
@@ -38,6 +39,10 @@ export const useDraftEntities = () => {
return draftEntitiesContext;
};
+/**
+ * Context to provide full information of draft entities, for use in the actions page.
+ * A separate app-wide context provides simply a count of draft entities.
+ */
export const DraftEntitiesContextProvider: FunctionComponent<
PropsWithChildren
> = ({ children }) => {
@@ -48,9 +53,11 @@ export const DraftEntitiesContextProvider: FunctionComponent<
const { authenticatedUser } = useAuthInfo();
+ const { refetch: refetchDraftEntitiesCount } = useDraftEntitiesCount();
+
const {
data: draftEntitiesData,
- refetch,
+ refetch: refetchFullData,
loading,
} = useQuery(
getEntitySubgraphQuery,
@@ -104,10 +111,17 @@ export const DraftEntitiesContextProvider: FunctionComponent<
draftEntitiesSubgraph,
loading,
refetch: async () => {
- await refetch();
+ await refetchFullData();
+ await refetchDraftEntitiesCount();
},
}),
- [draftEntities, draftEntitiesSubgraph, loading, refetch],
+ [
+ draftEntities,
+ draftEntitiesSubgraph,
+ loading,
+ refetchFullData,
+ refetchDraftEntitiesCount,
+ ],
);
return (
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entities.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entities.tsx
index 9e97636ca2f..d89e5e9aa30 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entities.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entities.tsx
@@ -17,7 +17,6 @@ import {
useState,
} from "react";
-import { useDraftEntities } from "../../shared/draft-entities-context";
import { Button } from "../../shared/ui";
import type { MinimalActor } from "../../shared/use-actors";
import { useActors } from "../../shared/use-actors";
@@ -30,6 +29,7 @@ import {
getDraftEntityTypes,
isFilerStateDefaultFilterState,
} from "./draft-entities/draft-entities-filters";
+import { useDraftEntities } from "./draft-entities-context";
import { DraftEntity } from "./draft-entity";
const incrementNumberOfEntitiesToDisplay = 20;
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entity.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entity.tsx
index 1e96a4cff0d..efd3ae37c10 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entity.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entity.tsx
@@ -6,11 +6,11 @@ import { Box, Checkbox, Typography } from "@mui/material";
import type { FunctionComponent } from "react";
import { useMemo, useState } from "react";
-import { useDraftEntities } from "../../shared/draft-entities-context";
import { ArrowUpRightRegularIcon } from "../../shared/icons/arrow-up-right-regular-icon";
import { Link } from "../../shared/ui";
import { EntityEditorSlideStack } from "../shared/entity-editor-slide-stack";
import { useEntityHref } from "../shared/use-entity-href";
+import { useDraftEntities } from "./draft-entities-context";
import { DraftEntityActionButtons } from "./draft-entity/draft-entity-action-buttons";
import { DraftEntityProvenance } from "./draft-entity/draft-entity-provenance";
import { DraftEntityType } from "./draft-entity/draft-entity-type";
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-action-buttons.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-action-buttons.tsx
index 6e44e8c84f1..cefcb42706c 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-action-buttons.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-action-buttons.tsx
@@ -7,16 +7,20 @@ import type { FunctionComponent } from "react";
import { CheckRegularIcon } from "../../../shared/icons/check-regular-icon";
import { AcceptDraftEntityButton } from "../../shared/accept-draft-entity-button";
import { DiscardDraftEntityButton } from "../../shared/discard-draft-entity-button";
+import { useDraftEntities } from "../draft-entities-context";
export const DraftEntityActionButtons: FunctionComponent<{
entity: Entity;
subgraph: Subgraph;
}> = ({ entity, subgraph }) => {
+ const { refetch } = useDraftEntities();
+
return (
}
@@ -40,7 +44,7 @@ export const DraftEntityActionButtons: FunctionComponent<{
size="xs"
variant="primary"
startIcon={}
- onAcceptedEntity={null}
+ onAcceptedEntity={refetch}
>
Accept
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-chip.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-chip.tsx
index 2a9708d7090..cacd334671f 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-chip.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-chip.tsx
@@ -6,7 +6,6 @@ export const DraftEntityChip = styled(Chip)(({ theme, clickable }) => ({
background: theme.palette.common.white,
borderColor: theme.palette.gray[30],
fontWeight: 500,
- fontSize: 12,
textTransform: "none",
...(clickable
? {
@@ -20,6 +19,7 @@ export const DraftEntityChip = styled(Chip)(({ theme, clickable }) => ({
color: theme.palette.gray[50],
},
[`& .${chipClasses.label}`]: {
- padding: theme.spacing(0.5, 1.25),
+ padding: theme.spacing(0.3, 1.25),
+ fontSize: 12,
},
}));
diff --git a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-type.tsx b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-type.tsx
index 4a9f01127f3..127cae8dd9c 100644
--- a/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-type.tsx
+++ b/apps/hash-frontend/src/pages/actions.page/draft-entity/draft-entity-type.tsx
@@ -76,7 +76,14 @@ export const DraftEntityType: FunctionComponent<{
alignItems: "center",
}}
>
-
+
palette.gray[50]}
@@ -90,6 +97,7 @@ export const DraftEntityType: FunctionComponent<{
(allOf) => allOf.$ref === linkEntityTypeUrl,
)
}
+ sx={{ mr: 0.5 }}
/>
{entityType.schema.title}
diff --git a/apps/hash-frontend/src/pages/notifications.page.tsx b/apps/hash-frontend/src/pages/notifications.page.tsx
index efe5d34bf3b..918f52bcf99 100644
--- a/apps/hash-frontend/src/pages/notifications.page.tsx
+++ b/apps/hash-frontend/src/pages/notifications.page.tsx
@@ -1,332 +1,19 @@
import { BellLightIcon } from "@hashintel/design-system";
-import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths";
-import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties";
-import {
- extractDraftIdFromEntityId,
- extractEntityUuidFromEntityId,
- extractOwnedByIdFromEntityId,
-} from "@local/hash-subgraph";
import {
breadcrumbsClasses,
buttonClasses,
Container,
- Skeleton,
- styled,
- Table as MuiTable,
- TableBody,
- TableCell as MuiTableCell,
- tableCellClasses,
- TableHead,
- TableRow as MuiTableRow,
- tableRowClasses,
Typography,
} from "@mui/material";
-import {
- differenceInDays,
- differenceInMinutes,
- format,
- isThisYear,
- isToday,
-} from "date-fns";
import { NextSeo } from "next-seo";
-import type { FunctionComponent } from "react";
-import { useCallback, useMemo } from "react";
-import { useUserOrOrgShortnameByOwnedById } from "../components/hooks/use-user-or-org-shortname-by-owned-by-id";
-import { constructPageRelativeUrl } from "../lib/routes";
import type { NextPageWithLayout } from "../shared/layout";
import { getLayoutWithSidebar } from "../shared/layout";
-import { useNotificationEntities } from "../shared/notification-entities-context";
-import { Button, Link } from "../shared/ui";
-import type {
- GraphChangeNotification,
- Notification,
- PageRelatedNotification,
-} from "./shared/notifications-with-links-context";
-import { useNotificationsWithLinksContextValue } from "./shared/notifications-with-links-context";
+import { NotificationsTable } from "./notifications.page/notifications-table";
+import { NotificationsWithLinksContextProvider } from "./notifications.page/notifications-with-links-context";
import { TopContextBar } from "./shared/top-context-bar";
-const Table = styled(MuiTable)(({ theme }) => ({
- borderCollapse: "separate",
- borderSpacing: 0,
- background: theme.palette.common.white,
- borderRadius: "8px",
- borderColor: theme.palette.gray[30],
- borderStyle: "solid",
- borderWidth: 1,
- overflow: "hidden",
-}));
-
-const TableRow = styled(MuiTableRow)(() => ({
- [`&:last-of-type .${tableCellClasses.body}`]: {
- borderBottom: "none",
- },
- [`&:first-of-type .${tableCellClasses.head}`]: {
- "&:first-of-type": {
- borderTopLeftRadius: "8px",
- },
- "&:last-of-type": {
- borderTopRightRadius: "8px",
- },
- },
- [`&:last-of-type .${tableCellClasses.body}`]: {
- "&:first-of-type": {
- borderBottomLeftRadius: "8px",
- },
- "&:last-of-type": {
- borderBottomRightRadius: "8px",
- },
- },
-}));
-
-const TableCell = styled(MuiTableCell)(({ theme }) => ({
- whiteSpace: "nowrap",
- border: 0,
- borderStyle: "solid",
- borderColor: theme.palette.gray[20],
- borderBottomWidth: 1,
- borderRightWidth: 1,
- padding: theme.spacing(0.5, 1.5),
- [`&.${tableCellClasses.head}`]: {
- fontSize: 13,
- fontWeight: 600,
- color: theme.palette.common.black,
- },
- [`&.${tableCellClasses.body}`]: {
- fontSize: 14,
- fontWeight: 500,
- color: theme.palette.gray[90],
- },
- "&:last-of-type": {
- borderRightWidth: 0,
- },
-}));
-
-const GraphChangeNotificationContent = ({
- notification,
- handleNotificationClick,
- targetHref,
-}: {
- notification: GraphChangeNotification;
- handleNotificationClick: () => void;
- targetHref?: string;
-}) => {
- const { occurredInEntityLabel, occurredInEntity, operation } = notification;
-
- return (
-
- HASH AI {operation}d{" "}
-
- {occurredInEntityLabel}
- {" "}
- {extractDraftIdFromEntityId(occurredInEntity.metadata.recordId.entityId)
- ? "as draft"
- : ""}
-
- );
-};
-
-const PageRelatedNotificationContent = ({
- notification,
- handleNotificationClick,
- targetHref,
-}: {
- notification: PageRelatedNotification;
- handleNotificationClick: () => void;
- targetHref?: string;
-}) => {
- const { kind, triggeredByUser, occurredInEntity } = notification;
-
- const pageTitle = useMemo(() => {
- const { title } = simplifyProperties(occurredInEntity.properties);
-
- return title;
- }, [occurredInEntity]);
-
- return (
- <>
-
- {triggeredByUser.displayName}
- {" "}
- {kind === "new-comment"
- ? "commented on "
- : kind === "comment-reply"
- ? "replied to your comment on "
- : kind === "page-mention"
- ? "mentioned you in "
- : "mentioned you in a comment on "}
-
- {pageTitle}
-
- >
- );
-};
-
-const NotificationRow: FunctionComponent<{ notification: Notification }> = ({
- notification,
-}) => {
- const { markNotificationAsRead } = useNotificationEntities();
-
- const handleNotificationClick = useCallback(async () => {
- await markNotificationAsRead({ notificationEntity: notification.entity });
- }, [markNotificationAsRead, notification]);
-
- const ownedById = useMemo(
- () =>
- extractOwnedByIdFromEntityId(
- notification.occurredInEntity.metadata.recordId.entityId,
- ),
- [notification],
- );
-
- const { shortname: entityOwningShortname } = useUserOrOrgShortnameByOwnedById(
- { ownedById },
- );
-
- const targetHref = useMemo(() => {
- if (!entityOwningShortname) {
- return undefined;
- }
-
- if (notification.kind === "graph-change") {
- return generateEntityPath({
- entityId: notification.occurredInEntity.metadata.recordId.entityId,
- includeDraftId: true,
- shortname: entityOwningShortname,
- });
- }
-
- const { occurredInBlock } = notification;
-
- /** @todo: append query param if the mention was in a comment */
- return constructPageRelativeUrl({
- workspaceShortname: entityOwningShortname,
- pageEntityUuid: extractEntityUuidFromEntityId(
- notification.occurredInEntity.metadata.recordId.entityId,
- ),
- highlightedBlockEntityId: occurredInBlock.metadata.recordId.entityId,
- });
- }, [entityOwningShortname, notification]);
-
- const humanReadableCreatedAt = useMemo(() => {
- const now = new Date();
-
- const createdAtTimestamp =
- notification.kind === "graph-change"
- ? (notification.occurredInEntityEditionTimestamp ??
- notification.entity.metadata.provenance.createdAtDecisionTime)
- : notification.entity.metadata.provenance.createdAtDecisionTime;
-
- const createdAt = new Date(createdAtTimestamp);
-
- const numberOfMinutesAgo = differenceInMinutes(now, createdAt);
-
- if (numberOfMinutesAgo < 1) {
- return "Just now";
- }
-
- if (isToday(createdAt)) {
- if (numberOfMinutesAgo < 60) {
- return `${numberOfMinutesAgo} minute${
- numberOfMinutesAgo > 1 ? "s" : ""
- } ago`;
- }
- const numberOfHoursAgo = Math.floor(numberOfMinutesAgo / 60);
- return `${numberOfHoursAgo} hour${numberOfHoursAgo > 1 ? "s" : ""} ago`;
- }
- const numberOfDaysAgo = differenceInDays(now, createdAt);
-
- if (numberOfDaysAgo < 7) {
- return format(createdAt, "h:mma iiii"); // "12:00AM Monday"
- }
-
- if (isThisYear(createdAt)) {
- return format(createdAt, "h:mma MMMM do"); // "12:00AM October 27th"
- }
-
- return format(createdAt, "h:mma MMMM do, yyyy"); // "12:00AM December 24th, 2022"
- }, [notification]);
-
- return (
- palette.gray[20]
- : undefined,
- opacity: notification.readAt ? 0.6 : 1,
- }}
- >
- palette.gray[70],
- },
- }}
- >
- {humanReadableCreatedAt}
-
- palette.blue[70],
- "&:hover": { color: ({ palette }) => palette.blue[90] },
- },
- }}
- >
- {notification.kind === "graph-change" ? (
-
- ) : (
-
- )}
-
-
-
- {notification.readAt ? null : (
-
- )}
-
-
- );
-};
-
const NotificationsPage: NextPageWithLayout = () => {
- const { notifications } = useNotificationsWithLinksContextValue();
-
return (
<>
@@ -351,52 +38,9 @@ const NotificationsPage: NextPageWithLayout = () => {
Notifications
- {notifications && notifications.length === 0 ? (
- You don't have any notifications.
- ) : (
-
-
-
-
- When
-
-
- Title
-
-
- Actions
-
-
-
- .${tableRowClasses.root}:last-of-type > .${tableCellClasses.root}`]:
- {
- borderBottomWidth: 0,
- },
- }}
- >
- {notifications ? (
- notifications.map((notification) => (
-
- ))
- ) : (
-
-
-
-
-
-
-
-
-
- )}
-
-
- )}
+
+
+
>
);
diff --git a/apps/hash-frontend/src/pages/notifications.page/notifications-table.tsx b/apps/hash-frontend/src/pages/notifications.page/notifications-table.tsx
new file mode 100644
index 00000000000..074805c3de1
--- /dev/null
+++ b/apps/hash-frontend/src/pages/notifications.page/notifications-table.tsx
@@ -0,0 +1,374 @@
+import { generateEntityPath } from "@local/hash-isomorphic-utils/frontend-paths";
+import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties";
+import {
+ extractDraftIdFromEntityId,
+ extractEntityUuidFromEntityId,
+ extractOwnedByIdFromEntityId,
+} from "@local/hash-subgraph";
+import {
+ Skeleton,
+ styled,
+ Table as MuiTable,
+ TableBody,
+ TableCell as MuiTableCell,
+ tableCellClasses,
+ TableHead,
+ TableRow as MuiTableRow,
+ tableRowClasses,
+ Typography,
+} from "@mui/material";
+import {
+ differenceInDays,
+ differenceInMinutes,
+ format,
+ isThisYear,
+ isToday,
+} from "date-fns";
+import type { FunctionComponent } from "react";
+import { useCallback, useMemo } from "react";
+
+import { useUserOrOrgShortnameByOwnedById } from "../../components/hooks/use-user-or-org-shortname-by-owned-by-id";
+import { constructPageRelativeUrl } from "../../lib/routes";
+import { useNotificationCount } from "../../shared/notification-count-context";
+import { Button, Link } from "../../shared/ui";
+import type {
+ GraphChangeNotification,
+ Notification,
+ PageRelatedNotification,
+} from "./notifications-with-links-context";
+import { useNotificationsWithLinks } from "./notifications-with-links-context";
+
+const Table = styled(MuiTable)(({ theme }) => ({
+ borderCollapse: "separate",
+ borderSpacing: 0,
+ background: theme.palette.common.white,
+ borderRadius: "8px",
+ borderColor: theme.palette.gray[30],
+ borderStyle: "solid",
+ borderWidth: 1,
+ overflow: "hidden",
+}));
+
+const TableRow = styled(MuiTableRow)(() => ({
+ [`&:last-of-type .${tableCellClasses.body}`]: {
+ borderBottom: "none",
+ },
+ [`&:first-of-type .${tableCellClasses.head}`]: {
+ "&:first-of-type": {
+ borderTopLeftRadius: "8px",
+ },
+ "&:last-of-type": {
+ borderTopRightRadius: "8px",
+ },
+ },
+ [`&:last-of-type .${tableCellClasses.body}`]: {
+ "&:first-of-type": {
+ borderBottomLeftRadius: "8px",
+ },
+ "&:last-of-type": {
+ borderBottomRightRadius: "8px",
+ },
+ },
+}));
+
+const TableCell = styled(MuiTableCell)(({ theme }) => ({
+ whiteSpace: "nowrap",
+ border: 0,
+ borderStyle: "solid",
+ borderColor: theme.palette.gray[20],
+ borderBottomWidth: 1,
+ borderRightWidth: 1,
+ padding: theme.spacing(0.5, 1.5),
+ [`&.${tableCellClasses.head}`]: {
+ fontSize: 13,
+ fontWeight: 600,
+ color: theme.palette.common.black,
+ },
+ [`&.${tableCellClasses.body}`]: {
+ fontSize: 14,
+ fontWeight: 500,
+ color: theme.palette.gray[90],
+ },
+ "&:last-of-type": {
+ borderRightWidth: 0,
+ },
+}));
+
+const GraphChangeNotificationContent = ({
+ notification,
+ handleNotificationClick,
+ targetHref,
+}: {
+ notification: GraphChangeNotification;
+ handleNotificationClick: () => void;
+ targetHref?: string;
+}) => {
+ const { occurredInEntityLabel, occurredInEntity, operation } = notification;
+
+ return (
+
+ HASH AI {operation}d{" "}
+
+ {occurredInEntityLabel}
+ {" "}
+ {extractDraftIdFromEntityId(occurredInEntity.metadata.recordId.entityId)
+ ? "as draft"
+ : ""}
+
+ );
+};
+
+const PageRelatedNotificationContent = ({
+ notification,
+ handleNotificationClick,
+ targetHref,
+}: {
+ notification: PageRelatedNotification;
+ handleNotificationClick: () => void;
+ targetHref?: string;
+}) => {
+ const { kind, triggeredByUser, occurredInEntity } = notification;
+
+ const pageTitle = useMemo(() => {
+ const { title } = simplifyProperties(occurredInEntity.properties);
+
+ return title;
+ }, [occurredInEntity]);
+
+ return (
+ <>
+
+ {triggeredByUser.displayName}
+ {" "}
+ {kind === "new-comment"
+ ? "commented on "
+ : kind === "comment-reply"
+ ? "replied to your comment on "
+ : kind === "page-mention"
+ ? "mentioned you in "
+ : "mentioned you in a comment on "}
+
+ {pageTitle}
+
+ >
+ );
+};
+
+const NotificationRow: FunctionComponent<{ notification: Notification }> = ({
+ notification,
+}) => {
+ const { markNotificationAsRead } = useNotificationCount();
+ const { refetch } = useNotificationsWithLinks();
+
+ const handleNotificationClick = useCallback(async () => {
+ await markNotificationAsRead({
+ notificationEntityId: notification.entity.entityId,
+ });
+ refetch();
+ }, [markNotificationAsRead, notification, refetch]);
+
+ const ownedById = useMemo(
+ () =>
+ extractOwnedByIdFromEntityId(
+ notification.occurredInEntity.metadata.recordId.entityId,
+ ),
+ [notification],
+ );
+
+ const { shortname: entityOwningShortname } = useUserOrOrgShortnameByOwnedById(
+ { ownedById },
+ );
+
+ const targetHref = useMemo(() => {
+ if (!entityOwningShortname) {
+ return undefined;
+ }
+
+ if (notification.kind === "graph-change") {
+ return generateEntityPath({
+ entityId: notification.occurredInEntity.metadata.recordId.entityId,
+ includeDraftId: true,
+ shortname: entityOwningShortname,
+ });
+ }
+
+ const { occurredInBlock } = notification;
+
+ /** @todo: append query param if the mention was in a comment */
+ return constructPageRelativeUrl({
+ workspaceShortname: entityOwningShortname,
+ pageEntityUuid: extractEntityUuidFromEntityId(
+ notification.occurredInEntity.metadata.recordId.entityId,
+ ),
+ highlightedBlockEntityId: occurredInBlock.metadata.recordId.entityId,
+ });
+ }, [entityOwningShortname, notification]);
+
+ const humanReadableCreatedAt = useMemo(() => {
+ const now = new Date();
+
+ const createdAtTimestamp =
+ notification.kind === "graph-change"
+ ? (notification.occurredInEntityEditionTimestamp ??
+ notification.entity.metadata.provenance.createdAtDecisionTime)
+ : notification.entity.metadata.provenance.createdAtDecisionTime;
+
+ const createdAt = new Date(createdAtTimestamp);
+
+ const numberOfMinutesAgo = differenceInMinutes(now, createdAt);
+
+ if (numberOfMinutesAgo < 1) {
+ return "Just now";
+ }
+
+ if (isToday(createdAt)) {
+ if (numberOfMinutesAgo < 60) {
+ return `${numberOfMinutesAgo} minute${
+ numberOfMinutesAgo > 1 ? "s" : ""
+ } ago`;
+ }
+ const numberOfHoursAgo = Math.floor(numberOfMinutesAgo / 60);
+ return `${numberOfHoursAgo} hour${numberOfHoursAgo > 1 ? "s" : ""} ago`;
+ }
+ const numberOfDaysAgo = differenceInDays(now, createdAt);
+
+ if (numberOfDaysAgo < 7) {
+ return format(createdAt, "h:mma iiii"); // "12:00AM Monday"
+ }
+
+ if (isThisYear(createdAt)) {
+ return format(createdAt, "h:mma MMMM do"); // "12:00AM October 27th"
+ }
+
+ return format(createdAt, "h:mma MMMM do, yyyy"); // "12:00AM December 24th, 2022"
+ }, [notification]);
+
+ return (
+ palette.gray[20]
+ : undefined,
+ opacity: notification.readAt ? 0.6 : 1,
+ }}
+ >
+ palette.gray[70],
+ },
+ }}
+ >
+ {humanReadableCreatedAt}
+
+ palette.blue[70],
+ "&:hover": { color: ({ palette }) => palette.blue[90] },
+ },
+ }}
+ >
+ {notification.kind === "graph-change" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {notification.readAt ? null : (
+
+ )}
+
+
+ );
+};
+
+export const NotificationsTable = () => {
+ const { notifications } = useNotificationsWithLinks();
+
+ if (notifications?.length === 0) {
+ return You don't have any notifications.;
+ }
+
+ return (
+
+
+
+
+ When
+
+
+ Title
+
+
+ Actions
+
+
+
+ .${tableRowClasses.root}:last-of-type > .${tableCellClasses.root}`]:
+ {
+ borderBottomWidth: 0,
+ },
+ }}
+ >
+ {notifications ? (
+ notifications.map((notification) => (
+
+ ))
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/hash-frontend/src/pages/shared/notifications-with-links-context.tsx b/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx
similarity index 84%
rename from apps/hash-frontend/src/pages/shared/notifications-with-links-context.tsx
rename to apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx
index 3947902d5c9..c8bdc131cad 100644
--- a/apps/hash-frontend/src/pages/shared/notifications-with-links-context.tsx
+++ b/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx
@@ -1,13 +1,14 @@
import { useQuery } from "@apollo/client";
import type { VersionedUrl } from "@blockprotocol/type-system";
import { typedEntries, typedValues } from "@local/advanced-types/typed-entries";
-import type { Filter } from "@local/hash-graph-client";
import type { Entity } from "@local/hash-graph-sdk/entity";
import type { TextWithTokens } from "@local/hash-isomorphic-utils/entity";
import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label";
import {
currentTimeInstantTemporalAxes,
+ generateVersionedUrlMatchingFilter,
mapGqlSubgraphFieldsFragmentToSubgraph,
+ pageOrNotificationNotArchivedFilter,
zeroedGraphResolveDepths,
} from "@local/hash-isomorphic-utils/graph-queries";
import {
@@ -32,8 +33,10 @@ import type {
EntityVertex,
LinkEntityAndRightEntity,
} from "@local/hash-subgraph";
-import { extractEntityUuidFromEntityId } from "@local/hash-subgraph";
-import { getOutgoingLinkAndTargetEntities } from "@local/hash-subgraph/stdlib";
+import {
+ getOutgoingLinkAndTargetEntities,
+ getRoots,
+} from "@local/hash-subgraph/stdlib";
import type { FunctionComponent, PropsWithChildren } from "react";
import { createContext, useContext, useMemo, useRef } from "react";
@@ -44,7 +47,8 @@ import type {
import { getEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity.queries";
import type { MinimalUser } from "../../lib/user-and-org";
import { constructMinimalUser } from "../../lib/user-and-org";
-import { useNotificationEntities } from "../../shared/notification-entities-context";
+import { pollInterval } from "../../shared/poll-interval";
+import { useAuthInfo } from "../shared/auth-info-context";
export type PageMentionNotification = {
kind: "page-mention";
@@ -93,6 +97,7 @@ export type Notification = PageRelatedNotification | GraphChangeNotification;
type NotificationsWithLinksContextValue = {
notifications?: Notification[];
+ refetch: () => void;
};
export const NotificationsWithLinksContext =
@@ -118,33 +123,30 @@ const isLinkAndRightEntityWithLinkType =
export const useNotificationsWithLinksContextValue =
(): NotificationsWithLinksContextValue => {
- const { notificationEntities } = useNotificationEntities();
-
- const getNotificationEntitiesFilter = useMemo(
- () => ({
- any:
- notificationEntities?.map((entity) => ({
- equal: [
- { path: ["uuid"] },
- {
- parameter: extractEntityUuidFromEntityId(
- entity.metadata.recordId.entityId,
- ),
- },
- ],
- })) ?? [],
- }),
- [notificationEntities],
- );
+ const { authenticatedUser } = useAuthInfo();
- const { data: notificationsWithOutgoingLinksData } = useQuery<
+ const { data: notificationsWithOutgoingLinksData, refetch } = useQuery<
GetEntitySubgraphQuery,
GetEntitySubgraphQueryVariables
>(getEntitySubgraphQuery, {
variables: {
includePermissions: false,
request: {
- filter: getNotificationEntitiesFilter,
+ filter: {
+ all: [
+ {
+ equal: [
+ { path: ["ownedById"] },
+ { parameter: authenticatedUser?.accountId },
+ ],
+ },
+ generateVersionedUrlMatchingFilter(
+ systemEntityTypes.notification.entityTypeId,
+ { ignoreParents: false },
+ ),
+ pageOrNotificationNotArchivedFilter,
+ ],
+ },
graphResolveDepths: {
...zeroedGraphResolveDepths,
inheritsFrom: { outgoing: 255 },
@@ -157,16 +159,17 @@ export const useNotificationsWithLinksContextValue =
includeDrafts: true,
},
},
- skip: !notificationEntities || notificationEntities.length === 0,
+ skip: !authenticatedUser,
fetchPolicy: "network-only",
+ pollInterval,
});
- const outgoingLinksSubgraph = useMemo(
+ const notificationsSubgraph = useMemo(
() =>
notificationsWithOutgoingLinksData
- ? mapGqlSubgraphFieldsFragmentToSubgraph(
- notificationsWithOutgoingLinksData.getEntitySubgraph.subgraph,
- )
+ ? mapGqlSubgraphFieldsFragmentToSubgraph<
+ EntityRootType
+ >(notificationsWithOutgoingLinksData.getEntitySubgraph.subgraph)
: undefined,
[notificationsWithOutgoingLinksData],
);
@@ -176,12 +179,12 @@ export const useNotificationsWithLinksContextValue =
);
const notifications = useMemo(() => {
- if (notificationEntities && notificationEntities.length === 0) {
- return [];
- } else if (!outgoingLinksSubgraph || !notificationEntities) {
+ if (!notificationsSubgraph) {
return previouslyFetchedNotificationsRef.current ?? undefined;
}
+ const notificationEntities = getRoots(notificationsSubgraph);
+
const derivedNotifications = notificationEntities
.map((entity) => {
const {
@@ -194,7 +197,7 @@ export const useNotificationsWithLinksContextValue =
const { readAt } = simplifyProperties(entity.properties);
const outgoingLinks = getOutgoingLinkAndTargetEntities(
- outgoingLinksSubgraph,
+ notificationsSubgraph,
entityId,
);
@@ -252,7 +255,8 @@ export const useNotificationsWithLinksContextValue =
return {
kind: "comment-mention",
readAt,
- entity: entity as Entity,
+ entity:
+ entity as unknown as Entity,
occurredInEntity: occurredInEntity as Entity,
occurredInBlock: occurredInBlock as Entity,
occurredInText: occurredInText as Entity,
@@ -265,7 +269,8 @@ export const useNotificationsWithLinksContextValue =
return {
kind: "page-mention",
readAt,
- entity: entity as Entity,
+ entity:
+ entity as unknown as Entity,
occurredInEntity: occurredInEntity as Entity,
occurredInBlock: occurredInBlock as Entity,
occurredInText: occurredInText as Entity,
@@ -325,7 +330,8 @@ export const useNotificationsWithLinksContextValue =
return {
kind: "comment-reply",
readAt,
- entity: entity as Entity,
+ entity:
+ entity as unknown as Entity,
occurredInEntity: occurredInEntity as Entity,
occurredInBlock: occurredInBlock as Entity,
triggeredByComment:
@@ -338,7 +344,8 @@ export const useNotificationsWithLinksContextValue =
return {
kind: "new-comment",
readAt,
- entity: entity as Entity,
+ entity:
+ entity as unknown as Entity,
occurredInEntity: occurredInEntity as Entity,
occurredInBlock: occurredInBlock as Entity,
triggeredByComment:
@@ -380,14 +387,15 @@ export const useNotificationsWithLinksContextValue =
let occurredInEntity: Entity | undefined;
for (const [vertexKey, editionMap] of typedEntries(
- outgoingLinksSubgraph.vertices,
+ notificationsSubgraph.vertices,
)) {
/**
- * The created/updated record might be a draft, in which case it is keyed in the subgraph by `${entityId}~${draftId}`.
- * We need to find the vertex that _starts with_ the entityId and contains an edition at the exact timestamp from the link.
- * Needing to do this is a limitation caused by:
+ * The created/updated record might be a draft, in which case it is keyed in the subgraph by
+ * `${entityId}~${draftId}`. We need to find the vertex that _starts with_ the entityId and contains an
+ * edition at the exact timestamp from the link. Needing to do this is a limitation caused by:
* 1. The fact that links only point to the entire Entity, not any specific edition or draft series of it
- * 2. The logic for returning linked entities from the subgraph library will just return the non-draft entity if it is found
+ * 2. The logic for returning linked entities from the subgraph library will just return the non-draft
+ * entity if it is found
*/
if (!vertexKey.startsWith(linkRightEntityId)) {
continue;
@@ -396,9 +404,9 @@ export const useNotificationsWithLinksContextValue =
const editions = typedValues(editionMap).flat();
/**
- * We have a candidate – this might be one of multiple draft series for the entity, or the single live series.
- * We match the timestamp logged in the link to the editions of the entity.
- * This may result in a false positive if the live entity and any of its drafts have editions at the exact same timestamp.
+ * We have a candidate – this might be one of multiple draft series for the entity, or the single live
+ * series. We match the timestamp logged in the link to the editions of the entity. This may result in a
+ * false positive if the live entity and any of its drafts have editions at the exact same timestamp.
*/
occurredInEntity = editions.find(
(vertex): vertex is EntityVertex =>
@@ -412,12 +420,12 @@ export const useNotificationsWithLinksContextValue =
}
/**
- * If the entity has been updated since the notification was created, we won't have the edition in the subgraph,
- * because the request above only fetches editions still valid for the current timestamp.
- * In order to show the notification we just take any available edition.
+ * If the entity has been updated since the notification was created, we won't have the edition in the
+ * subgraph, because the request above only fetches editions still valid for the current timestamp. In
+ * order to show the notification we just take any available edition.
*
- * The other option would be to fetch the entire history for all entities which are the subject of a notification,
- * but this might be a lot of data.
+ * The other option would be to fetch the entire history for all entities which are the subject of a
+ * notification, but this might be a lot of data.
*/
const anyAvailableEdition = editions.find(
(vertex): vertex is EntityVertex => vertex.kind === "entity",
@@ -435,14 +443,14 @@ export const useNotificationsWithLinksContextValue =
}
const graphChangeEntity =
- entity as Entity;
+ entity as unknown as Entity;
return {
kind: "graph-change",
readAt,
entity: graphChangeEntity,
occurredInEntityLabel: generateEntityLabel(
- outgoingLinksSubgraph,
+ notificationsSubgraph,
occurredInEntity,
),
occurredInEntityEditionTimestamp,
@@ -489,11 +497,15 @@ export const useNotificationsWithLinksContextValue =
previouslyFetchedNotificationsRef.current = derivedNotifications;
return derivedNotifications;
- }, [notificationEntities, outgoingLinksSubgraph]);
+ }, [notificationsSubgraph]);
- return { notifications };
+ return { notifications, refetch };
};
+/**
+ * Context to provide full information on notifications, for use on the notifications page.
+ * A separate app-wide context provides only a count of notifications.
+ */
export const NotificationsWithLinksContextProvider: FunctionComponent<
PropsWithChildren
> = ({ children }) => {
diff --git a/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx b/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx
index 2b1213afc50..daa1912a07b 100644
--- a/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx
+++ b/apps/hash-frontend/src/pages/shared/accept-draft-entity-button.tsx
@@ -15,13 +15,12 @@ import type {
UpdateEntityMutationVariables,
} from "../../graphql/api-types.gen";
import { updateEntityMutation } from "../../graphql/queries/knowledge/entity.queries";
-import { useDraftEntities } from "../../shared/draft-entities-context";
+import { useDraftEntitiesCount } from "../../shared/draft-entities-count-context";
import { CheckRegularIcon } from "../../shared/icons/check-regular-icon";
-import { useNotificationEntities } from "../../shared/notification-entities-context";
+import { useNotificationCount } from "../../shared/notification-count-context";
import type { ButtonProps } from "../../shared/ui";
import { Button } from "../../shared/ui";
import { LinkLabelWithSourceAndDestination } from "./link-label-with-source-and-destination";
-import { useNotificationsWithLinks } from "./notifications-with-links-context";
const LeftOrRightEntityEndAdornment: FunctionComponent<{
isDraft: boolean;
@@ -136,37 +135,15 @@ export const AcceptDraftEntityButton: FunctionComponent<
UpdateEntityMutationVariables
>(updateEntityMutation);
- const { refetch: refetchDraftEntities } = useDraftEntities();
+ const { refetch: refetchDraftEntitiesCount } = useDraftEntitiesCount();
- const { markNotificationAsRead } = useNotificationEntities();
- const { notifications } = useNotificationsWithLinks();
-
- /**
- * Notifications are no longer created for draft entities, but they will exist for existing draft entities.
- * Can be removed in the future – change to stop notifs for draft entities made in March 2024.
- */
- const markRelatedGraphChangeNotificationsAsRead = useCallback(
- async (params: { draftEntity: Entity }) => {
- const relatedGraphChangeNotifications =
- notifications?.filter(
- ({ kind, occurredInEntity }) =>
- kind === "graph-change" &&
- occurredInEntity.metadata.recordId.entityId ===
- params.draftEntity.metadata.recordId.entityId,
- ) ?? [];
-
- await Promise.all(
- relatedGraphChangeNotifications.map((notification) =>
- markNotificationAsRead({ notificationEntity: notification.entity }),
- ),
- );
- },
- [notifications, markNotificationAsRead],
- );
+ const { markNotificationsAsReadForEntity } = useNotificationCount();
const acceptDraftEntity = useCallback(
async (params: { draftEntity: Entity }) => {
- await markRelatedGraphChangeNotificationsAsRead(params);
+ await markNotificationsAsReadForEntity({
+ targetEntityId: params.draftEntity.entityId,
+ });
const response = await updateEntity({
variables: {
@@ -178,7 +155,7 @@ export const AcceptDraftEntityButton: FunctionComponent<
},
});
- await refetchDraftEntities();
+ await refetchDraftEntitiesCount();
if (!response.data) {
throw new Error("An error occurred accepting the draft entity.");
@@ -186,11 +163,7 @@ export const AcceptDraftEntityButton: FunctionComponent<
return new Entity(response.data.updateEntity);
},
- [
- updateEntity,
- refetchDraftEntities,
- markRelatedGraphChangeNotificationsAsRead,
- ],
+ [markNotificationsAsReadForEntity, updateEntity, refetchDraftEntitiesCount],
);
const handleAccept = useCallback(async () => {
diff --git a/apps/hash-frontend/src/pages/shared/discard-draft-entity-button.tsx b/apps/hash-frontend/src/pages/shared/discard-draft-entity-button.tsx
index 2b101ca5252..d7dd51748bb 100644
--- a/apps/hash-frontend/src/pages/shared/discard-draft-entity-button.tsx
+++ b/apps/hash-frontend/src/pages/shared/discard-draft-entity-button.tsx
@@ -16,11 +16,9 @@ import type {
ArchiveEntityMutationVariables,
} from "../../graphql/api-types.gen";
import { archiveEntityMutation } from "../../graphql/queries/knowledge/entity.queries";
-import { useDraftEntities } from "../../shared/draft-entities-context";
-import { useNotificationEntities } from "../../shared/notification-entities-context";
+import { useNotificationCount } from "../../shared/notification-count-context";
import type { ButtonProps } from "../../shared/ui";
import { Button } from "../../shared/ui";
-import { useNotificationsWithLinks } from "./notifications-with-links-context";
export const DiscardDraftEntityButton: FunctionComponent<
{
@@ -34,34 +32,7 @@ export const DiscardDraftEntityButton: FunctionComponent<
onDiscardedEntity,
...buttonProps
}) => {
- const { refetch: refetchDraftEntities } = useDraftEntities();
-
- const { archiveNotification } = useNotificationEntities();
-
- const { notifications } = useNotificationsWithLinks();
-
- const archiveRelatedNotifications = useCallback(
- async (params: { draftEntity: Entity }) => {
- const relatedNotifications = notifications?.filter(
- (notification) =>
- notification.occurredInEntity.metadata.recordId.entityId ===
- params.draftEntity.metadata.recordId.entityId,
- );
-
- if (!relatedNotifications) {
- return;
- }
-
- await Promise.all(
- relatedNotifications.map((notification) => {
- return archiveNotification({
- notificationEntity: notification.entity,
- });
- }),
- );
- },
- [notifications, archiveNotification],
- );
+ const { archiveNotificationsForEntity } = useNotificationCount();
const [archiveEntity] = useMutation<
ArchiveEntityMutation,
@@ -70,15 +41,16 @@ export const DiscardDraftEntityButton: FunctionComponent<
const discardDraftEntity = useCallback(
async (params: { draftEntity: Entity }) => {
- await archiveRelatedNotifications(params);
+ await archiveNotificationsForEntity({
+ targetEntityId: params.draftEntity.entityId,
+ });
await archiveEntity({
variables: {
entityId: params.draftEntity.metadata.recordId.entityId,
},
});
- await refetchDraftEntities();
},
- [archiveEntity, archiveRelatedNotifications, refetchDraftEntities],
+ [archiveEntity, archiveNotificationsForEntity],
);
const [
diff --git a/apps/hash-frontend/src/shared/draft-entities-count-context.tsx b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx
new file mode 100644
index 00000000000..04c955cdb62
--- /dev/null
+++ b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx
@@ -0,0 +1,92 @@
+import { useQuery } from "@apollo/client";
+import {
+ currentTimeInstantTemporalAxes,
+ zeroedGraphResolveDepths,
+} from "@local/hash-isomorphic-utils/graph-queries";
+import type { FunctionComponent, PropsWithChildren } from "react";
+import { createContext, useContext, useMemo } from "react";
+
+import type {
+ GetEntitySubgraphQuery,
+ GetEntitySubgraphQueryVariables,
+} from "../graphql/api-types.gen";
+import { getEntitySubgraphQuery } from "../graphql/queries/knowledge/entity.queries";
+import { useAuthInfo } from "../pages/shared/auth-info-context";
+import { pollInterval } from "./poll-interval";
+
+export type DraftEntitiesCountContextValue = {
+ count?: number;
+ loading: boolean;
+ refetch: () => Promise;
+};
+
+export const DraftEntitiesCountContext =
+ createContext(null);
+
+export const useDraftEntitiesCount = () => {
+ const draftEntitiesContext = useContext(DraftEntitiesCountContext);
+
+ if (!draftEntitiesContext) {
+ throw new Error("DraftEntitiesCountContext missing");
+ }
+
+ return draftEntitiesContext;
+};
+
+export const DraftEntitiesCountContextProvider: FunctionComponent<
+ PropsWithChildren
+> = ({ children }) => {
+ const { authenticatedUser } = useAuthInfo();
+
+ const {
+ data: draftEntitiesData,
+ refetch,
+ loading,
+ } = useQuery(
+ getEntitySubgraphQuery,
+ {
+ variables: {
+ request: {
+ filter: {
+ all: [
+ {
+ // @ts-expect-error -- Support null in Path parameter in structural queries in Node
+ // @see https://linear.app/hash/issue/H-1207
+ notEqual: [{ path: ["draftId"] }, null],
+ },
+ {
+ equal: [{ path: ["archived"] }, { parameter: false }],
+ },
+ ],
+ },
+ temporalAxes: currentTimeInstantTemporalAxes,
+ graphResolveDepths: zeroedGraphResolveDepths,
+ includeCount: true,
+ includeDrafts: true,
+ limit: 0,
+ },
+ includePermissions: false,
+ },
+ pollInterval,
+ fetchPolicy: "network-only",
+ skip: !authenticatedUser,
+ },
+ );
+
+ const value = useMemo(
+ () => ({
+ count: draftEntitiesData?.getEntitySubgraph.count ?? undefined,
+ loading,
+ refetch: async () => {
+ await refetch();
+ },
+ }),
+ [draftEntitiesData, loading, refetch],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx b/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx
index d87da417b3b..d9ea8326217 100644
--- a/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx
+++ b/apps/hash-frontend/src/shared/layout/layout-with-header/notifications-dropdown.tsx
@@ -3,14 +3,14 @@ import { FontAwesomeIcon } from "@hashintel/design-system";
import { Tooltip, useTheme } from "@mui/material";
import type { FunctionComponent } from "react";
-import { useNotificationEntities } from "../../notification-entities-context";
+import { useNotificationCount } from "../../notification-count-context";
import { Link } from "../../ui";
import { HeaderIconButtonWithCount } from "./shared/header-icon-button-with-count";
export const NotificationsDropdown: FunctionComponent = () => {
const theme = useTheme();
- const { numberOfUnreadNotifications } = useNotificationEntities();
+ const { numberOfUnreadNotifications } = useNotificationCount();
return (
diff --git a/apps/hash-frontend/src/shared/layout/layout-with-header/page-header.tsx b/apps/hash-frontend/src/shared/layout/layout-with-header/page-header.tsx
index c549567853d..3e2fe88700d 100644
--- a/apps/hash-frontend/src/shared/layout/layout-with-header/page-header.tsx
+++ b/apps/hash-frontend/src/shared/layout/layout-with-header/page-header.tsx
@@ -4,7 +4,7 @@ import type { FunctionComponent, ReactNode } from "react";
import { useHashInstance } from "../../../components/hooks/use-hash-instance";
import { useLogoutFlow } from "../../../components/hooks/use-logout-flow";
import { useAuthInfo } from "../../../pages/shared/auth-info-context";
-import { useDraftEntities } from "../../draft-entities-context";
+import { useDraftEntitiesCount } from "../../draft-entities-count-context";
import { CheckRegularIcon } from "../../icons/check-regular-icon";
import { HashLockup } from "../../icons/hash-lockup";
import { Button, Link } from "../../ui";
@@ -37,7 +37,7 @@ export const PageHeader: FunctionComponent = () => {
const { authenticatedUser } = useAuthInfo();
const { hashInstance } = useHashInstance();
const { logout } = useLogoutFlow();
- const { draftEntities } = useDraftEntities();
+ const { count: draftEntitiesCount } = useDraftEntitiesCount();
return (
{
sx={{ color: theme.palette.blue[70] }}
/>
}
- count={draftEntities?.length}
+ count={draftEntitiesCount}
/>
diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx
index a335c56e5fc..2375e466359 100644
--- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx
+++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx
@@ -17,12 +17,12 @@ import {
import { useHashInstance } from "../../../components/hooks/use-hash-instance";
import { useEnabledFeatureFlags } from "../../../pages/shared/use-enabled-feature-flags";
import { useActiveWorkspace } from "../../../pages/shared/workspace-context";
-import { useDraftEntities } from "../../draft-entities-context";
+import { useDraftEntitiesCount } from "../../draft-entities-count-context";
import { ArrowRightToLineIcon } from "../../icons";
import { BoltLightIcon } from "../../icons/bolt-light-icon";
import { InboxIcon } from "../../icons/inbox-icon";
import { NoteIcon } from "../../icons/note-icon";
-import { useNotificationEntities } from "../../notification-entities-context";
+import { useNotificationCount } from "../../notification-count-context";
import { useRoutePageInfo } from "../../routing";
import { useUserPreferences } from "../../use-user-preferences";
import { AccountEntitiesList } from "./sidebar/account-entities-list";
@@ -72,9 +72,9 @@ export const PageSidebar: FunctionComponent = () => {
const { hashInstance } = useHashInstance();
- const { numberOfUnreadNotifications } = useNotificationEntities();
+ const { numberOfUnreadNotifications } = useNotificationCount();
- const { draftEntities } = useDraftEntities();
+ const { count: draftEntitiesCount } = useDraftEntitiesCount();
const workersSection = useMemo(
() =>
@@ -129,7 +129,7 @@ export const PageSidebar: FunctionComponent = () => {
});
}
- const numberOfPendingActions = draftEntities?.length ?? 0;
+ const numberOfPendingActions = draftEntitiesCount ?? 0;
return [
{
@@ -171,7 +171,7 @@ export const PageSidebar: FunctionComponent = () => {
: []),
];
}, [
- draftEntities,
+ draftEntitiesCount,
numberOfUnreadNotifications,
enabledFeatureFlags,
preferences,
diff --git a/apps/hash-frontend/src/shared/notification-count-context.tsx b/apps/hash-frontend/src/shared/notification-count-context.tsx
new file mode 100644
index 00000000000..abf17021242
--- /dev/null
+++ b/apps/hash-frontend/src/shared/notification-count-context.tsx
@@ -0,0 +1,330 @@
+import { useLazyQuery, useMutation, useQuery } from "@apollo/client";
+import type { EntityId } from "@local/hash-graph-types/entity";
+import type { BaseUrl } from "@local/hash-graph-types/ontology";
+import {
+ currentTimeInstantTemporalAxes,
+ generateVersionedUrlMatchingFilter,
+ mapGqlSubgraphFieldsFragmentToSubgraph,
+ pageOrNotificationNotArchivedFilter,
+ zeroedGraphResolveDepths,
+} from "@local/hash-isomorphic-utils/graph-queries";
+import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
+import type {
+ ArchivedPropertyValueWithMetadata,
+ Notification,
+ ReadAtPropertyValueWithMetadata,
+} from "@local/hash-isomorphic-utils/system-types/commentnotification";
+import type { EntityRootType } from "@local/hash-subgraph";
+import { extractEntityUuidFromEntityId } from "@local/hash-subgraph";
+import { getRoots } from "@local/hash-subgraph/stdlib";
+import type { FunctionComponent, PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useMemo } from "react";
+
+import type {
+ GetEntitySubgraphQuery,
+ GetEntitySubgraphQueryVariables,
+ UpdateEntitiesMutation,
+ UpdateEntitiesMutationVariables,
+ UpdateEntityMutation,
+ UpdateEntityMutationVariables,
+} from "../graphql/api-types.gen";
+import {
+ getEntitySubgraphQuery,
+ updateEntitiesMutation,
+ updateEntityMutation,
+} from "../graphql/queries/knowledge/entity.queries";
+import { useAuthInfo } from "../pages/shared/auth-info-context";
+import { pollInterval } from "./poll-interval";
+
+export type NotificationCountContextValues = {
+ numberOfUnreadNotifications?: number;
+ loading: boolean;
+ markNotificationAsRead: (params: {
+ notificationEntityId: EntityId;
+ }) => Promise;
+ /**
+ * Mark notifications as read if they link to a specific entity
+ */
+ markNotificationsAsReadForEntity: (params: {
+ targetEntityId: EntityId;
+ }) => Promise;
+ /**
+ * Archive notifications if they link to a specific entity
+ */
+ archiveNotificationsForEntity: (params: {
+ targetEntityId: EntityId;
+ }) => Promise;
+};
+
+export const NotificationCountContext =
+ createContext(null);
+
+export const useNotificationCount = () => {
+ const notificationCountContext = useContext(NotificationCountContext);
+
+ if (!notificationCountContext) {
+ throw new Error("Context missing");
+ }
+
+ return notificationCountContext;
+};
+
+/**
+ * This is app-wide context to provide:
+ * 1. The count of notifications
+ * 2. The ability to mark notifications as
+ * - read: keeps them visible on the notifications page
+ * - archived: they will no longer be visible, unless specifically sought out
+ *
+ * The notifications page has separate context which requests all notification data.
+ */
+export const NotificationCountContextProvider: FunctionComponent<
+ PropsWithChildren
+> = ({ children }) => {
+ const { authenticatedUser } = useAuthInfo();
+
+ const {
+ data: notificationCountData,
+ loading: loadingNotificationCount,
+ refetch: refetchNotificationCount,
+ } = useQuery(
+ getEntitySubgraphQuery,
+ {
+ pollInterval,
+ variables: {
+ includePermissions: false,
+ request: {
+ filter: {
+ all: [
+ {
+ equal: [
+ { path: ["ownedById"] },
+ { parameter: authenticatedUser?.accountId },
+ ],
+ },
+ generateVersionedUrlMatchingFilter(
+ systemEntityTypes.notification.entityTypeId,
+ { ignoreParents: false },
+ ),
+ pageOrNotificationNotArchivedFilter,
+ ],
+ },
+ graphResolveDepths: zeroedGraphResolveDepths,
+ temporalAxes: currentTimeInstantTemporalAxes,
+ includeDrafts: false,
+ includeCount: true,
+ limit: 0,
+ },
+ },
+ skip: !authenticatedUser,
+ fetchPolicy: "network-only",
+ },
+ );
+
+ const [getEntitySubgraph] = useLazyQuery<
+ GetEntitySubgraphQuery,
+ GetEntitySubgraphQueryVariables
+ >(getEntitySubgraphQuery, {
+ fetchPolicy: "network-only",
+ });
+
+ const [updateEntity] = useMutation<
+ UpdateEntityMutation,
+ UpdateEntityMutationVariables
+ >(updateEntityMutation, {
+ onCompleted: () => refetchNotificationCount(),
+ });
+
+ const [updateEntities] = useMutation<
+ UpdateEntitiesMutation,
+ UpdateEntitiesMutationVariables
+ >(updateEntitiesMutation, {
+ onCompleted: () => refetchNotificationCount(),
+ });
+
+ const getNotificationsLinkingToEntity = useCallback(
+ async ({ targetEntityId }: { targetEntityId: EntityId }) => {
+ const relatedNotificationData = await getEntitySubgraph({
+ variables: {
+ includePermissions: false,
+ request: {
+ filter: {
+ all: [
+ {
+ equal: [
+ { path: ["ownedById"] },
+ { parameter: authenticatedUser?.accountId },
+ ],
+ },
+ generateVersionedUrlMatchingFilter(
+ systemEntityTypes.notification.entityTypeId,
+ { ignoreParents: false },
+ ),
+ {
+ equal: [
+ { path: ["outgoingLinks", "rightEntity", "uuid"] },
+ {
+ parameter: extractEntityUuidFromEntityId(targetEntityId),
+ },
+ ],
+ },
+ ],
+ },
+ graphResolveDepths: zeroedGraphResolveDepths,
+ temporalAxes: currentTimeInstantTemporalAxes,
+ includeDrafts: false,
+ },
+ },
+ });
+
+ if (!relatedNotificationData.data?.getEntitySubgraph.subgraph) {
+ return [];
+ }
+
+ const subgraph = mapGqlSubgraphFieldsFragmentToSubgraph<
+ EntityRootType
+ >(relatedNotificationData.data.getEntitySubgraph.subgraph);
+
+ const notifications = getRoots(subgraph);
+
+ return notifications;
+ },
+ [authenticatedUser?.accountId, getEntitySubgraph],
+ );
+
+ const markNotificationAsRead = useCallback<
+ NotificationCountContextValues["markNotificationAsRead"]
+ >(
+ async (params) => {
+ const { notificationEntityId } = params;
+
+ const now = new Date();
+
+ await updateEntity({
+ variables: {
+ entityUpdate: {
+ entityId: notificationEntityId,
+ propertyPatches: [
+ {
+ op: "add",
+ path: [
+ "https://hash.ai/@hash/types/property-type/read-at/" satisfies keyof Notification["properties"] as BaseUrl,
+ ],
+ property: {
+ value: now.toISOString(),
+ metadata: {
+ dataTypeId:
+ "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1",
+ },
+ } satisfies ReadAtPropertyValueWithMetadata,
+ },
+ ],
+ },
+ },
+ });
+ },
+ [updateEntity],
+ );
+
+ const markNotificationsAsReadForEntity = useCallback<
+ NotificationCountContextValues["markNotificationsAsReadForEntity"]
+ >(
+ async (params) => {
+ const now = new Date();
+
+ const { targetEntityId } = params;
+
+ const notifications = await getNotificationsLinkingToEntity({
+ targetEntityId,
+ });
+
+ if (notifications.length) {
+ await updateEntities({
+ variables: {
+ entityUpdates: notifications.map((notification) => ({
+ entityId: notification.metadata.recordId.entityId,
+ propertyPatches: [
+ {
+ op: "add",
+ path: [
+ "https://hash.ai/@hash/types/property-type/read-at/" satisfies keyof Notification["properties"] as BaseUrl,
+ ],
+ property: {
+ value: now.toISOString(),
+ metadata: {
+ dataTypeId:
+ "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1",
+ },
+ } satisfies ReadAtPropertyValueWithMetadata,
+ },
+ ],
+ })),
+ },
+ });
+ }
+ },
+ [getNotificationsLinkingToEntity, updateEntities],
+ );
+
+ const archiveNotificationsForEntity = useCallback<
+ NotificationCountContextValues["archiveNotificationsForEntity"]
+ >(
+ async (params) => {
+ const { targetEntityId } = params;
+
+ const notifications = await getNotificationsLinkingToEntity({
+ targetEntityId,
+ });
+
+ if (notifications.length) {
+ await updateEntities({
+ variables: {
+ entityUpdates: notifications.map((notification) => ({
+ entityId: notification.metadata.recordId.entityId,
+ propertyPatches: [
+ {
+ op: "add",
+ path: [
+ "https://hash.ai/@hash/types/property-type/archived/" satisfies keyof Notification["properties"] as BaseUrl,
+ ],
+ property: {
+ value: true,
+ metadata: {
+ dataTypeId:
+ "https://blockprotocol.org/@blockprotocol/types/data-type/boolean/v/1",
+ },
+ } satisfies ArchivedPropertyValueWithMetadata,
+ },
+ ],
+ })),
+ },
+ });
+ }
+ },
+ [getNotificationsLinkingToEntity, updateEntities],
+ );
+
+ const value = useMemo(
+ () => ({
+ archiveNotificationsForEntity,
+ loading: loadingNotificationCount,
+ markNotificationAsRead,
+ markNotificationsAsReadForEntity,
+ numberOfUnreadNotifications:
+ notificationCountData?.getEntitySubgraph.count ?? undefined,
+ }),
+ [
+ archiveNotificationsForEntity,
+ loadingNotificationCount,
+ markNotificationAsRead,
+ markNotificationsAsReadForEntity,
+ notificationCountData,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/hash-frontend/src/shared/notification-entities-context.tsx b/apps/hash-frontend/src/shared/notification-entities-context.tsx
deleted file mode 100644
index c7a8b1787ca..00000000000
--- a/apps/hash-frontend/src/shared/notification-entities-context.tsx
+++ /dev/null
@@ -1,334 +0,0 @@
-import { useMutation, useQuery } from "@apollo/client";
-import type { Entity } from "@local/hash-graph-sdk/entity";
-import type { BaseUrl } from "@local/hash-graph-types/ontology";
-import {
- currentTimeInstantTemporalAxes,
- generateVersionedUrlMatchingFilter,
- mapGqlSubgraphFieldsFragmentToSubgraph,
- pageOrNotificationNotArchivedFilter,
- zeroedGraphResolveDepths,
-} from "@local/hash-isomorphic-utils/graph-queries";
-import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
-import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties";
-import type {
- ArchivedPropertyValueWithMetadata,
- CommentNotification,
- Notification,
- ReadAtPropertyValueWithMetadata,
-} from "@local/hash-isomorphic-utils/system-types/commentnotification";
-import type { GraphChangeNotification } from "@local/hash-isomorphic-utils/system-types/graphchangenotification";
-import type { MentionNotification } from "@local/hash-isomorphic-utils/system-types/mentionnotification";
-import type { EntityRootType } from "@local/hash-subgraph";
-import { getRoots } from "@local/hash-subgraph/stdlib";
-import type { FunctionComponent, PropsWithChildren } from "react";
-import { createContext, useCallback, useContext, useMemo } from "react";
-
-import type {
- GetEntitySubgraphQuery,
- GetEntitySubgraphQueryVariables,
- UpdateEntitiesMutation,
- UpdateEntitiesMutationVariables,
- UpdateEntityMutation,
- UpdateEntityMutationVariables,
-} from "../graphql/api-types.gen";
-import {
- getEntitySubgraphQuery,
- updateEntitiesMutation,
- updateEntityMutation,
-} from "../graphql/queries/knowledge/entity.queries";
-import { useAuthInfo } from "../pages/shared/auth-info-context";
-import { pollInterval } from "./poll-interval";
-
-export type NotificationEntitiesContextValues = {
- notificationEntities?: Entity<
- | Notification
- | MentionNotification
- | CommentNotification
- | GraphChangeNotification
- >[];
- numberOfUnreadNotifications?: number;
- loading: boolean;
- refetch: () => Promise;
- markNotificationAsRead: (params: {
- notificationEntity: Entity;
- }) => Promise;
- markNotificationsAsRead: (params: {
- notificationEntities: Entity[];
- }) => Promise;
- archiveNotification: (params: {
- notificationEntity: Entity;
- }) => Promise;
- archiveNotifications: (params: {
- notificationEntities: Entity[];
- }) => Promise;
-};
-
-export const NotificationEntitiesContext =
- createContext(null);
-
-export const useNotificationEntities = () => {
- const notificationsEntitiesContext = useContext(NotificationEntitiesContext);
-
- if (!notificationsEntitiesContext) {
- throw new Error("Context missing");
- }
-
- return notificationsEntitiesContext;
-};
-
-export const NotificationEntitiesContextProvider: FunctionComponent<
- PropsWithChildren
-> = ({ children }) => {
- const { authenticatedUser } = useAuthInfo();
-
- const {
- data: notificationEntitiesData,
- loading: loadingNotificationEntities,
- refetch: refetchNotificationEntities,
- } = useQuery(
- getEntitySubgraphQuery,
- {
- pollInterval,
- variables: {
- includePermissions: false,
- request: {
- filter: {
- all: [
- {
- equal: [
- { path: ["ownedById"] },
- { parameter: authenticatedUser?.accountId },
- ],
- },
- generateVersionedUrlMatchingFilter(
- systemEntityTypes.notification.entityTypeId,
- { ignoreParents: false },
- ),
- pageOrNotificationNotArchivedFilter,
- ],
- },
- graphResolveDepths: zeroedGraphResolveDepths,
- temporalAxes: currentTimeInstantTemporalAxes,
- includeDrafts: false,
- },
- },
- skip: !authenticatedUser,
- fetchPolicy: "network-only",
- },
- );
-
- const notificationEntitiesSubgraph = useMemo(
- () =>
- notificationEntitiesData
- ? mapGqlSubgraphFieldsFragmentToSubgraph>(
- notificationEntitiesData.getEntitySubgraph.subgraph,
- )
- : undefined,
- [notificationEntitiesData],
- );
-
- const notificationEntities = useMemo<
- | Entity<
- | Notification
- | MentionNotification
- | CommentNotification
- | GraphChangeNotification
- >[]
- | undefined
- >(
- () =>
- notificationEntitiesSubgraph
- ? getRoots(notificationEntitiesSubgraph)
- : undefined,
- [notificationEntitiesSubgraph],
- );
-
- const refetch = useCallback(async () => {
- await refetchNotificationEntities();
- }, [refetchNotificationEntities]);
-
- const [updateEntity] = useMutation<
- UpdateEntityMutation,
- UpdateEntityMutationVariables
- >(updateEntityMutation);
-
- const markNotificationAsRead = useCallback(
- async (params: { notificationEntity: Entity }) => {
- const { notificationEntity } = params;
-
- const now = new Date();
-
- await updateEntity({
- variables: {
- entityUpdate: {
- entityId: notificationEntity.metadata.recordId.entityId,
- propertyPatches: [
- {
- op: "add",
- path: [
- "https://hash.ai/@hash/types/property-type/read-at/" satisfies keyof Notification["properties"] as BaseUrl,
- ],
- property: {
- value: now.toISOString(),
- metadata: {
- dataTypeId:
- "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1",
- },
- } satisfies ReadAtPropertyValueWithMetadata,
- },
- ],
- },
- },
- });
-
- await refetch();
- },
- [updateEntity, refetch],
- );
-
- const [updateEntities] = useMutation<
- UpdateEntitiesMutation,
- UpdateEntitiesMutationVariables
- >(updateEntitiesMutation);
-
- const markNotificationsAsRead = useCallback(
- async (params: { notificationEntities: Entity[] }) => {
- const now = new Date();
-
- await updateEntities({
- variables: {
- entityUpdates: params.notificationEntities.map(
- (notificationEntity) => ({
- entityId: notificationEntity.metadata.recordId.entityId,
- entityTypeIds: notificationEntity.metadata.entityTypeIds,
- propertyPatches: [
- {
- op: "add",
- path: [
- "https://hash.ai/@hash/types/property-type/read-at/" satisfies keyof Notification["properties"] as BaseUrl,
- ],
- property: {
- value: now.toISOString(),
- metadata: {
- dataTypeId:
- "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1",
- },
- } satisfies ReadAtPropertyValueWithMetadata,
- },
- ],
- }),
- ),
- },
- });
-
- await refetch();
- },
- [updateEntities, refetch],
- );
-
- const archiveNotification = useCallback(
- async (params: { notificationEntity: Entity; shouldRefetch?: boolean }) => {
- const { notificationEntity, shouldRefetch = true } = params;
-
- await updateEntity({
- variables: {
- entityUpdate: {
- entityId: notificationEntity.metadata.recordId.entityId,
- entityTypeIds: notificationEntity.metadata.entityTypeIds,
- propertyPatches: [
- {
- op: "add",
- path: [
- "https://hash.ai/@hash/types/property-type/archived/" satisfies keyof Notification["properties"] as BaseUrl,
- ],
- property: {
- value: true,
- metadata: {
- dataTypeId:
- "https://blockprotocol.org/@blockprotocol/types/data-type/boolean/v/1",
- },
- } satisfies ArchivedPropertyValueWithMetadata,
- },
- ],
- },
- },
- });
-
- if (shouldRefetch) {
- await refetch();
- }
- },
- [updateEntity, refetch],
- );
-
- const archiveNotifications = useCallback(
- async (params: { notificationEntities: Entity[] }) => {
- await updateEntities({
- variables: {
- entityUpdates: params.notificationEntities.map(
- (notificationEntity) => ({
- entityId: notificationEntity.metadata.recordId.entityId,
- entityTypeIds: notificationEntity.metadata.entityTypeIds,
- propertyPatches: [
- {
- op: "add",
- path: [
- "https://hash.ai/@hash/types/property-type/archived/" satisfies keyof Notification["properties"] as BaseUrl,
- ],
- property: {
- value: true,
- metadata: {
- dataTypeId:
- "https://blockprotocol.org/@blockprotocol/types/data-type/boolean/v/1",
- },
- } satisfies ArchivedPropertyValueWithMetadata,
- },
- ],
- }),
- ),
- },
- });
- await refetch();
- },
- [updateEntities, refetch],
- );
-
- const numberOfUnreadNotifications = useMemo(
- () =>
- notificationEntities?.filter(({ properties }) => {
- const { readAt } = simplifyProperties(properties);
-
- return !readAt;
- }).length,
- [notificationEntities],
- );
-
- const value = useMemo(
- () => ({
- notificationEntities,
- numberOfUnreadNotifications,
- loading: loadingNotificationEntities,
- archiveNotifications,
- refetch,
- markNotificationAsRead,
- markNotificationsAsRead,
- archiveNotification,
- }),
- [
- notificationEntities,
- numberOfUnreadNotifications,
- archiveNotifications,
- loadingNotificationEntities,
- refetch,
- markNotificationAsRead,
- markNotificationsAsRead,
- archiveNotification,
- ],
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/generation.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/generation.typedef.ts
index 976f10a4255..878ca394c90 100644
--- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/generation.typedef.ts
+++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/generation.typedef.ts
@@ -9,8 +9,6 @@ export const generationTypedef = gql`
extend type Query {
"""
Generates the plural form of a word or phrase (e.g. Company -> Companies)
-
- TODO handle missing API keys gracefully for self-hosted instances
"""
generatePlural(singular: String!): String!
diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts
index d4fdf24323a..183f0df636d 100644
--- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts
+++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts
@@ -24,6 +24,7 @@ export const entityTypedef = gql`
}
type GetEntitySubgraphResponse {
+ count: Int
closedMultiEntityTypes: ClosedMultiEntityTypesRootMap
definitions: ClosedMultiEntityTypesDefinitions
userPermissionsOnEntities: UserPermissionsOnEntities!