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!