From 23e663301e371ce9e33f92ed1de1617aef365275 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Mon, 16 Oct 2023 15:47:38 -0600 Subject: [PATCH 01/35] create notifications collection and api resolver --- server/src/core/server/data/context.ts | 6 ++ .../server/graph/loaders/Notifications.ts | 19 ++++++ server/src/core/server/graph/loaders/index.ts | 2 + .../src/core/server/graph/resolvers/Query.ts | 6 ++ .../core/server/graph/schema/schema.graphql | 63 +++++++++++++++++++ .../models/notifications/notification.ts | 40 ++++++++++++ 6 files changed, 136 insertions(+) create mode 100644 server/src/core/server/graph/loaders/Notifications.ts create mode 100644 server/src/core/server/models/notifications/notification.ts diff --git a/server/src/core/server/data/context.ts b/server/src/core/server/data/context.ts index 375a4b37be..9aa1ec6f15 100644 --- a/server/src/core/server/data/context.ts +++ b/server/src/core/server/data/context.ts @@ -8,6 +8,7 @@ import { DSAReport } from "coral-server/models/dsaReport/report"; import { createCollection } from "coral-server/models/helpers"; import { Invite } from "coral-server/models/invite"; import { MigrationRecord } from "coral-server/models/migration"; +import { Notification } from "coral-server/models/notifications/notification"; import { PersistedQuery } from "coral-server/models/queries"; import { SeenComments } from "coral-server/models/seenComments/seenComments"; import { Site } from "coral-server/models/site"; @@ -37,6 +38,7 @@ export interface MongoContext { >; seenComments(): Collection>; dsaReports(): Collection>; + notifications(): Collection>; } export class MongoContextImpl implements MongoContext { @@ -88,6 +90,10 @@ export class MongoContextImpl implements MongoContext { public dsaReports(): Collection> { return createCollection("dsaReports")(this.live); } + public notifications(): Collection> { + return createCollection("notifications")(this.live); + } + public archivedComments(): Collection> { if (!this.archive) { throw new Error( diff --git a/server/src/core/server/graph/loaders/Notifications.ts b/server/src/core/server/graph/loaders/Notifications.ts new file mode 100644 index 0000000000..c9fcd2b8dc --- /dev/null +++ b/server/src/core/server/graph/loaders/Notifications.ts @@ -0,0 +1,19 @@ +import Context from "coral-server/graph/context"; +import { + NotificationsConnectionInput, + retrieveNotificationsConnection, +} from "coral-server/models/notifications/notification"; + +export default (ctx: Context) => ({ + connection: async ({ + ownerID, + first, + after, + }: NotificationsConnectionInput) => { + return await retrieveNotificationsConnection(ctx.mongo, ctx.tenant.id, { + ownerID, + first, + after, + }); + }, +}); diff --git a/server/src/core/server/graph/loaders/index.ts b/server/src/core/server/graph/loaders/index.ts index 1b47829656..64a77c41bd 100644 --- a/server/src/core/server/graph/loaders/index.ts +++ b/server/src/core/server/graph/loaders/index.ts @@ -4,6 +4,7 @@ import Auth from "./Auth"; import CommentActions from "./CommentActions"; import CommentModerationActions from "./CommentModerationActions"; import Comments from "./Comments"; +import Notifications from "./Notifications"; import SeenComments from "./SeenComments"; import Sites from "./Sites"; import Stories from "./Stories"; @@ -18,4 +19,5 @@ export default (ctx: Context) => ({ Users: Users(ctx), Sites: Sites(ctx), SeenComments: SeenComments(ctx), + Notifications: Notifications(ctx), }); diff --git a/server/src/core/server/graph/resolvers/Query.ts b/server/src/core/server/graph/resolvers/Query.ts index c8857f7aa7..97acb1e9cd 100644 --- a/server/src/core/server/graph/resolvers/Query.ts +++ b/server/src/core/server/graph/resolvers/Query.ts @@ -71,4 +71,10 @@ export const Query: Required> = { }, }, }), + notifications: (source, { ownerID, first, after }, ctx) => + ctx.loaders.Notifications.connection({ + ownerID, + first: defaultTo(first, 10), + after, + }), }; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index df8279110d..12149c3b03 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -4459,6 +4459,61 @@ type Queues { unarchiver: Queue! } +################################################################################ +## Notifications +################################################################################ + +type Notification { + """ + id is the uuid identifier for the notification. + """ + id: ID! + + """ + ownerID is the string identifier for who this notification is directed to. + """ + ownerID: ID! + + """ + createdAt is when this notification was created, used for sorting. + """ + createdAt: Time! + + """ + body is the content of this notification. + """ + body: String! +} + +type NotificationEdge { + """ + node is the Flag for this edge. + """ + node: Notification! + + """ + cursor is used in pagination. + """ + cursor: Cursor! +} + +type NotificationsConnection { + """ + edges are a subset of FlagEdge's. + """ + edges: [NotificationEdge!]! + + """ + nodes is a list of Flags. + """ + nodes: [Notification!]! + + """ + pageInfo is information to aid in pagination. + """ + pageInfo: PageInfo! +} + ################################################################################ ## Query ################################################################################ @@ -4625,6 +4680,14 @@ type Query { emailDomain will return a specific emailDomain configuration if it exists. """ emailDomain(id: ID!): EmailDomain @auth(roles: [ADMIN]) + + """ + notifications will return a list of notifications for an ownerID. + """ + notifications( + ownerID: ID! + first: Int = 10 @constraint(max: 50) + after: Cursor): NotificationsConnection! } ################################################################################ diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts new file mode 100644 index 0000000000..e28a29c58d --- /dev/null +++ b/server/src/core/server/models/notifications/notification.ts @@ -0,0 +1,40 @@ +import { MongoContext } from "coral-server/data/context"; + +import { ConnectionInput, Query, resolveConnection } from "../helpers"; +import { TenantResource } from "../tenant"; + +export interface Notification extends TenantResource { + readonly id: string; + + readonly tenantID: string; + + createdAt: Date; + + ownerID: string; + + reportID?: string; + + body?: string; +} + +type BaseConnectionInput = ConnectionInput; + +export interface NotificationsConnectionInput extends BaseConnectionInput { + ownerID: string; +} + +export const retrieveNotificationsConnection = async ( + mongo: MongoContext, + tenantID: string, + input: NotificationsConnectionInput +) => { + const query = new Query(mongo.notifications()).where({ tenantID }); + + query.orderBy({ createdAt: -1 }); + + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + return resolveConnection(query, input, (n) => n.createdAt); +}; From 3b34ebd5ba79a590313f664e785f226646bbc9ff Mon Sep 17 00:00:00 2001 From: nick-funk Date: Tue, 17 Oct 2023 15:36:10 -0600 Subject: [PATCH 02/35] create preliminary notifications tab --- client/src/core/client/stream/App/App.tsx | 8 ++ client/src/core/client/stream/App/TabBar.tsx | 32 ++++- .../client/stream/App/TabBarContainer.tsx | 1 + client/src/core/client/stream/classes.ts | 2 + .../Notifications/NotificationContainer.tsx | 25 ++++ .../Notifications/NotificationsContainer.tsx | 26 ++++ .../Notifications/NotificationsListQuery.tsx | 41 ++++++ .../Notifications/NotificationsPaginator.tsx | 119 ++++++++++++++++++ .../tabs/Notifications/NotificationsQuery.tsx | 72 +++++++++++ 9 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx diff --git a/client/src/core/client/stream/App/App.tsx b/client/src/core/client/stream/App/App.tsx index 915d7c2db9..d99b760fef 100644 --- a/client/src/core/client/stream/App/App.tsx +++ b/client/src/core/client/stream/App/App.tsx @@ -4,6 +4,7 @@ import React, { FunctionComponent } from "react"; import { useCoralContext } from "coral-framework/lib/bootstrap/CoralContext"; import CLASSES from "coral-stream/classes"; +import NotificationsQuery from "coral-stream/tabs/Notifications/NotificationsQuery"; import { HorizontalGutter, TabContent, TabPane } from "coral-ui/components/v2"; import Comments from "../tabs/Comments"; @@ -68,6 +69,13 @@ const App: FunctionComponent = (props) => { > + + + diff --git a/client/src/core/client/stream/App/TabBar.tsx b/client/src/core/client/stream/App/TabBar.tsx index d9b5614b9b..a5edbb9e98 100644 --- a/client/src/core/client/stream/App/TabBar.tsx +++ b/client/src/core/client/stream/App/TabBar.tsx @@ -8,6 +8,7 @@ import { CogIcon, ConversationChatIcon, ConversationQuestionWarningIcon, + InformationCircleIcon, MessagesBubbleSquareIcon, RatingStarIcon, SingleNeutralCircleIcon, @@ -17,7 +18,12 @@ import { MatchMedia, Tab, TabBar } from "coral-ui/components/v2"; import styles from "./TabBar.css"; -type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; +type TabValue = + | "COMMENTS" + | "PROFILE" + | "DISCUSSIONS" + | "NOTIFICATIONS" + | "%future added value"; export interface Props { activeTab: TabValue; @@ -25,6 +31,7 @@ export interface Props { showProfileTab: boolean; showDiscussionsTab: boolean; showConfigureTab: boolean; + showNotificationsTab: boolean; mode: | "COMMENTS" | "QA" @@ -53,6 +60,10 @@ const AppTabBar: FunctionComponent = (props) => { ); const myProfileText = getMessage("general-tabBar-myProfileTab", "My Profile"); const configureText = getMessage("general-tabBar-configure", "Configure"); + const notificationsText = getMessage( + "general-tabBar-notifications", + "Notifications" + ); return ( @@ -155,6 +166,25 @@ const AppTabBar: FunctionComponent = (props) => { )} )} + + {props.showNotificationsTab && ( + + {matches ? ( + {notificationsText} + ) : ( +
+ +
+ )} +
+ )} )}
diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 294bcdf957..8652a5f8dd 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -66,6 +66,7 @@ export const TabBarContainer: FunctionComponent = ({ showProfileTab={!!viewer} showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} + showNotificationsTab={true} onTabClick={handleSetActiveTab} /> ); diff --git a/client/src/core/client/stream/classes.ts b/client/src/core/client/stream/classes.ts index 239fd069fd..2db9898908 100644 --- a/client/src/core/client/stream/classes.ts +++ b/client/src/core/client/stream/classes.ts @@ -77,6 +77,8 @@ const CLASSES = { configure: "coral coral-tabBar-tab coral-tabBar-configure", activeTab: "coral-tabBar-tab-active", + + notifications: "coral coral-tabBar-tab coral-tabBar-notifications", }, /** diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx new file mode 100644 index 0000000000..11d5da5a91 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -0,0 +1,25 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; + +interface Props { + notification: NotificationContainer_notification; +} + +const NotificationContainer: FunctionComponent = ({ notification }) => { + return
{notification.body}
; +}; + +const enhanced = withFragmentContainer({ + notification: graphql` + fragment NotificationContainer_notification on Notification { + id + body + } + `, +})(NotificationContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx new file mode 100644 index 0000000000..796c74372e --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx @@ -0,0 +1,26 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import { NotificationsContainer_viewer } from "coral-stream/__generated__/NotificationsContainer_viewer.graphql"; + +import NotificationsListQuery from "./NotificationsListQuery"; + +interface Props { + viewer: NotificationsContainer_viewer; +} + +const NotificationsContainer: FunctionComponent = ({ viewer }) => { + return ; +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment NotificationsContainer_viewer on User { + id + } + `, +})(NotificationsContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx new file mode 100644 index 0000000000..1b53319661 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { QueryRenderer } from "coral-framework/lib/relay"; +import { Spinner } from "coral-ui/components/v2"; +import { QueryError } from "coral-ui/components/v3"; + +import { NotificationsListQuery as QueryTypes } from "coral-stream/__generated__/NotificationsListQuery.graphql"; +import NotificationsPaginator from "./NotificationsPaginator"; + +interface Props { + viewerID: string; +} + +const NotificationsListQuery: FunctionComponent = ({ viewerID }) => { + return ( + + query={graphql` + query NotificationsListQuery($viewerID: ID!) { + ...NotificationsPaginator_query @arguments(viewerID: $viewerID) + } + `} + variables={{ + viewerID, + }} + render={({ error, props }) => { + if (error) { + return ; + } + + if (!props) { + return ; + } + + return ; + }} + /> + ); +}; + +export default NotificationsListQuery; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx new file mode 100644 index 0000000000..95fe6cb545 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx @@ -0,0 +1,119 @@ +import React, { FunctionComponent, useCallback, useState } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import { useRefetch, withPaginationContainer } from "coral-framework/lib/relay"; +import Spinner from "coral-stream/common/Spinner"; + +import { NotificationsPaginator_query } from "coral-stream/__generated__/NotificationsPaginator_query.graphql"; +import { NotificationsPaginatorPaginationQueryVariables } from "coral-stream/__generated__/NotificationsPaginatorPaginationQuery.graphql"; + +import NotificationContainer from "./NotificationContainer"; + +interface Props { + query: NotificationsPaginator_query; + relay: RelayPaginationProp; + viewerID: string; +} + +const NotificationsPaginator: FunctionComponent = (props) => { + const [disableLoadMore, setDisableLoadMore] = useState(false); + + const [, isRefetching] = + useRefetch( + props.relay, + 10, + { viewerID: props.viewerID } + ); + + const loadMore = useCallback(() => { + if (!props.relay.hasMore() || props.relay.isLoading()) { + return; + } + + setDisableLoadMore(true); + + props.relay.loadMore( + 10, // Fetch the next 10 feed items + (error: any) => { + setDisableLoadMore(false); + + if (error) { + // eslint-disable-next-line no-console + console.error(error); + } + } + ); + }, [props.relay]); + + if (!props.query) { + return null; + } + + return ( +
+ {props.query.notifications.edges.map(({ node }) => { + return ; + })} + {isRefetching && } + {!isRefetching && !disableLoadMore && props.relay.hasMore() && ( + + )} +
+ ); +}; + +type FragmentVariables = NotificationsPaginatorPaginationQueryVariables; + +const enhanced = withPaginationContainer< + Props, + NotificationsPaginatorPaginationQueryVariables, + FragmentVariables +>( + { + query: graphql` + fragment NotificationsPaginator_query on Query + @argumentDefinitions( + count: { type: "Int", defaultValue: 10 } + cursor: { type: "Cursor" } + viewerID: { type: "ID!" } + ) { + notifications(ownerID: $viewerID, after: $cursor, first: $count) + @connection(key: "NotificationsPaginator_notifications") { + edges { + node { + id + ...NotificationContainer_notification + } + } + } + } + `, + }, + { + getConnectionFromProps(props) { + return props.query && props.query.notifications; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + ...fragmentVariables, + count, + cursor, + viewerID: props.viewerID, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query NotificationsPaginatorPaginationQuery( + $viewerID: ID! + $cursor: Cursor + $count: Int! + ) { + ...NotificationsPaginator_query + @arguments(viewerID: $viewerID, count: $count, cursor: $cursor) + } + `, + } +)(NotificationsPaginator); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx new file mode 100644 index 0000000000..0213ef8802 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx @@ -0,0 +1,72 @@ +import { Localized } from "@fluent/react/compat"; +import { once } from "lodash"; +import React, { FunctionComponent, Suspense } from "react"; +import { graphql } from "react-relay"; + +import { QueryRenderData, QueryRenderer } from "coral-framework/lib/relay"; +import { Delay, Spinner } from "coral-ui/components/v2"; +import { QueryError } from "coral-ui/components/v3"; + +import { NotificationsQuery as QueryTypes } from "coral-stream/__generated__/NotificationsQuery.graphql"; + +const loadNotificationsContainer = () => + import("./NotificationsContainer" /* webpackChunkName: "notifications" */); + +// (cvle) For some reason without `setTimeout` this request will block other requests. +const preload = once(() => + setTimeout(() => { + void loadNotificationsContainer(); + }, 0) +); + +const LazyLoadContainer = React.lazy(loadNotificationsContainer); + +export const render = ({ error, props }: QueryRenderData) => { + if (error) { + return ; + } + + preload(); + + if (!props) { + return ( + + + + ); + } + + if (!props.viewer) { + return ( + +
Error loading notifications
+
+ ); + } + + return ( + }> + + + ); +}; + +const NotificationsQuery: FunctionComponent = () => { + return ( + + query={graphql` + query NotificationsQuery { + viewer { + ...NotificationsContainer_viewer + } + } + `} + variables={{}} + render={(data) => { + return render(data); + }} + /> + ); +}; + +export default NotificationsQuery; From b31f69149877542dbdaf2cd8e2ffc9ad8df4adfa Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 11:31:09 -0600 Subject: [PATCH 03/35] create preliminary notifications context --- .../app/handlers/api/account/notifications.ts | 2 +- .../core/server/cron/notificationDigesting.ts | 2 +- .../core/server/events/listeners/notifier.ts | 2 +- server/src/core/server/graph/context.ts | 8 ++ .../models/notifications/notification.ts | 9 ++ .../core/server/queue/tasks/notifier/index.ts | 2 +- .../server/queue/tasks/notifier/messages.ts | 6 +- .../server/queue/tasks/notifier/processor.ts | 6 +- .../{ => email}/categories/categories.ts | 0 .../{ => email}/categories/category.ts | 0 .../{ => email}/categories/featured.ts | 0 .../{ => email}/categories/index.ts | 0 .../{ => email}/categories/moderation.ts | 0 .../{ => email}/categories/reply.ts | 0 .../{ => email}/categories/staffReply.ts | 0 .../{ => email}/categories/unsubscribe.ts | 0 .../notifications/{ => email}/context.ts | 0 .../notifications/{ => email}/notification.ts | 0 .../notifications/internal/context.ts | 99 +++++++++++++++++++ 19 files changed, 126 insertions(+), 10 deletions(-) rename server/src/core/server/services/notifications/{ => email}/categories/categories.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/category.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/featured.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/index.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/moderation.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/reply.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/staffReply.ts (100%) rename server/src/core/server/services/notifications/{ => email}/categories/unsubscribe.ts (100%) rename server/src/core/server/services/notifications/{ => email}/context.ts (100%) rename server/src/core/server/services/notifications/{ => email}/notification.ts (100%) create mode 100644 server/src/core/server/services/notifications/internal/context.ts diff --git a/server/src/core/server/app/handlers/api/account/notifications.ts b/server/src/core/server/app/handlers/api/account/notifications.ts index ff7daccabb..dbec9512c3 100644 --- a/server/src/core/server/app/handlers/api/account/notifications.ts +++ b/server/src/core/server/app/handlers/api/account/notifications.ts @@ -2,7 +2,7 @@ import { AppOptions } from "coral-server/app"; import { RequestLimiter } from "coral-server/app/request/limiter"; import { updateUserNotificationSettings } from "coral-server/models/user"; import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; -import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe"; +import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/email/categories/unsubscribe"; import { RequestHandler, TenantCoralRequest } from "coral-server/types/express"; export type UnsubscribeCheckOptions = Pick< diff --git a/server/src/core/server/cron/notificationDigesting.ts b/server/src/core/server/cron/notificationDigesting.ts index 993dd1e8b3..a6f5c6a317 100644 --- a/server/src/core/server/cron/notificationDigesting.ts +++ b/server/src/core/server/cron/notificationDigesting.ts @@ -5,7 +5,7 @@ import { MongoContext } from "coral-server/data/context"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import NotificationContext from "coral-server/services/notifications/context"; +import NotificationContext from "coral-server/services/notifications/email/context"; import { TenantCache } from "coral-server/services/tenant/cache"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/events/listeners/notifier.ts b/server/src/core/server/events/listeners/notifier.ts index 6ecd044140..a8dfb80142 100644 --- a/server/src/core/server/events/listeners/notifier.ts +++ b/server/src/core/server/events/listeners/notifier.ts @@ -1,5 +1,5 @@ import { NotifierQueue } from "coral-server/queue/tasks/notifier"; -import { categories } from "coral-server/services/notifications/categories"; +import { categories } from "coral-server/services/notifications/email/categories"; import { CommentFeaturedCoralEventPayload, diff --git a/server/src/core/server/graph/context.ts b/server/src/core/server/graph/context.ts index d68c3feca5..585db3ec2b 100644 --- a/server/src/core/server/graph/context.ts +++ b/server/src/core/server/graph/context.ts @@ -24,6 +24,7 @@ import { WordListService } from "coral-server/services/comments/pipeline/phases/ import { ErrorReporter } from "coral-server/services/errors"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; @@ -103,6 +104,8 @@ export default class GraphContext { public readonly wordList: WordListService; + public readonly notifications: InternalNotificationContext; + constructor(options: GraphContextOptions) { this.id = options.id || uuid(); this.now = options.now || new Date(); @@ -151,5 +154,10 @@ export default class GraphContext { this.disableCaching, this.config.get("redis_cache_expiry") / 1000 ); + + this.notifications = new InternalNotificationContext( + this.mongo, + this.logger + ); } } diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts index e28a29c58d..f5aa88b5d4 100644 --- a/server/src/core/server/models/notifications/notification.ts +++ b/server/src/core/server/models/notifications/notification.ts @@ -38,3 +38,12 @@ export const retrieveNotificationsConnection = async ( return resolveConnection(query, input, (n) => n.createdAt); }; + +export const createNotification = async ( + mongo: MongoContext, + notification: Notification +) => { + const op = await mongo.notifications().insertOne(notification); + + return op.result.ok ? notification : null; +}; diff --git a/server/src/core/server/queue/tasks/notifier/index.ts b/server/src/core/server/queue/tasks/notifier/index.ts index f9cdff2eb5..ba2d15dee0 100644 --- a/server/src/core/server/queue/tasks/notifier/index.ts +++ b/server/src/core/server/queue/tasks/notifier/index.ts @@ -9,7 +9,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt"; import { categories, NotificationCategory, -} from "coral-server/services/notifications/categories"; +} from "coral-server/services/notifications/email/categories"; import { TenantCache } from "coral-server/services/tenant/cache"; import { createJobProcessor, JOB_NAME, NotifierData } from "./processor"; diff --git a/server/src/core/server/queue/tasks/notifier/messages.ts b/server/src/core/server/queue/tasks/notifier/messages.ts index 9ab4de8151..76c1cdcfe1 100644 --- a/server/src/core/server/queue/tasks/notifier/messages.ts +++ b/server/src/core/server/queue/tasks/notifier/messages.ts @@ -1,8 +1,8 @@ import { CoralEventPayload } from "coral-server/events/event"; import logger from "coral-server/logger"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/queue/tasks/notifier/processor.ts b/server/src/core/server/queue/tasks/notifier/processor.ts index 07df2d1824..57b1afd647 100644 --- a/server/src/core/server/queue/tasks/notifier/processor.ts +++ b/server/src/core/server/queue/tasks/notifier/processor.ts @@ -6,9 +6,9 @@ import logger from "coral-server/logger"; import { JobProcessor } from "coral-server/queue/Task"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { TenantCache } from "coral-server/services/tenant/cache"; import { diff --git a/server/src/core/server/services/notifications/categories/categories.ts b/server/src/core/server/services/notifications/email/categories/categories.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/categories.ts rename to server/src/core/server/services/notifications/email/categories/categories.ts diff --git a/server/src/core/server/services/notifications/categories/category.ts b/server/src/core/server/services/notifications/email/categories/category.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/category.ts rename to server/src/core/server/services/notifications/email/categories/category.ts diff --git a/server/src/core/server/services/notifications/categories/featured.ts b/server/src/core/server/services/notifications/email/categories/featured.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/featured.ts rename to server/src/core/server/services/notifications/email/categories/featured.ts diff --git a/server/src/core/server/services/notifications/categories/index.ts b/server/src/core/server/services/notifications/email/categories/index.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/index.ts rename to server/src/core/server/services/notifications/email/categories/index.ts diff --git a/server/src/core/server/services/notifications/categories/moderation.ts b/server/src/core/server/services/notifications/email/categories/moderation.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/moderation.ts rename to server/src/core/server/services/notifications/email/categories/moderation.ts diff --git a/server/src/core/server/services/notifications/categories/reply.ts b/server/src/core/server/services/notifications/email/categories/reply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/reply.ts rename to server/src/core/server/services/notifications/email/categories/reply.ts diff --git a/server/src/core/server/services/notifications/categories/staffReply.ts b/server/src/core/server/services/notifications/email/categories/staffReply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/staffReply.ts rename to server/src/core/server/services/notifications/email/categories/staffReply.ts diff --git a/server/src/core/server/services/notifications/categories/unsubscribe.ts b/server/src/core/server/services/notifications/email/categories/unsubscribe.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/unsubscribe.ts rename to server/src/core/server/services/notifications/email/categories/unsubscribe.ts diff --git a/server/src/core/server/services/notifications/context.ts b/server/src/core/server/services/notifications/email/context.ts similarity index 100% rename from server/src/core/server/services/notifications/context.ts rename to server/src/core/server/services/notifications/email/context.ts diff --git a/server/src/core/server/services/notifications/notification.ts b/server/src/core/server/services/notifications/email/notification.ts similarity index 100% rename from server/src/core/server/services/notifications/notification.ts rename to server/src/core/server/services/notifications/email/notification.ts diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts new file mode 100644 index 0000000000..301ae87ff4 --- /dev/null +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -0,0 +1,99 @@ +import { v4 as uuid } from "uuid"; + +import { MongoContext } from "coral-server/data/context"; +import { Logger } from "coral-server/logger"; +import { Comment } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport/report"; +import { + createNotification, + Notification, +} from "coral-server/models/notifications/notification"; + +export enum NotificationType { + COMMENT_FEATURED = "COMMENT_FEATURED", + COMMENT_APPROVED = "COMMENT_APPROVED", + COMMENT_REJECTED = "COMMENT_REJECTED", +} + +export interface CreateNotificationInput { + targetUserID: string; + type: NotificationType; + + comment?: Readonly; + report?: Readonly; +} + +interface CreationResult { + notification: Notification | null; + attempted: boolean; +} + +export class InternalNotificationContext { + private mongo: MongoContext; + private log: Logger; + + constructor(mongo: MongoContext, log: Logger) { + this.mongo = mongo; + this.log = log; + } + + public async create(tenantID: string, input: CreateNotificationInput) { + const { type, targetUserID, comment } = input; + + const now = new Date(); + + const result: CreationResult = { + notification: null, + attempted: false, + }; + + if (type === NotificationType.COMMENT_FEATURED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + body: `comment ${comment.id} was featured.`, + }); + result.attempted = true; + } else if (type === NotificationType.COMMENT_APPROVED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + body: `comment ${comment.id} was approved.`, + }); + result.attempted = true; + } else if (type === NotificationType.COMMENT_REJECTED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + body: `comment ${comment.id} was rejected.`, + }); + result.attempted = true; + } + + if (!result.notification && result.attempted) { + this.logNotificationError(tenantID, input); + } + } + + private logNotificationError( + tenantID: string, + input: CreateNotificationInput + ) { + this.log.error( + { + tenantID, + userID: input.targetUserID, + commentID: input.comment ? input.comment.id : null, + reportID: input.report ? input.report.id : null, + type: input.type, + }, + "failed to create internal notification" + ); + } +} From 0e0f441f026869fc67c806e5c12c6a4db211dd2f Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 15:14:54 -0600 Subject: [PATCH 04/35] rename logging function --- .../core/server/services/notifications/internal/context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts index 301ae87ff4..55849a15ee 100644 --- a/server/src/core/server/services/notifications/internal/context.ts +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -77,11 +77,11 @@ export class InternalNotificationContext { } if (!result.notification && result.attempted) { - this.logNotificationError(tenantID, input); + this.logCreateNotificationError(tenantID, input); } } - private logNotificationError( + private logCreateNotificationError( tenantID: string, input: CreateNotificationInput ) { From 7236f0792cfd80c23570fdf5cdd83c6322b8e424 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 15:32:28 -0600 Subject: [PATCH 05/35] post notifications for approve, reject, and feature --- server/src/core/server/graph/mutators/Actions.ts | 2 ++ server/src/core/server/graph/mutators/Comments.ts | 9 +++++++++ server/src/core/server/index.ts | 2 ++ server/src/core/server/queue/index.ts | 2 ++ server/src/core/server/queue/tasks/rejector.ts | 6 ++++++ server/src/core/server/stacks/approveComment.ts | 11 +++++++++++ server/src/core/server/stacks/createComment.ts | 5 +++++ server/src/core/server/stacks/rejectComment.ts | 11 +++++++++++ 8 files changed, 48 insertions(+) diff --git a/server/src/core/server/graph/mutators/Actions.ts b/server/src/core/server/graph/mutators/Actions.ts index 5a8c6757ba..4687c2bbd4 100644 --- a/server/src/core/server/graph/mutators/Actions.ts +++ b/server/src/core/server/graph/mutators/Actions.ts @@ -21,6 +21,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, @@ -39,6 +40,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, diff --git a/server/src/core/server/graph/mutators/Comments.ts b/server/src/core/server/graph/mutators/Comments.ts index 0b7e4a9fbd..082a9f14c8 100644 --- a/server/src/core/server/graph/mutators/Comments.ts +++ b/server/src/core/server/graph/mutators/Comments.ts @@ -13,6 +13,7 @@ import { } from "coral-server/services/comments/actions"; import { CreateCommentMediaInput } from "coral-server/services/comments/media"; import { publishCommentFeatured } from "coral-server/services/events"; +import { NotificationType } from "coral-server/services/notifications/internal/context"; import { markSeen } from "coral-server/services/seenComments"; import { approveComment, @@ -55,6 +56,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, ctx.user!, { @@ -230,6 +232,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, commentID, commentRevisionID, @@ -254,6 +257,12 @@ export const Comments = (ctx: GraphContext) => ({ // Publish that the comment was featured. await publishCommentFeatured(ctx.broker, comment); + await ctx.notifications.create(ctx.tenant.id, { + targetUserID: comment.authorID!, + comment, + type: NotificationType.COMMENT_FEATURED, + }); + // Return it to the next step. return comment; }, diff --git a/server/src/core/server/index.ts b/server/src/core/server/index.ts index 74567c21d7..8e1e2ac0d9 100644 --- a/server/src/core/server/index.ts +++ b/server/src/core/server/index.ts @@ -49,6 +49,7 @@ import { retrieveAllTenants, retrieveTenant, Tenant } from "./models/tenant"; import { WordListCategory } from "./services/comments/pipeline/phases/wordList/message"; import { WordListService } from "./services/comments/pipeline/phases/wordList/service"; import { ErrorReporter, SentryErrorReporter } from "./services/errors"; +import { InternalNotificationContext } from "./services/notifications/internal/context"; import { isInstalled } from "./services/tenant"; export interface ServerOptions { @@ -263,6 +264,7 @@ class Server { tenantCache: this.tenantCache, i18n: this.i18n, signingConfig: this.signingConfig, + notifications: new InternalNotificationContext(this.mongo, logger), }); // Create the pubsub client. diff --git a/server/src/core/server/queue/index.ts b/server/src/core/server/queue/index.ts index 00b27bb94f..0c6364a550 100644 --- a/server/src/core/server/queue/index.ts +++ b/server/src/core/server/queue/index.ts @@ -8,6 +8,7 @@ import { } from "coral-server/queue/tasks/loadCache"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis, createRedisClient, @@ -58,6 +59,7 @@ export interface QueueOptions { i18n: I18n; signingConfig: JWTSigningConfig; redis: AugmentedRedis; + notifications: InternalNotificationContext; } export interface TaskQueue { diff --git a/server/src/core/server/queue/tasks/rejector.ts b/server/src/core/server/queue/tasks/rejector.ts index fc1840bb03..7f54d73ea1 100644 --- a/server/src/core/server/queue/tasks/rejector.ts +++ b/server/src/core/server/queue/tasks/rejector.ts @@ -14,6 +14,7 @@ import { retrieveAllCommentsUserConnection, retrieveCommentsBySitesUserConnection, } from "coral-server/services/comments"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { rejectComment } from "coral-server/stacks"; @@ -30,6 +31,7 @@ export interface RejectorProcessorOptions { redis: AugmentedRedis; tenantCache: TenantCache; config: Config; + notifications: InternalNotificationContext; } export interface RejectorData { @@ -149,6 +151,7 @@ const rejectLiveComments = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + notifications: InternalNotificationContext, tenant: Readonly, authorID: string, moderatorID: string, @@ -169,6 +172,7 @@ const rejectLiveComments = async ( cache, config, null, + notifications, tenant, comment.id, revision.id, @@ -197,6 +201,7 @@ const createJobProcessor = redis, tenantCache, config, + notifications, }: RejectorProcessorOptions): JobProcessor => async (job) => { // Pull out the job data. @@ -238,6 +243,7 @@ const createJobProcessor = redis, cache, config, + notifications, tenant, authorID, moderatorID, diff --git a/server/src/core/server/stacks/approveComment.ts b/server/src/core/server/stacks/approveComment.ts index 58a50394f6..69eac7fd6c 100644 --- a/server/src/core/server/stacks/approveComment.ts +++ b/server/src/core/server/stacks/approveComment.ts @@ -5,6 +5,10 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { getLatestRevision } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { moderate } from "coral-server/services/comments/moderation"; +import { + InternalNotificationContext, + NotificationType, +} from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsNotSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; @@ -19,6 +23,7 @@ const approveComment = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -78,6 +83,12 @@ const approveComment = async ( } } + await notifications.create(tenant.id, { + targetUserID: result.after.authorID!, + comment: result.after, + type: NotificationType.COMMENT_REJECTED, + }); + // Return the resulting comment. return result.after; }; diff --git a/server/src/core/server/stacks/createComment.ts b/server/src/core/server/stacks/createComment.ts index 7fc240dd27..feb176ae88 100644 --- a/server/src/core/server/stacks/createComment.ts +++ b/server/src/core/server/stacks/createComment.ts @@ -57,6 +57,7 @@ import { processForModeration, } from "coral-server/services/comments/pipeline"; import { WordListService } from "coral-server/services/comments/pipeline/phases/wordList/service"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { updateUserLastCommentID } from "coral-server/services/users"; import { Request } from "coral-server/types/express"; @@ -96,6 +97,7 @@ const markCommentAsAnswered = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, comment: Readonly, story: Story, @@ -145,6 +147,7 @@ const markCommentAsAnswered = async ( cache, config, broker, + notifications, tenant, comment.parentID, comment.parentRevisionID, @@ -202,6 +205,7 @@ export default async function create( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, author: User, input: CreateComment, @@ -387,6 +391,7 @@ export default async function create( cache, config, broker, + notifications, tenant, comment, story, diff --git a/server/src/core/server/stacks/rejectComment.ts b/server/src/core/server/stacks/rejectComment.ts index 3b98ba01e8..b7d40bb885 100644 --- a/server/src/core/server/stacks/rejectComment.ts +++ b/server/src/core/server/stacks/rejectComment.ts @@ -11,6 +11,10 @@ import { import { Tenant } from "coral-server/models/tenant"; import { removeTag } from "coral-server/services/comments"; import { moderate } from "coral-server/services/comments/moderation"; +import { + InternalNotificationContext, + NotificationType, +} from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; @@ -69,6 +73,7 @@ const rejectComment = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker | null, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -149,6 +154,12 @@ const rejectComment = async ( }); } + await notifications.create(tenant.id, { + targetUserID: result.after.authorID!, + comment: result.after, + type: NotificationType.COMMENT_REJECTED, + }); + // Return the resulting comment. return rollingResult; }; From b4cafca98737f47dc4463624742285158b86fe17 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 15:52:05 -0600 Subject: [PATCH 06/35] create notification resolver allows us to resolve linked comments or dsa reports that might be connected to a notification through the graphql API. --- .../Notifications/NotificationContainer.tsx | 10 +++++++- .../server/graph/resolvers/Notification.ts | 25 +++++++++++++++++++ .../src/core/server/graph/resolvers/index.ts | 2 ++ .../core/server/graph/schema/schema.graphql | 5 ++++ .../models/notifications/notification.ts | 1 + .../notifications/internal/context.ts | 3 +++ 6 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 server/src/core/server/graph/resolvers/Notification.ts diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index 11d5da5a91..fb08447d52 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -10,7 +10,12 @@ interface Props { } const NotificationContainer: FunctionComponent = ({ notification }) => { - return
{notification.body}
; + return ( +
+ {notification.body} -{" "} + {notification.comment ? notification.comment.body : ""} +
+ ); }; const enhanced = withFragmentContainer({ @@ -18,6 +23,9 @@ const enhanced = withFragmentContainer({ fragment NotificationContainer_notification on Notification { id body + comment { + body + } } `, })(NotificationContainer); diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts new file mode 100644 index 0000000000..fb9b2f3081 --- /dev/null +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -0,0 +1,25 @@ +import { CommentNotFoundError } from "coral-server/errors"; +import { Notification } from "coral-server/models/notifications/notification"; + +import { GQLNotificationTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const NotificationResolver: Required< + GQLNotificationTypeResolver +> = { + id: ({ id }) => id, + ownerID: ({ ownerID }) => ownerID, + createdAt: ({ createdAt }) => createdAt, + body: ({ body }) => body, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + const comment = await ctx.loaders.Comments.comment.load(commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + return comment; + }, +}; diff --git a/server/src/core/server/graph/resolvers/index.ts b/server/src/core/server/graph/resolvers/index.ts index 3d25555cce..fc90bced55 100644 --- a/server/src/core/server/graph/resolvers/index.ts +++ b/server/src/core/server/graph/resolvers/index.ts @@ -45,6 +45,7 @@ import { ModMessageStatus } from "./ModMessageStatus"; import { ModMessageStatusHistory } from "./ModMessageStatusHistory"; import { Mutation } from "./Mutation"; import { NewCommentersConfiguration } from "./NewCommentersConfiguration"; +import { NotificationResolver as Notification } from "./Notification"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; import { PremodStatus } from "./PremodStatus"; import { PremodStatusHistory } from "./PremodStatusHistory"; @@ -165,6 +166,7 @@ const Resolvers: GQLResolver = { YouTubeMediaConfiguration, LocalAuthIntegration, AuthenticationTargetFilter, + Notification, }; export default Resolvers; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 12149c3b03..46ca38516d 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -4483,6 +4483,11 @@ type Notification { body is the content of this notification. """ body: String! + + """ + comment is the optional comment that is linked to this notification. + """ + comment: Comment } type NotificationEdge { diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts index f5aa88b5d4..77c7be08d1 100644 --- a/server/src/core/server/models/notifications/notification.ts +++ b/server/src/core/server/models/notifications/notification.ts @@ -13,6 +13,7 @@ export interface Notification extends TenantResource { ownerID: string; reportID?: string; + commentID?: string; body?: string; } diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts index 55849a15ee..9e442be298 100644 --- a/server/src/core/server/services/notifications/internal/context.ts +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -54,6 +54,7 @@ export class InternalNotificationContext { createdAt: now, ownerID: targetUserID, body: `comment ${comment.id} was featured.`, + commentID: comment.id, }); result.attempted = true; } else if (type === NotificationType.COMMENT_APPROVED && comment) { @@ -63,6 +64,7 @@ export class InternalNotificationContext { createdAt: now, ownerID: targetUserID, body: `comment ${comment.id} was approved.`, + commentID: comment.id, }); result.attempted = true; } else if (type === NotificationType.COMMENT_REJECTED && comment) { @@ -72,6 +74,7 @@ export class InternalNotificationContext { createdAt: now, ownerID: targetUserID, body: `comment ${comment.id} was rejected.`, + commentID: comment.id, }); result.attempted = true; } From c212ae450be4ef18968bf1b8be81bb3f13c1e1ea Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 15:59:19 -0600 Subject: [PATCH 07/35] define indices for notifications in INDEXES.md --- INDEXES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/INDEXES.md b/INDEXES.md index 564e05c52b..77f282603a 100644 --- a/INDEXES.md +++ b/INDEXES.md @@ -6,6 +6,20 @@ The goal of this document is to date-mark the indexes you add to support the cha If you are releasing, you can use this readme to check all the indexes prior to the release you are deploying and have a good idea of what indexes you might need to deploy to Mongo along with your release of a new Coral Docker image to kubernetes. +## 2023-10-18 + +``` +db.notifications.createIndex({ tenantID: 1, id: 1 }, { unique: true }); +``` + +- This index creates the uniqueness constraint for the `tenantID` and `id` fields on the notifications collection + +``` +db.notifications.createIndex({ tenantID: 1, ownerID: 1, createdAt: 1 }); +``` + +- This index speeds up the retrieval of notifications by `tenantID`, `ownerID`, and `createdAt` which is the most common way of retrieving notifications for pagination in the notifications tab on the stream. + ## 2023-03-28 ``` From f4bbcfa591800a237959fd527eaa5e39e5303091 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 18 Oct 2023 16:47:10 -0600 Subject: [PATCH 08/35] bring translations into notification context allows us to use translation keys when creating the text for notifications. --- server/src/core/server/graph/context.ts | 1 + .../core/server/graph/mutators/Comments.ts | 2 +- server/src/core/server/index.ts | 6 +++- .../src/core/server/locales/en-US/common.ftl | 4 +++ .../notifications/internal/context.ts | 35 ++++++++++++++++--- .../src/core/server/stacks/approveComment.ts | 2 +- .../src/core/server/stacks/rejectComment.ts | 2 +- 7 files changed, 43 insertions(+), 9 deletions(-) diff --git a/server/src/core/server/graph/context.ts b/server/src/core/server/graph/context.ts index 585db3ec2b..4a27d6e557 100644 --- a/server/src/core/server/graph/context.ts +++ b/server/src/core/server/graph/context.ts @@ -157,6 +157,7 @@ export default class GraphContext { this.notifications = new InternalNotificationContext( this.mongo, + this.i18n, this.logger ); } diff --git a/server/src/core/server/graph/mutators/Comments.ts b/server/src/core/server/graph/mutators/Comments.ts index 082a9f14c8..a02e175f1f 100644 --- a/server/src/core/server/graph/mutators/Comments.ts +++ b/server/src/core/server/graph/mutators/Comments.ts @@ -257,7 +257,7 @@ export const Comments = (ctx: GraphContext) => ({ // Publish that the comment was featured. await publishCommentFeatured(ctx.broker, comment); - await ctx.notifications.create(ctx.tenant.id, { + await ctx.notifications.create(ctx.tenant.id, ctx.tenant.locale, { targetUserID: comment.authorID!, comment, type: NotificationType.COMMENT_FEATURED, diff --git a/server/src/core/server/index.ts b/server/src/core/server/index.ts index 8e1e2ac0d9..777dbaf4af 100644 --- a/server/src/core/server/index.ts +++ b/server/src/core/server/index.ts @@ -264,7 +264,11 @@ class Server { tenantCache: this.tenantCache, i18n: this.i18n, signingConfig: this.signingConfig, - notifications: new InternalNotificationContext(this.mongo, logger), + notifications: new InternalNotificationContext( + this.mongo, + this.i18n, + logger + ), }); // Create the pubsub client. diff --git a/server/src/core/server/locales/en-US/common.ftl b/server/src/core/server/locales/en-US/common.ftl index 1d5891bd36..c3be755131 100644 --- a/server/src/core/server/locales/en-US/common.ftl +++ b/server/src/core/server/locales/en-US/common.ftl @@ -18,3 +18,7 @@ comment-counts-ratings-and-reviews = } staff-label = Staff + +notifications-commentWasFeatured = Comment was featured. +notifications-commentWasApproved = Comment was approved. +notifications-commentWasRejected = Comment was rejected. diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts index 9e442be298..e3f0e73709 100644 --- a/server/src/core/server/services/notifications/internal/context.ts +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -1,5 +1,6 @@ import { v4 as uuid } from "uuid"; +import { LanguageCode } from "coral-common/common/lib/helpers"; import { MongoContext } from "coral-server/data/context"; import { Logger } from "coral-server/logger"; import { Comment } from "coral-server/models/comment"; @@ -8,6 +9,7 @@ import { createNotification, Notification, } from "coral-server/models/notifications/notification"; +import { I18n, translate } from "coral-server/services/i18n"; export enum NotificationType { COMMENT_FEATURED = "COMMENT_FEATURED", @@ -31,13 +33,19 @@ interface CreationResult { export class InternalNotificationContext { private mongo: MongoContext; private log: Logger; + private i18n: I18n; - constructor(mongo: MongoContext, log: Logger) { + constructor(mongo: MongoContext, i18n: I18n, log: Logger) { this.mongo = mongo; + this.i18n = i18n; this.log = log; } - public async create(tenantID: string, input: CreateNotificationInput) { + public async create( + tenantID: string, + lang: LanguageCode, + input: CreateNotificationInput + ) { const { type, targetUserID, comment } = input; const now = new Date(); @@ -53,7 +61,11 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: `comment ${comment.id} was featured.`, + body: this.translatePhrase( + lang, + "notifications-commentWasFeatured", + "Comment was featured" + ), commentID: comment.id, }); result.attempted = true; @@ -63,7 +75,11 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: `comment ${comment.id} was approved.`, + body: this.translatePhrase( + lang, + "notifications-commentWasApproved", + "Comment was approved" + ), commentID: comment.id, }); result.attempted = true; @@ -73,7 +89,11 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: `comment ${comment.id} was rejected.`, + body: this.translatePhrase( + lang, + "notifications-commentWasRejected", + "Comment was rejected" + ), commentID: comment.id, }); result.attempted = true; @@ -84,6 +104,11 @@ export class InternalNotificationContext { } } + private translatePhrase(lang: LanguageCode, key: string, text: string) { + const bundle = this.i18n.getBundle(lang); + return translate(bundle, text, key); + } + private logCreateNotificationError( tenantID: string, input: CreateNotificationInput diff --git a/server/src/core/server/stacks/approveComment.ts b/server/src/core/server/stacks/approveComment.ts index 69eac7fd6c..804c172882 100644 --- a/server/src/core/server/stacks/approveComment.ts +++ b/server/src/core/server/stacks/approveComment.ts @@ -83,7 +83,7 @@ const approveComment = async ( } } - await notifications.create(tenant.id, { + await notifications.create(tenant.id, tenant.locale, { targetUserID: result.after.authorID!, comment: result.after, type: NotificationType.COMMENT_REJECTED, diff --git a/server/src/core/server/stacks/rejectComment.ts b/server/src/core/server/stacks/rejectComment.ts index b7d40bb885..e7db4a378d 100644 --- a/server/src/core/server/stacks/rejectComment.ts +++ b/server/src/core/server/stacks/rejectComment.ts @@ -154,7 +154,7 @@ const rejectComment = async ( }); } - await notifications.create(tenant.id, { + await notifications.create(tenant.id, tenant.locale, { targetUserID: result.after.authorID!, comment: result.after, type: NotificationType.COMMENT_REJECTED, From 2148e7ee09056f5f818430b964288453af919bf5 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 14:23:12 -0600 Subject: [PATCH 09/35] add optional title to notification models --- .../server/graph/resolvers/Notification.ts | 1 + .../core/server/graph/schema/schema.graphql | 9 +++- .../src/core/server/locales/en-US/common.ftl | 9 ++-- .../models/notifications/notification.ts | 1 + .../notifications/internal/context.ts | 51 ++++++++++++++++--- 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts index fb9b2f3081..9742b23d4b 100644 --- a/server/src/core/server/graph/resolvers/Notification.ts +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -9,6 +9,7 @@ export const NotificationResolver: Required< id: ({ id }) => id, ownerID: ({ ownerID }) => ownerID, createdAt: ({ createdAt }) => createdAt, + title: ({ title }) => title, body: ({ body }) => body, comment: async ({ commentID }, input, ctx) => { if (!commentID) { diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 46ca38516d..342db30059 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -4480,9 +4480,14 @@ type Notification { createdAt: Time! """ - body is the content of this notification. + title is the title text of this notification. """ - body: String! + title: String + + """ + body is the text content of this notification. + """ + body: String """ comment is the optional comment that is linked to this notification. diff --git a/server/src/core/server/locales/en-US/common.ftl b/server/src/core/server/locales/en-US/common.ftl index c3be755131..ed7394cb85 100644 --- a/server/src/core/server/locales/en-US/common.ftl +++ b/server/src/core/server/locales/en-US/common.ftl @@ -19,6 +19,9 @@ comment-counts-ratings-and-reviews = staff-label = Staff -notifications-commentWasFeatured = Comment was featured. -notifications-commentWasApproved = Comment was approved. -notifications-commentWasRejected = Comment was rejected. +notifications-commentWasFeatured-title = Comment was featured +notifications-commentWasFeatured-body = The comment { $commentID } was featured. +notifications-commentWasApproved-title = Comment was approved +notifications-commentWasApproved-body = The comment { $commentID } was approved. +notifications-commentWasRejected-title = Comment was rejected +notifications-commentWasRejected-body = The comment { $commentID } was rejected. diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts index 77c7be08d1..aca5d82a28 100644 --- a/server/src/core/server/models/notifications/notification.ts +++ b/server/src/core/server/models/notifications/notification.ts @@ -15,6 +15,7 @@ export interface Notification extends TenantResource { reportID?: string; commentID?: string; + title?: string; body?: string; } diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts index e3f0e73709..0ca4ee0e71 100644 --- a/server/src/core/server/services/notifications/internal/context.ts +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -61,11 +61,19 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: this.translatePhrase( + title: this.translatePhrase( lang, - "notifications-commentWasFeatured", + "notifications-commentWasFeatured-title", "Comment was featured" ), + body: this.translatePhrase( + lang, + "notifications-commentWasFeatured-body", + `The comment ${comment.id} was featured.`, + { + commentID: comment.id, + } + ), commentID: comment.id, }); result.attempted = true; @@ -75,11 +83,19 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: this.translatePhrase( + title: this.translatePhrase( lang, - "notifications-commentWasApproved", + "notifications-commentWasApproved-title", "Comment was approved" ), + body: this.translatePhrase( + lang, + "notifications-commentWasApproved-body", + `The comment ${comment.id} was approved.`, + { + commentID: comment.id, + } + ), commentID: comment.id, }); result.attempted = true; @@ -89,11 +105,19 @@ export class InternalNotificationContext { tenantID, createdAt: now, ownerID: targetUserID, - body: this.translatePhrase( + title: this.translatePhrase( lang, - "notifications-commentWasRejected", + "notifications-commentWasRejected-title", "Comment was rejected" ), + body: this.translatePhrase( + lang, + "notifications-commentWasRejected-body", + `The comment ${comment.id} was rejected.`, + { + commentID: comment.id, + } + ), commentID: comment.id, }); result.attempted = true; @@ -104,9 +128,20 @@ export class InternalNotificationContext { } } - private translatePhrase(lang: LanguageCode, key: string, text: string) { + private translatePhrase( + lang: LanguageCode, + key: string, + text: string, + args?: object | undefined + ) { const bundle = this.i18n.getBundle(lang); - return translate(bundle, text, key); + + const result = translate(bundle, text, key, args); + + // eslint-disable-next-line no-console + console.log(result, args); + + return result; } private logCreateNotificationError( From 6c018c7f04897801e94a5ba32eb8b55aad31072b Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 14:42:55 -0600 Subject: [PATCH 10/35] preliminarily style notifications --- .../Notifications/NotificationContainer.css | 32 +++++++++++++++++++ .../Notifications/NotificationContainer.tsx | 15 ++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationContainer.css diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css new file mode 100644 index 0000000000..67c3eee4b6 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -0,0 +1,32 @@ +.root { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); + + padding: var(--spacing-4); + box-sizing: border-box; + + border-color: var(--palette-grey-300); + border-width: 1px; + border-style: solid; + + border-left-width: 4px; + + word-break: break-word; +} + +.title { + width: 100%; + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); +} + +.body { + width: 100%; + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index fb08447d52..ef89b5bb56 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -1,3 +1,4 @@ +import cn from "classnames"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; @@ -5,15 +6,19 @@ import { withFragmentContainer } from "coral-framework/lib/relay"; import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; +import styles from "./NotificationContainer.css"; + interface Props { notification: NotificationContainer_notification; } -const NotificationContainer: FunctionComponent = ({ notification }) => { +const NotificationContainer: FunctionComponent = ({ + notification: { title, body }, +}) => { return ( -
- {notification.body} -{" "} - {notification.comment ? notification.comment.body : ""} +
+ {title &&
{title}
} + {body &&
{body}
}
); }; @@ -22,8 +27,10 @@ const enhanced = withFragmentContainer({ notification: graphql` fragment NotificationContainer_notification on Notification { id + title body comment { + id body } } From b542c8b6dc3be5775760387658a749a68108d4c9 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 14:49:04 -0600 Subject: [PATCH 11/35] show comment url in notifications with linked comment data shows as an example how notifications can have linked content --- .../tabs/Notifications/NotificationContainer.css | 8 ++++++++ .../tabs/Notifications/NotificationContainer.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css index 67c3eee4b6..c34991db65 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -30,3 +30,11 @@ font-weight: var(--font-weight-primary-regular); font-size: var(--font-size-2); } + +.commentData { + padding: var(--spacing-4); + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index ef89b5bb56..67126076d9 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -2,6 +2,7 @@ import cn from "classnames"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; +import { getURLWithCommentID } from "coral-framework/helpers"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; @@ -13,12 +14,17 @@ interface Props { } const NotificationContainer: FunctionComponent = ({ - notification: { title, body }, + notification: { title, body, comment }, }) => { + const commentURL = comment + ? getURLWithCommentID(comment.story.url, comment.id) + : ""; + return (
{title &&
{title}
} {body &&
{body}
} + {comment && {commentURL}}
); }; @@ -31,7 +37,9 @@ const enhanced = withFragmentContainer({ body comment { id - body + story { + url + } } } `, From b19296b206243222617a60597c46cf2cdfea9769 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 14:57:27 -0600 Subject: [PATCH 12/35] show the user box under notifications --- .../Notifications/NotificationsContainer.css | 3 +++ .../Notifications/NotificationsContainer.tsx | 25 +++++++++++++++++-- .../tabs/Notifications/NotificationsQuery.tsx | 5 +++- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css new file mode 100644 index 0000000000..ae8d775db9 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css @@ -0,0 +1,3 @@ +.userBox { + padding-bottom: var(--spacing-2); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx index 796c74372e..3773698b8c 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx @@ -2,23 +2,44 @@ import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import { withFragmentContainer } from "coral-framework/lib/relay"; +import { UserBoxContainer } from "coral-stream/common/UserBox"; +import { NotificationsContainer_settings } from "coral-stream/__generated__/NotificationsContainer_settings.graphql"; import { NotificationsContainer_viewer } from "coral-stream/__generated__/NotificationsContainer_viewer.graphql"; import NotificationsListQuery from "./NotificationsListQuery"; +import styles from "./NotificationsContainer.css"; + interface Props { viewer: NotificationsContainer_viewer; + settings: NotificationsContainer_settings; } -const NotificationsContainer: FunctionComponent = ({ viewer }) => { - return ; +const NotificationsContainer: FunctionComponent = ({ + viewer, + settings, +}) => { + return ( + <> +
+ +
+ + + ); }; const enhanced = withFragmentContainer({ viewer: graphql` fragment NotificationsContainer_viewer on User { id + ...UserBoxContainer_viewer + } + `, + settings: graphql` + fragment NotificationsContainer_settings on Settings { + ...UserBoxContainer_settings } `, })(NotificationsContainer); diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx index 0213ef8802..20f1adbb65 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx @@ -46,7 +46,7 @@ export const render = ({ error, props }: QueryRenderData) => { return ( }> - + ); }; @@ -59,6 +59,9 @@ const NotificationsQuery: FunctionComponent = () => { viewer { ...NotificationsContainer_viewer } + settings { + ...NotificationsContainer_settings + } } `} variables={{}} From b16891998b89e5be37df57302b6ac1a64d777e85 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 15:10:26 -0600 Subject: [PATCH 13/35] only show notifications tab if dsa features are enabled --- client/src/core/client/stream/App/App.tsx | 18 +++++++++++------- .../core/client/stream/App/AppContainer.tsx | 14 ++++++++------ .../core/client/stream/App/TabBarContainer.tsx | 14 ++++++++------ 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/client/src/core/client/stream/App/App.tsx b/client/src/core/client/stream/App/App.tsx index d99b760fef..eaace3541a 100644 --- a/client/src/core/client/stream/App/App.tsx +++ b/client/src/core/client/stream/App/App.tsx @@ -19,10 +19,12 @@ type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; export interface AppProps { activeTab: TabValue; + dsaFeaturesEnabled: boolean; } const App: FunctionComponent = (props) => { const { browserInfo } = useCoralContext(); + return ( = (props) => { > - - - + {props.dsaFeaturesEnabled && ( + + + + )}
diff --git a/client/src/core/client/stream/App/AppContainer.tsx b/client/src/core/client/stream/App/AppContainer.tsx index 4852e1b921..1d8ae524ae 100644 --- a/client/src/core/client/stream/App/AppContainer.tsx +++ b/client/src/core/client/stream/App/AppContainer.tsx @@ -30,16 +30,18 @@ interface Props { } const AppContainer: FunctionComponent = ({ disableListeners }) => { - const [{ activeTab }] = useLocal(graphql` - fragment AppContainerLocal on Local { - activeTab - } - `); + const [{ activeTab, dsaFeaturesEnabled }] = + useLocal(graphql` + fragment AppContainerLocal on Local { + activeTab + dsaFeaturesEnabled + } + `); return ( <> {disableListeners ? null : listeners} - + ); }; diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 8652a5f8dd..1fe8f2987c 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -30,11 +30,13 @@ export const TabBarContainer: FunctionComponent = ({ settings, setActiveTab, }) => { - const [{ activeTab }] = useLocal(graphql` - fragment TabBarContainerLocal on Local { - activeTab - } - `); + const [{ activeTab, dsaFeaturesEnabled }] = + useLocal(graphql` + fragment TabBarContainerLocal on Local { + activeTab + dsaFeaturesEnabled + } + `); const handleSetActiveTab = useCallback( (tab: SetActiveTabInput["tab"]) => { void setActiveTab({ tab }); @@ -66,7 +68,7 @@ export const TabBarContainer: FunctionComponent = ({ showProfileTab={!!viewer} showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} - showNotificationsTab={true} + showNotificationsTab={!!dsaFeaturesEnabled} onTabClick={handleSetActiveTab} /> ); From 9441bf2a7bba7808202a42b300898f648cb4268e Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 15:13:59 -0600 Subject: [PATCH 14/35] only show notifications tab in-stream when user is signed in --- client/src/core/client/stream/App/TabBarContainer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 1fe8f2987c..33fba230af 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -61,6 +61,11 @@ export const TabBarContainer: FunctionComponent = ({ [viewer, story] ); + const showNotificationsTab = useMemo( + () => !!viewer && !!dsaFeaturesEnabled, + [viewer, dsaFeaturesEnabled] + ); + return ( = ({ showProfileTab={!!viewer} showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} - showNotificationsTab={!!dsaFeaturesEnabled} + showNotificationsTab={showNotificationsTab} onTabClick={handleSetActiveTab} /> ); From 2d5530cb40a04285c15a0d55b7dceeb7fffcb036 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 15:21:11 -0600 Subject: [PATCH 15/35] style load more button for notifications paginations --- client/src/core/client/stream/classes.ts | 4 ++++ .../Notifications/NotificationsPaginator.tsx | 18 +++++++++++++++++- locales/en-US/stream.ftl | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/client/src/core/client/stream/classes.ts b/client/src/core/client/stream/classes.ts index 2db9898908..b4de3f6efc 100644 --- a/client/src/core/client/stream/classes.ts +++ b/client/src/core/client/stream/classes.ts @@ -144,6 +144,10 @@ const CLASSES = { settings: "coral coral-tabBarSecondary-tab coral-tabBarMyProfile-settings", }, + tabBarNotifications: { + loadMore: "coral coral-tabBarNotifications-loadMore", + }, + /** * createComment is the comment creation box where a user can write a comment. */ diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx index 95fe6cb545..af772b21cb 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx @@ -1,8 +1,11 @@ +import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent, useCallback, useState } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; import { useRefetch, withPaginationContainer } from "coral-framework/lib/relay"; +import CLASSES from "coral-stream/classes"; import Spinner from "coral-stream/common/Spinner"; +import { Button } from "coral-ui/components/v3"; import { NotificationsPaginator_query } from "coral-stream/__generated__/NotificationsPaginator_query.graphql"; import { NotificationsPaginatorPaginationQueryVariables } from "coral-stream/__generated__/NotificationsPaginatorPaginationQuery.graphql"; @@ -56,7 +59,20 @@ const NotificationsPaginator: FunctionComponent = (props) => { })} {isRefetching && } {!isRefetching && !disableLoadMore && props.relay.hasMore() && ( - + + + )} ); diff --git a/locales/en-US/stream.ftl b/locales/en-US/stream.ftl index d9df263453..d579675c0a 100644 --- a/locales/en-US/stream.ftl +++ b/locales/en-US/stream.ftl @@ -1003,3 +1003,7 @@ stream-footer-links-discussions = More discussions .title = Go to more discussions stream-footer-navigation = .aria-label = Comments Footer + +## Notifications + +notifications-loadMore = Load More \ No newline at end of file From e1074603c22540056ce8f20a17650447b66349ff Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 19 Oct 2023 15:24:20 -0600 Subject: [PATCH 16/35] fix typo in creating approved comment notifications --- server/src/core/server/stacks/approveComment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/core/server/stacks/approveComment.ts b/server/src/core/server/stacks/approveComment.ts index 804c172882..06762a569c 100644 --- a/server/src/core/server/stacks/approveComment.ts +++ b/server/src/core/server/stacks/approveComment.ts @@ -86,7 +86,7 @@ const approveComment = async ( await notifications.create(tenant.id, tenant.locale, { targetUserID: result.after.authorID!, comment: result.after, - type: NotificationType.COMMENT_REJECTED, + type: NotificationType.COMMENT_APPROVED, }); // Return the resulting comment. From 3106951457c4d293560245ba4c5e19c705594ca2 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Tue, 24 Oct 2023 16:02:02 -0600 Subject: [PATCH 17/35] check if target user exists before creating notifications --- .../server/services/notifications/internal/context.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts index 0ca4ee0e71..7a2dff6bf9 100644 --- a/server/src/core/server/services/notifications/internal/context.ts +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -9,6 +9,7 @@ import { createNotification, Notification, } from "coral-server/models/notifications/notification"; +import { retrieveUser } from "coral-server/models/user"; import { I18n, translate } from "coral-server/services/i18n"; export enum NotificationType { @@ -48,6 +49,15 @@ export class InternalNotificationContext { ) { const { type, targetUserID, comment } = input; + const existingUser = retrieveUser(this.mongo, tenantID, targetUserID); + if (!existingUser) { + this.log.warn( + { userID: targetUserID }, + "attempted to create notification for user that does not exist, ignoring" + ); + return; + } + const now = new Date(); const result: CreationResult = { From 815b451c5909e77c10ff53c32f3d0a5ee3627fda Mon Sep 17 00:00:00 2001 From: nick-funk Date: Tue, 24 Oct 2023 17:42:50 -0600 Subject: [PATCH 18/35] preliminarily hook up notification seen state resolvers --- client/src/core/client/stream/App/TabBar.tsx | 5 +- .../client/stream/App/TabBarContainer.tsx | 2 + .../server/graph/loaders/Notifications.ts | 4 ++ .../src/core/server/graph/resolvers/Query.ts | 23 +++++++-- .../src/core/server/graph/resolvers/User.ts | 10 ++++ .../core/server/graph/schema/schema.graphql | 11 ++++ .../models/notifications/notification.ts | 51 ++++++++++++++++++- server/src/core/server/models/user/user.ts | 6 +++ 8 files changed, 107 insertions(+), 5 deletions(-) diff --git a/client/src/core/client/stream/App/TabBar.tsx b/client/src/core/client/stream/App/TabBar.tsx index a5edbb9e98..f7999c9688 100644 --- a/client/src/core/client/stream/App/TabBar.tsx +++ b/client/src/core/client/stream/App/TabBar.tsx @@ -32,6 +32,7 @@ export interface Props { showDiscussionsTab: boolean; showConfigureTab: boolean; showNotificationsTab: boolean; + hasNewNotifications: boolean; mode: | "COMMENTS" | "QA" @@ -177,7 +178,9 @@ const AppTabBar: FunctionComponent = (props) => { variant="streamPrimary" > {matches ? ( - {notificationsText} + + {notificationsText} {`${props.hasNewNotifications}`} + ) : (
diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 33fba230af..7b1310ca99 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -74,6 +74,7 @@ export const TabBarContainer: FunctionComponent = ({ showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} showNotificationsTab={showNotificationsTab} + hasNewNotifications={!!viewer?.hasNewNotifications} onTabClick={handleSetActiveTab} /> ); @@ -84,6 +85,7 @@ const enhanced = withSetActiveTabMutation( viewer: graphql` fragment TabBarContainer_viewer on User { role + hasNewNotifications } `, story: graphql` diff --git a/server/src/core/server/graph/loaders/Notifications.ts b/server/src/core/server/graph/loaders/Notifications.ts index c9fcd2b8dc..de5bbbfce5 100644 --- a/server/src/core/server/graph/loaders/Notifications.ts +++ b/server/src/core/server/graph/loaders/Notifications.ts @@ -1,5 +1,6 @@ import Context from "coral-server/graph/context"; import { + hasNewNotifications, NotificationsConnectionInput, retrieveNotificationsConnection, } from "coral-server/models/notifications/notification"; @@ -16,4 +17,7 @@ export default (ctx: Context) => ({ after, }); }, + hasNewNotifications: async (ownerID: string, lastSeen: Date) => { + return hasNewNotifications(ctx.tenant.id, ctx.mongo, ownerID, lastSeen); + }, }); diff --git a/server/src/core/server/graph/resolvers/Query.ts b/server/src/core/server/graph/resolvers/Query.ts index 97acb1e9cd..f60792bd57 100644 --- a/server/src/core/server/graph/resolvers/Query.ts +++ b/server/src/core/server/graph/resolvers/Query.ts @@ -1,6 +1,8 @@ import { defaultTo } from "lodash"; +import { UserNotFoundError } from "coral-server/errors"; import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { markLastSeenNotification } from "coral-server/models/notifications/notification"; import { getEmailDomain, getExternalModerationPhase, @@ -71,10 +73,25 @@ export const Query: Required> = { }, }, }), - notifications: (source, { ownerID, first, after }, ctx) => - ctx.loaders.Notifications.connection({ + notifications: async (source, { ownerID, first, after }, ctx) => { + const user = await ctx.loaders.Users.user.load(ownerID); + if (!user) { + throw new UserNotFoundError(ownerID); + } + + const connection = await ctx.loaders.Notifications.connection({ ownerID, first: defaultTo(first, 10), after, - }), + }); + + await markLastSeenNotification( + ctx.tenant.id, + ctx.mongo, + user, + connection.nodes.map((n) => n.createdAt) + ); + + return connection; + }, }; diff --git a/server/src/core/server/graph/resolvers/User.ts b/server/src/core/server/graph/resolvers/User.ts index f1ec3c7d22..0faf2707f9 100644 --- a/server/src/core/server/graph/resolvers/User.ts +++ b/server/src/core/server/graph/resolvers/User.ts @@ -89,4 +89,14 @@ export const User: GQLUserTypeResolver = { return ctx.loaders.Stories.story.loadMany(results.map(({ _id }) => _id)); }, mediaSettings: ({ mediaSettings = {} }) => mediaSettings, + hasNewNotifications: ({ lastSeenNotificationDate }, input, ctx) => { + if (!ctx.user) { + return false; + } + + return ctx.loaders.Notifications.hasNewNotifications( + ctx.user.id, + ctx.user.lastSeenNotificationDate ?? new Date(0) + ); + }, }; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 342db30059..7f31d95a6d 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3153,6 +3153,17 @@ type User { bio is a user-defined biography. """ bio: String + + """ + hasNewNotifications returns true if the user has received new notifications + since they last viewed their notifications tab. + """ + hasNewNotifications: Boolean! + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) } """ diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts index aca5d82a28..f7d972ba30 100644 --- a/server/src/core/server/models/notifications/notification.ts +++ b/server/src/core/server/models/notifications/notification.ts @@ -2,6 +2,7 @@ import { MongoContext } from "coral-server/data/context"; import { ConnectionInput, Query, resolveConnection } from "../helpers"; import { TenantResource } from "../tenant"; +import { User } from "../user"; export interface Notification extends TenantResource { readonly id: string; @@ -30,7 +31,10 @@ export const retrieveNotificationsConnection = async ( tenantID: string, input: NotificationsConnectionInput ) => { - const query = new Query(mongo.notifications()).where({ tenantID }); + const query = new Query(mongo.notifications()).where({ + tenantID, + ownerID: input.ownerID, + }); query.orderBy({ createdAt: -1 }); @@ -49,3 +53,48 @@ export const createNotification = async ( return op.result.ok ? notification : null; }; + +interface LastSeenNotificationChange { + $set: { + lastSeenNotificationDate?: Date | null; + }; +} + +export const markLastSeenNotification = async ( + tenantID: string, + mongo: MongoContext, + user: Readonly, + notificationDates: Date[] +) => { + let max = new Date(0); + for (const date of notificationDates) { + if (max.getTime() < date.getTime()) { + max = date; + } + } + + const change: LastSeenNotificationChange = { + $set: { lastSeenNotificationDate: user.lastSeenNotificationDate }, + }; + + if (user.lastSeenNotificationDate && user.lastSeenNotificationDate < max) { + change.$set.lastSeenNotificationDate = max; + } else { + change.$set.lastSeenNotificationDate = max; + } + + await mongo.users().findOneAndUpdate({ tenantID, id: user.id }, change); +}; + +export const hasNewNotifications = async ( + tenantID: string, + mongo: MongoContext, + ownerID: string, + lastSeen: Date +) => { + const exists = await mongo + .notifications() + .findOne({ tenantID, ownerID, createdAt: { $gt: lastSeen } }); + + return exists !== null; +}; diff --git a/server/src/core/server/models/user/user.ts b/server/src/core/server/models/user/user.ts index aa1b854db6..4488781bee 100644 --- a/server/src/core/server/models/user/user.ts +++ b/server/src/core/server/models/user/user.ts @@ -616,6 +616,12 @@ export interface User extends TenantResource { * bio is a user deifned biography */ bio?: string; + + /** + * lastSeenNotificationDate is the date of the last notification the user loaded (viewed) + * in their notification tab. + */ + lastSeenNotificationDate?: Date | null; } function hashPassword(password: string): Promise { From 00e7ddbb0eb60fd8d10faa094ef230e3c1268cff Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 25 Oct 2023 10:28:32 -0600 Subject: [PATCH 19/35] use bell icon for notifications show active bell icon when new notifications are available --- client/src/core/client/stream/App/TabBar.css | 7 ++++ client/src/core/client/stream/App/TabBar.tsx | 36 ++++++++++--------- .../icons/ActiveNotificationBellIcon.tsx | 32 +++++++++++++++++ .../components/icons/NotificationBellIcon.tsx | 28 +++++++++++++++ .../core/client/ui/components/icons/index.ts | 2 ++ locales/en-US/stream.ftl | 2 ++ 6 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx create mode 100644 client/src/core/client/ui/components/icons/NotificationBellIcon.tsx diff --git a/client/src/core/client/stream/App/TabBar.css b/client/src/core/client/stream/App/TabBar.css index 28793e4b81..91fab71ca0 100644 --- a/client/src/core/client/stream/App/TabBar.css +++ b/client/src/core/client/stream/App/TabBar.css @@ -5,6 +5,13 @@ padding-right: var(--spacing-3); } +.condensedTab { + padding-top: var(--spacing-2); + padding-bottom: var(--spacing-2); + padding-left: var(--spacing-2); + padding-right: var(--spacing-2); +} + .configureTab { padding-left: var(--spacing-4); padding-right: var(--spacing-4); diff --git a/client/src/core/client/stream/App/TabBar.tsx b/client/src/core/client/stream/App/TabBar.tsx index f7999c9688..10d91e8a33 100644 --- a/client/src/core/client/stream/App/TabBar.tsx +++ b/client/src/core/client/stream/App/TabBar.tsx @@ -5,11 +5,12 @@ import useGetMessage from "coral-framework/lib/i18n/useGetMessage"; import { GQLSTORY_MODE } from "coral-framework/schema"; import CLASSES from "coral-stream/classes"; import { + ActiveNotificationBellIcon, CogIcon, ConversationChatIcon, ConversationQuestionWarningIcon, - InformationCircleIcon, MessagesBubbleSquareIcon, + NotificationBellIcon, RatingStarIcon, SingleNeutralCircleIcon, SvgIcon, @@ -61,10 +62,12 @@ const AppTabBar: FunctionComponent = (props) => { ); const myProfileText = getMessage("general-tabBar-myProfileTab", "My Profile"); const configureText = getMessage("general-tabBar-configure", "Configure"); - const notificationsText = getMessage( - "general-tabBar-notifications", - "Notifications" - ); + const notificationsText = props.hasNewNotifications + ? getMessage( + "general-tabBar-notifications-hasNew", + "Notifications (has new)" + ) + : getMessage("general-tabBar-notifications", "Notifications"); return ( @@ -170,22 +173,23 @@ const AppTabBar: FunctionComponent = (props) => { {props.showNotificationsTab && ( - {matches ? ( - - {notificationsText} {`${props.hasNewNotifications}`} - - ) : ( -
- -
- )} +
+ +
)} diff --git a/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx b/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx new file mode 100644 index 0000000000..749cf8bc26 --- /dev/null +++ b/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx @@ -0,0 +1,32 @@ +import React, { FunctionComponent } from "react"; + +const ActiveNotificationBellIcon: FunctionComponent = () => { + return ( + + + + + + + ); +}; + +export default ActiveNotificationBellIcon; diff --git a/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx b/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx new file mode 100644 index 0000000000..7ebbefb703 --- /dev/null +++ b/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx @@ -0,0 +1,28 @@ +import React, { FunctionComponent } from "react"; + +const NotificationBellIcon: FunctionComponent = () => { + return ( + + + + + + ); +}; + +export default NotificationBellIcon; diff --git a/client/src/core/client/ui/components/icons/index.ts b/client/src/core/client/ui/components/icons/index.ts index 55e68dbeff..56728761f6 100644 --- a/client/src/core/client/ui/components/icons/index.ts +++ b/client/src/core/client/ui/components/icons/index.ts @@ -1,3 +1,4 @@ +export { default as ActiveNotificationBellIcon } from "./ActiveNotificationBellIcon"; export { default as AddIcon } from "./AddIcon"; export { default as AlarmBellIcon } from "./AlarmBellIcon"; export { default as AlarmClockIcon } from "./AlarmClockIcon"; @@ -50,6 +51,7 @@ export { default as MultipleActionsChatIcon } from "./MultipleActionsChatIcon"; export { default as MultipleNeutralIcon } from "./MultipleNeutralIcon"; export { default as NavigationMenuHorizontalIcon } from "./NavigationMenuHorizontalIcon"; export { default as NavigationMenuVerticalIcon } from "./NavigationMenuVerticalIcon"; +export { default as NotificationBellIcon } from "./NotificationBellIcon"; export { default as PaperWriteIcon } from "./PaperWriteIcon"; export { default as PencilIcon } from "./PencilIcon"; export { default as QuestionCircleIcon } from "./QuestionCircleIcon"; diff --git a/locales/en-US/stream.ftl b/locales/en-US/stream.ftl index d579675c0a..5ad488fa83 100644 --- a/locales/en-US/stream.ftl +++ b/locales/en-US/stream.ftl @@ -28,6 +28,8 @@ general-tabBar-myProfileTab = My Profile general-tabBar-discussionsTab = Discussions general-tabBar-reviewsTab = Reviews general-tabBar-configure = Configure +general-tabBar-notifications = Notifications +general-tabBar-notifications-hasNew = Notifications (has new) general-mainTablist = .aria-label = Main Tablist From 310bedf1b0bea8aa72b5cc76c6bbc22905963c35 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 25 Oct 2023 11:23:35 -0600 Subject: [PATCH 20/35] handle hasNewNotifications within the local relay state this allows us to clear the notifications red dot when the user has viewed it and also in future, if we want to, hook it into a websocket or similar live update-able value. --- .../client/stream/App/TabBarContainer.tsx | 5 ++-- .../core/client/stream/App/TabBarQuery.tsx | 23 ++++++++++++++----- .../client/stream/local/initLocalState.ts | 2 ++ .../core/client/stream/local/local.graphql | 2 ++ .../Notifications/NotificationsContainer.tsx | 19 +++++++++++++-- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 7b1310ca99..a8c2da0c53 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -30,11 +30,12 @@ export const TabBarContainer: FunctionComponent = ({ settings, setActiveTab, }) => { - const [{ activeTab, dsaFeaturesEnabled }] = + const [{ activeTab, dsaFeaturesEnabled, hasNewNotifications }] = useLocal(graphql` fragment TabBarContainerLocal on Local { activeTab dsaFeaturesEnabled + hasNewNotifications } `); const handleSetActiveTab = useCallback( @@ -74,7 +75,7 @@ export const TabBarContainer: FunctionComponent = ({ showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} showNotificationsTab={showNotificationsTab} - hasNewNotifications={!!viewer?.hasNewNotifications} + hasNewNotifications={!!hasNewNotifications} onTabClick={handleSetActiveTab} /> ); diff --git a/client/src/core/client/stream/App/TabBarQuery.tsx b/client/src/core/client/stream/App/TabBarQuery.tsx index 1b5d488e88..26b470da79 100644 --- a/client/src/core/client/stream/App/TabBarQuery.tsx +++ b/client/src/core/client/stream/App/TabBarQuery.tsx @@ -11,12 +11,15 @@ import ErrorReporterSetUserContainer from "./ErrorReporterSetUserContainer"; import TabBarContainer from "./TabBarContainer"; const TabBarQuery: FunctionComponent = () => { - const [{ storyID, storyURL }] = useLocal(graphql` - fragment TabBarQueryLocal on Local { - storyID - storyURL - } - `); + const [{ storyID, storyURL, hasNewNotifications }, setLocal] = + useLocal(graphql` + fragment TabBarQueryLocal on Local { + storyID + storyURL + hasNewNotifications + } + `); + return ( query={graphql` @@ -24,6 +27,7 @@ const TabBarQuery: FunctionComponent = () => { viewer { ...TabBarContainer_viewer ...ErrorReporterSetUserContainer_viewer + hasNewNotifications } story(id: $storyID, url: $storyURL) { ...TabBarContainer_story @@ -46,6 +50,13 @@ const TabBarQuery: FunctionComponent = () => { ) : null; + if (hasNewNotifications === null) { + setLocal({ + hasNewNotifications: + props && props.viewer ? props.viewer.hasNewNotifications : false, + }); + } + return ( <> {ErrorReporterSetUser} diff --git a/client/src/core/client/stream/local/initLocalState.ts b/client/src/core/client/stream/local/initLocalState.ts index 2549ad3d29..2be1a5da29 100644 --- a/client/src/core/client/stream/local/initLocalState.ts +++ b/client/src/core/client/stream/local/initLocalState.ts @@ -179,5 +179,7 @@ export const createInitLocalState: (options: Options) => InitLocalState = const dsaFeaturesEnabled = staticConfig?.dsaFeaturesEnabled ?? false; localRecord.setValue(dsaFeaturesEnabled, "dsaFeaturesEnabled"); + + localRecord.setValue(null, "hasNewNotifications"); }); }; diff --git a/client/src/core/client/stream/local/local.graphql b/client/src/core/client/stream/local/local.graphql index 131585783f..d5e573a3dc 100644 --- a/client/src/core/client/stream/local/local.graphql +++ b/client/src/core/client/stream/local/local.graphql @@ -129,4 +129,6 @@ extend type Local { refreshStream: Boolean dsaFeaturesEnabled: Boolean + + hasNewNotifications: Boolean } diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx index 3773698b8c..ffc6b2c2a8 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx @@ -1,11 +1,12 @@ -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql } from "react-relay"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { useLocal, withFragmentContainer } from "coral-framework/lib/relay"; import { UserBoxContainer } from "coral-stream/common/UserBox"; import { NotificationsContainer_settings } from "coral-stream/__generated__/NotificationsContainer_settings.graphql"; import { NotificationsContainer_viewer } from "coral-stream/__generated__/NotificationsContainer_viewer.graphql"; +import { NotificationsContainerLocal } from "coral-stream/__generated__/NotificationsContainerLocal.graphql"; import NotificationsListQuery from "./NotificationsListQuery"; @@ -20,6 +21,20 @@ const NotificationsContainer: FunctionComponent = ({ viewer, settings, }) => { + const [, setLocal] = useLocal(graphql` + fragment NotificationsContainerLocal on Local { + hasNewNotifications + } + `); + + const setViewed = useCallback(() => { + setLocal({ hasNewNotifications: false }); + }, [setLocal]); + + useEffect(() => { + setTimeout(setViewed, 300); + }, [setViewed]); + return ( <>
From 068cebd6643c8e3975b5106f8a131dad9614887b Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 25 Oct 2023 16:12:30 -0600 Subject: [PATCH 21/35] update fixtures to handle new notification resolvers --- client/src/core/client/stream/App/TabBar.tsx | 11 +++-------- client/src/core/client/stream/test/create.tsx | 2 ++ client/src/core/client/stream/test/fixtures.ts | 1 + 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/client/src/core/client/stream/App/TabBar.tsx b/client/src/core/client/stream/App/TabBar.tsx index 10d91e8a33..3deba8cc90 100644 --- a/client/src/core/client/stream/App/TabBar.tsx +++ b/client/src/core/client/stream/App/TabBar.tsx @@ -62,12 +62,6 @@ const AppTabBar: FunctionComponent = (props) => { ); const myProfileText = getMessage("general-tabBar-myProfileTab", "My Profile"); const configureText = getMessage("general-tabBar-configure", "Configure"); - const notificationsText = props.hasNewNotifications - ? getMessage( - "general-tabBar-notifications-hasNew", - "Notifications (has new)" - ) - : getMessage("general-tabBar-notifications", "Notifications"); return ( @@ -173,12 +167,13 @@ const AppTabBar: FunctionComponent = (props) => { {props.showNotificationsTab && (
({ }, ], avatar: NULL_VALUE, + hasNewNotifications: false, }); export const userWithModMessageHistory = createFixture( From cd33c2b11de2868abf6be89c60e75b622bfa2eb5 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 25 Oct 2023 16:52:56 -0600 Subject: [PATCH 22/35] preliminarily visualize seen state on notifications --- .../Notifications/NotificationContainer.css | 8 +++++ .../Notifications/NotificationContainer.tsx | 30 +++++++++++++++++-- .../Notifications/NotificationsPaginator.tsx | 13 ++++++-- .../src/core/server/graph/resolvers/User.ts | 3 ++ .../core/server/graph/schema/schema.graphql | 10 +++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css index c34991db65..f267bd0411 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -15,6 +15,14 @@ word-break: break-word; } +.seen { + border-color: var(--palette-grey-300); +} + +.notSeen { + border-color: var(--palette-primary-500); +} + .title { width: 100%; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index 67126076d9..705095d053 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -6,33 +6,59 @@ import { getURLWithCommentID } from "coral-framework/helpers"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; +import { NotificationContainer_viewer } from "coral-stream/__generated__/NotificationContainer_viewer.graphql"; import styles from "./NotificationContainer.css"; interface Props { + viewer: NotificationContainer_viewer | null; notification: NotificationContainer_notification; } const NotificationContainer: FunctionComponent = ({ - notification: { title, body, comment }, + notification: { title, body, comment, createdAt }, + viewer, }) => { + if (!viewer) { + return null; + } + + const createdAtDate = new Date(createdAt); + const lastSeenDate = viewer.lastSeenNotificationDate + ? new Date(viewer.lastSeenNotificationDate) + : new Date(0); + + const seen = createdAtDate.getTime() <= lastSeenDate.getTime(); + const commentURL = comment ? getURLWithCommentID(comment.story.url, comment.id) : ""; return ( -
+
{title &&
{title}
} {body &&
{body}
} {comment && {commentURL}} + {`seen: ${seen}`}
); }; const enhanced = withFragmentContainer({ + viewer: graphql` + fragment NotificationContainer_viewer on User { + lastSeenNotificationDate + } + `, notification: graphql` fragment NotificationContainer_notification on Notification { id + createdAt title body comment { diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx index af772b21cb..32c9c714c7 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx @@ -48,14 +48,20 @@ const NotificationsPaginator: FunctionComponent = (props) => { ); }, [props.relay]); - if (!props.query) { + if (!props.query || !props.query.viewer) { return null; } return (
{props.query.notifications.edges.map(({ node }) => { - return ; + return ( + + ); })} {isRefetching && } {!isRefetching && !disableLoadMore && props.relay.hasMore() && ( @@ -93,6 +99,9 @@ const enhanced = withPaginationContainer< cursor: { type: "Cursor" } viewerID: { type: "ID!" } ) { + viewer { + ...NotificationContainer_viewer + } notifications(ownerID: $viewerID, after: $cursor, first: $count) @connection(key: "NotificationsPaginator_notifications") { edges { diff --git a/server/src/core/server/graph/resolvers/User.ts b/server/src/core/server/graph/resolvers/User.ts index 0faf2707f9..dcf1a1e1fc 100644 --- a/server/src/core/server/graph/resolvers/User.ts +++ b/server/src/core/server/graph/resolvers/User.ts @@ -99,4 +99,7 @@ export const User: GQLUserTypeResolver = { ctx.user.lastSeenNotificationDate ?? new Date(0) ); }, + lastSeenNotificationDate: ({ lastSeenNotificationDate }) => { + return lastSeenNotificationDate ?? new Date(0); + }, }; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 7f31d95a6d..3cb9af4668 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3164,6 +3164,16 @@ type User { roles: [ADMIN, MODERATOR] permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] ) + + """ + lastSeenNotificationDate is the date of the last notification the user viewed. + """ + lastSeenNotificationDate: Time + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) } """ From f182afb70d2d52c5a72e71e4445160684a2f8c76 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 25 Oct 2023 17:10:08 -0600 Subject: [PATCH 23/35] use memo for notification seen state --- .../Notifications/NotificationContainer.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index 705095d053..beaffb888b 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -1,5 +1,5 @@ import cn from "classnames"; -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useMemo } from "react"; import { graphql } from "react-relay"; import { getURLWithCommentID } from "coral-framework/helpers"; @@ -19,16 +19,18 @@ const NotificationContainer: FunctionComponent = ({ notification: { title, body, comment, createdAt }, viewer, }) => { - if (!viewer) { - return null; - } + const seen = useMemo(() => { + if (!viewer) { + return false; + } - const createdAtDate = new Date(createdAt); - const lastSeenDate = viewer.lastSeenNotificationDate - ? new Date(viewer.lastSeenNotificationDate) - : new Date(0); + const createdAtDate = new Date(createdAt); + const lastSeenDate = viewer.lastSeenNotificationDate + ? new Date(viewer.lastSeenNotificationDate) + : new Date(0); - const seen = createdAtDate.getTime() <= lastSeenDate.getTime(); + return createdAtDate.getTime() <= lastSeenDate.getTime(); + }, [createdAt, viewer]); const commentURL = comment ? getURLWithCommentID(comment.story.url, comment.id) @@ -44,7 +46,6 @@ const NotificationContainer: FunctionComponent = ({ {title &&
{title}
} {body &&
{body}
} {comment && {commentURL}} - {`seen: ${seen}`}
); }; From 89608a2ff397792b97169e869d04b7b0b93ccbf5 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Mon, 30 Oct 2023 09:31:04 -0600 Subject: [PATCH 24/35] add resolvers for DSA deports associated to a notification also adds loaders for DSA reports --- .../core/server/graph/loaders/DSAReports.ts | 22 +++++++++++++++ server/src/core/server/graph/loaders/index.ts | 2 ++ .../server/graph/resolvers/Notification.ts | 7 +++++ .../resolvers/NotificationDSAReportDetails.ts | 27 +++++++++++++++++++ .../src/core/server/graph/resolvers/index.ts | 2 ++ .../core/server/graph/schema/schema.graphql | 26 +++++++++++++++++- .../core/server/models/dsaReport/report.ts | 24 ++++++++++++++++- 7 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 server/src/core/server/graph/loaders/DSAReports.ts create mode 100644 server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts diff --git a/server/src/core/server/graph/loaders/DSAReports.ts b/server/src/core/server/graph/loaders/DSAReports.ts new file mode 100644 index 0000000000..17c5159b57 --- /dev/null +++ b/server/src/core/server/graph/loaders/DSAReports.ts @@ -0,0 +1,22 @@ +import DataLoader from "dataloader"; + +import { find } from "coral-server/models/dsaReport/report"; + +import GraphContext from "../context"; +import { createManyBatchLoadFn } from "./util"; + +export interface FindDSAReportInput { + id: string; +} + +export default (ctx: GraphContext) => ({ + find: new DataLoader( + createManyBatchLoadFn((input: FindDSAReportInput) => + find(ctx.mongo, ctx.tenant, input) + ), + { + cacheKeyFn: (input: FindDSAReportInput) => `${input.id}`, + cache: !ctx.disableCaching, + } + ), +}); diff --git a/server/src/core/server/graph/loaders/index.ts b/server/src/core/server/graph/loaders/index.ts index 64a77c41bd..906ed2d51a 100644 --- a/server/src/core/server/graph/loaders/index.ts +++ b/server/src/core/server/graph/loaders/index.ts @@ -4,6 +4,7 @@ import Auth from "./Auth"; import CommentActions from "./CommentActions"; import CommentModerationActions from "./CommentModerationActions"; import Comments from "./Comments"; +import DSAReports from "./DSAReports"; import Notifications from "./Notifications"; import SeenComments from "./SeenComments"; import Sites from "./Sites"; @@ -20,4 +21,5 @@ export default (ctx: Context) => ({ Sites: Sites(ctx), SeenComments: SeenComments(ctx), Notifications: Notifications(ctx), + DSAReports: DSAReports(ctx), }); diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts index 9742b23d4b..56e86def8a 100644 --- a/server/src/core/server/graph/resolvers/Notification.ts +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -23,4 +23,11 @@ export const NotificationResolver: Required< return comment; }, + dsaReport: async ({ reportID }, input, ctx) => { + if (!reportID) { + return null; + } + + return await ctx.loaders.DSAReports.find.load({ id: reportID }); + }, }; diff --git a/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts new file mode 100644 index 0000000000..77ecb0abf4 --- /dev/null +++ b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts @@ -0,0 +1,27 @@ +import { DSAReport } from "coral-server/models/dsaReport/report"; + +import { GQLNotificationDSAReportDetailsTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const NotificationDSAReportDetailsResolver: Required< + GQLNotificationDSAReportDetailsTypeResolver +> = { + id: ({ id }) => id, + publicID: ({ publicID }) => publicID, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + return await ctx.loaders.Comments.comment.load(commentID); + }, + user: async ({ userID }, input, ctx) => { + if (!userID) { + return null; + } + + return await ctx.loaders.Users.user.load(userID); + }, + lawBrokenDescription: ({ lawBrokenDescription }) => lawBrokenDescription, + additionalInformation: ({ additionalInformation }) => additionalInformation, + submissionID: ({ submissionID }) => submissionID, +}; diff --git a/server/src/core/server/graph/resolvers/index.ts b/server/src/core/server/graph/resolvers/index.ts index fc90bced55..58df174566 100644 --- a/server/src/core/server/graph/resolvers/index.ts +++ b/server/src/core/server/graph/resolvers/index.ts @@ -46,6 +46,7 @@ import { ModMessageStatusHistory } from "./ModMessageStatusHistory"; import { Mutation } from "./Mutation"; import { NewCommentersConfiguration } from "./NewCommentersConfiguration"; import { NotificationResolver as Notification } from "./Notification"; +import { NotificationDSAReportDetailsResolver as NotificationDSAReportDetails } from "./NotificationDSAReportDetails"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; import { PremodStatus } from "./PremodStatus"; import { PremodStatusHistory } from "./PremodStatusHistory"; @@ -167,6 +168,7 @@ const Resolvers: GQLResolver = { LocalAuthIntegration, AuthenticationTargetFilter, Notification, + NotificationDSAReportDetails, }; export default Resolvers; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 3cb9af4668..3c03c2c84e 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -4514,6 +4514,13 @@ type Notification { comment is the optional comment that is linked to this notification. """ comment: Comment + + """ + dsaReports are details of the DSA Reports related to the notification. + This is usually in reference to the comment that is also related to + the notification. + """ + dsaReport: NotificationDSAReportDetails } type NotificationEdge { @@ -4545,6 +4552,22 @@ type NotificationsConnection { pageInfo: PageInfo! } +type NotificationDSAReportDetails { + id: ID! + + publicID: String! + + comment: Comment + + user: User + + lawBrokenDescription: String @auth(roles: [ADMIN, MODERATOR]) + + additionalInformation: String @auth(roles: [ADMIN, MODERATOR]) + + submissionID: ID @auth(roles: [ADMIN, MODERATOR]) +} + ################################################################################ ## Query ################################################################################ @@ -4718,7 +4741,8 @@ type Query { notifications( ownerID: ID! first: Int = 10 @constraint(max: 50) - after: Cursor): NotificationsConnection! + after: Cursor + ): NotificationsConnection! } ################################################################################ diff --git a/server/src/core/server/models/dsaReport/report.ts b/server/src/core/server/models/dsaReport/report.ts index 1bbbf94935..968cbfd0ec 100644 --- a/server/src/core/server/models/dsaReport/report.ts +++ b/server/src/core/server/models/dsaReport/report.ts @@ -2,8 +2,9 @@ import { v4 as uuid } from "uuid"; import { Sub } from "coral-common/common/lib/types"; import { MongoContext } from "coral-server/data/context"; +import { FindDSAReportInput } from "coral-server/graph/loaders/DSAReports"; import { FilterQuery } from "coral-server/models/helpers"; -import { TenantResource } from "coral-server/models/tenant"; +import { Tenant, TenantResource } from "coral-server/models/tenant"; import { GQLDSAReportStatus } from "coral-server/graph/schema/__generated__/types"; @@ -98,3 +99,24 @@ export async function createDSAReport( dsaReport: report, }; } + +export async function find( + mongo: MongoContext, + tenant: Tenant, + input: FindDSAReportInput +) { + return findDSAReport(mongo, tenant.id, input.id); +} + +export async function findDSAReport( + mongo: MongoContext, + tenantID: string, + id: string +): Promise { + const result = await mongo.dsaReports().findOne({ + tenantID, + id, + }); + + return result ?? null; +} From 0071c5252f9db7c0c127b68790be13cb0c39488d Mon Sep 17 00:00:00 2001 From: nick-funk Date: Mon, 30 Oct 2023 13:53:15 -0600 Subject: [PATCH 25/35] add dsa notification details comments to schema --- .../core/server/graph/schema/schema.graphql | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 3c03c2c84e..65fe824e61 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -4516,7 +4516,7 @@ type Notification { comment: Comment """ - dsaReports are details of the DSA Reports related to the notification. + dsaReport is the details of the DSA Report related to the notification. This is usually in reference to the comment that is also related to the notification. """ @@ -4553,18 +4553,44 @@ type NotificationsConnection { } type NotificationDSAReportDetails { + """ + id is the primary identifier of the DSA report. + """ id: ID! + """ + publicID is the public identifier that is human readable + for the DSA report. + """ publicID: String! + """ + comment is the comment associated with the DSA report. + """ comment: Comment + """ + user is the target user (of the comment) the DSA report pertains to. + """ user: User + """ + lawBrokenDescription describes the law that was allegedly broken in the + DSA report. + """ lawBrokenDescription: String @auth(roles: [ADMIN, MODERATOR]) + """ + additionalInformation is any further relevant details added by the user + who filed the DSA report. + """ additionalInformation: String @auth(roles: [ADMIN, MODERATOR]) + """ + submissionID is the linking id that connects this DSA report to other reports + that may have been submitted at the same time by the same reporting user as + a collection of co-related DSA reports. + """ submissionID: ID @auth(roles: [ADMIN, MODERATOR]) } From a9ab1486aaebfeec264dd542bbdc160523899e85 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Mon, 30 Oct 2023 16:49:00 -0600 Subject: [PATCH 26/35] update notification styles to better match figma designs --- .../Notifications/NotificationContainer.css | 45 ++++++++++++++----- .../Notifications/NotificationContainer.tsx | 37 ++++++++------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css index f267bd0411..13d14d54fc 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -3,42 +3,67 @@ font-weight: var(--font-weight-primary-regular); font-size: var(--font-size-2); - padding: var(--spacing-4); + padding-left: var(--spacing-2); box-sizing: border-box; - border-color: var(--palette-grey-300); - border-width: 1px; - border-style: solid; - + border-left-style: solid; border-left-width: 4px; - word-break: break-word; + margin: var(--spacing-2); + padding: var(--spacing-2); } .seen { - border-color: var(--palette-grey-300); + border-left-color: var(--palette-background-body); } .notSeen { - border-color: var(--palette-primary-500); + border-left-color: var(--palette-primary-500); } .title { width: 100%; + display: flex; + justify-content: left; + align-items: center; + + margin-bottom: var(--spacing-2); +} + +.titleText { + margin-left: var(--spacing-2); font-family: var(--font-family-primary); font-weight: var(--font-weight-primary-semi-bold); - font-size: var(--font-size-2); + font-size: var(--font-size-3); + + word-break: break-word; } .body { - width: 100%; + margin-left: var(--spacing-5); + margin-bottom: var(--spacing-2); + + width: calc(100% - var(--spacing-4)); font-family: var(--font-family-primary); font-weight: var(--font-weight-primary-regular); font-size: var(--font-size-2); } +.footer { + margin-left: var(--spacing-5); +} + +.divider { + margin-left: var(--spacing-4); + + width: calc(100% - var(--spacing-4) * 2); + height: 1px; + + background-color: var(--palette-grey-200); +} + .commentData { padding: var(--spacing-4); diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index beaffb888b..bcc15297f4 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -2,8 +2,9 @@ import cn from "classnames"; import React, { FunctionComponent, useMemo } from "react"; import { graphql } from "react-relay"; -import { getURLWithCommentID } from "coral-framework/helpers"; import { withFragmentContainer } from "coral-framework/lib/relay"; +import { CheckCircleIcon, SvgIcon } from "coral-ui/components/icons"; +import { RelativeTime } from "coral-ui/components/v2"; import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; import { NotificationContainer_viewer } from "coral-stream/__generated__/NotificationContainer_viewer.graphql"; @@ -32,21 +33,27 @@ const NotificationContainer: FunctionComponent = ({ return createdAtDate.getTime() <= lastSeenDate.getTime(); }, [createdAt, viewer]); - const commentURL = comment - ? getURLWithCommentID(comment.story.url, comment.id) - : ""; - return ( -
- {title &&
{title}
} - {body &&
{body}
} - {comment && {commentURL}} -
+ <> +
+ {title && ( +
+ +
{title}
+
+ )} + {body &&
{body}
} +
+ +
+
+
+ ); }; From da5727700c6e794d883b7855970b0cdeaf1976a8 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Tue, 31 Oct 2023 13:36:13 -0600 Subject: [PATCH 27/35] add null/undefined check when setting lastSeenNotificationDate --- .../src/core/client/stream/App/TabBarContainer.tsx | 1 - .../server/models/notifications/notification.ts | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index a8c2da0c53..0373c7d74e 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -86,7 +86,6 @@ const enhanced = withSetActiveTabMutation( viewer: graphql` fragment TabBarContainer_viewer on User { role - hasNewNotifications } `, story: graphql` diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts index f7d972ba30..25947c6d9d 100644 --- a/server/src/core/server/models/notifications/notification.ts +++ b/server/src/core/server/models/notifications/notification.ts @@ -66,6 +66,10 @@ export const markLastSeenNotification = async ( user: Readonly, notificationDates: Date[] ) => { + if (!notificationDates || notificationDates.length === 0) { + return; + } + let max = new Date(0); for (const date of notificationDates) { if (max.getTime() < date.getTime()) { @@ -77,9 +81,15 @@ export const markLastSeenNotification = async ( $set: { lastSeenNotificationDate: user.lastSeenNotificationDate }, }; - if (user.lastSeenNotificationDate && user.lastSeenNotificationDate < max) { + if ( + user.lastSeenNotificationDate && + user.lastSeenNotificationDate.getTime() < max.getTime() + ) { change.$set.lastSeenNotificationDate = max; - } else { + } else if ( + user.lastSeenNotificationDate === null || + user.lastSeenNotificationDate === undefined + ) { change.$set.lastSeenNotificationDate = max; } From e9fcfc4c88994bf96f1cbaf40936b2f23b6aeed5 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 1 Nov 2023 17:36:45 -0600 Subject: [PATCH 28/35] implement preliminary comment styling for stream notifications --- .../NotificationCommentContainer.css | 47 ++++++++++ .../NotificationCommentContainer.tsx | 90 +++++++++++++++++++ .../Notifications/NotificationContainer.css | 13 +++ .../Notifications/NotificationContainer.tsx | 16 ++-- locales/en-US/stream.ftl | 8 +- 5 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css create mode 100644 client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css new file mode 100644 index 0000000000..3452ea87fd --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css @@ -0,0 +1,47 @@ +.toggle { + padding: 0; + margin-bottom: var(--spacing-1); + + border-style: none; + + background-color: transparent; + + color: var(--palette-primary-300); + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-2); + + &:hover, + &.mouseHover { + color: var(--palette-primary-200); + } + &:active, + &.active { + color: var(--palette-primary-400); + } +} + +.content { + border-left-style: solid; + border-left-width: 2px; + border-left-color: var(--palette-grey-400); + + padding-left: var(--spacing-3); +} + +.timestamp { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-grey-500); +} + +.storyTitle { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-text-900); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx new file mode 100644 index 0000000000..b42105ded0 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx @@ -0,0 +1,90 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLCOMMENT_STATUS } from "coral-framework/schema"; +import HTMLContent from "coral-stream/common/HTMLContent"; +import { Timestamp } from "coral-ui/components/v2"; + +import { NotificationCommentContainer_comment } from "coral-stream/__generated__/NotificationCommentContainer_comment.graphql"; + +import styles from "./NotificationCommentContainer.css"; + +interface Props { + comment: NotificationCommentContainer_comment; +} + +const NotificationCommentContainer: FunctionComponent = ({ + comment, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const onToggleOpenClosed = useCallback(() => { + setIsOpen(!isOpen); + }, [setIsOpen, isOpen]); + + return ( + <> + {comment.status === GQLCOMMENT_STATUS.APPROVED && isOpen && ( + + )} + {comment.status === GQLCOMMENT_STATUS.APPROVED && !isOpen && ( + + )} + {comment.status === GQLCOMMENT_STATUS.REJECTED && isOpen && ( + + )} + {comment.status === GQLCOMMENT_STATUS.REJECTED && !isOpen && ( + + )} + + {isOpen && ( +
+ + {comment.createdAt} + + {comment.body || ""} + {comment.story.metadata?.title && ( +
+ {comment.story.metadata.title} +
+ )} +
+ )} + + ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment NotificationCommentContainer_comment on Comment { + createdAt + body + status + story { + metadata { + title + } + } + } + `, +})(NotificationCommentContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css index 13d14d54fc..a42cb15dd0 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -51,10 +51,23 @@ font-size: var(--font-size-2); } +.contextItem { + margin-left: var(--spacing-5); + margin-bottom: var(--spacing-2); +} + .footer { margin-left: var(--spacing-5); } +.timestamp { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-text-500); +} + .divider { margin-left: var(--spacing-4); diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx index bcc15297f4..41d5ffb7aa 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -4,11 +4,13 @@ import { graphql } from "react-relay"; import { withFragmentContainer } from "coral-framework/lib/relay"; import { CheckCircleIcon, SvgIcon } from "coral-ui/components/icons"; -import { RelativeTime } from "coral-ui/components/v2"; +import { Timestamp } from "coral-ui/components/v2"; import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; import { NotificationContainer_viewer } from "coral-stream/__generated__/NotificationContainer_viewer.graphql"; +import NotificationCommentContainer from "./NotificationCommentContainer"; + import styles from "./NotificationContainer.css"; interface Props { @@ -48,8 +50,13 @@ const NotificationContainer: FunctionComponent = ({
)} {body &&
{body}
} + {comment && ( +
+ +
+ )}
- + {createdAt}
@@ -70,10 +77,7 @@ const enhanced = withFragmentContainer({ title body comment { - id - story { - url - } + ...NotificationCommentContainer_comment } } `, diff --git a/locales/en-US/stream.ftl b/locales/en-US/stream.ftl index 5ad488fa83..cb44a659e5 100644 --- a/locales/en-US/stream.ftl +++ b/locales/en-US/stream.ftl @@ -1008,4 +1008,10 @@ stream-footer-navigation = ## Notifications -notifications-loadMore = Load More \ No newline at end of file +notifications-loadMore = Load More +notification-comment-toggle-approved-open = Approved comment +notification-comment-toggle-approved-closed = + Approved comment +notification-comment-toggle-rejected-open = Rejected comment +notification-comment-toggle-rejected-closed = + Rejected comment +notification-comment-toggle-default-open = Comment +notification-comment-toggle-default-closed = + Comment \ No newline at end of file From daa73eaf644a872501b6c800ad69692e260629a2 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Wed, 1 Nov 2023 17:46:09 -0600 Subject: [PATCH 29/35] add title to notifications tab content area --- .../stream/tabs/Notifications/NotificationContainer.css | 2 +- .../stream/tabs/Notifications/NotificationsContainer.css | 8 ++++++++ .../stream/tabs/Notifications/NotificationsContainer.tsx | 4 ++++ locales/en-US/stream.ftl | 1 + 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css index a42cb15dd0..3857ef34c1 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -34,7 +34,7 @@ margin-left: var(--spacing-2); font-family: var(--font-family-primary); - font-weight: var(--font-weight-primary-semi-bold); + font-weight: var(--font-weight-primary-bold); font-size: var(--font-size-3); word-break: break-word; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css index ae8d775db9..fc49a10ff4 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css @@ -1,3 +1,11 @@ .userBox { padding-bottom: var(--spacing-2); } + +.title { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-3); + + word-break: break-word; +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx index ffc6b2c2a8..bafb554fd6 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx @@ -1,3 +1,4 @@ +import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent, useCallback, useEffect } from "react"; import { graphql } from "react-relay"; @@ -40,6 +41,9 @@ const NotificationsContainer: FunctionComponent = ({
+
+ Notifications +
); diff --git a/locales/en-US/stream.ftl b/locales/en-US/stream.ftl index cb44a659e5..9648b0b979 100644 --- a/locales/en-US/stream.ftl +++ b/locales/en-US/stream.ftl @@ -1008,6 +1008,7 @@ stream-footer-navigation = ## Notifications +notifications-title = Notifications notifications-loadMore = Load More notification-comment-toggle-approved-open = Approved comment notification-comment-toggle-approved-closed = + Approved comment From c8cf99d5d9eee6b46ebacdcce0e83040c7c286e2 Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 2 Nov 2023 10:22:34 -0600 Subject: [PATCH 30/35] rename loader function for single DSA Report --- server/src/core/server/graph/loaders/DSAReports.ts | 2 +- server/src/core/server/graph/resolvers/Notification.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/core/server/graph/loaders/DSAReports.ts b/server/src/core/server/graph/loaders/DSAReports.ts index 17c5159b57..af8d1da4e8 100644 --- a/server/src/core/server/graph/loaders/DSAReports.ts +++ b/server/src/core/server/graph/loaders/DSAReports.ts @@ -10,7 +10,7 @@ export interface FindDSAReportInput { } export default (ctx: GraphContext) => ({ - find: new DataLoader( + dsaReport: new DataLoader( createManyBatchLoadFn((input: FindDSAReportInput) => find(ctx.mongo, ctx.tenant, input) ), diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts index 56e86def8a..1ea492ac9c 100644 --- a/server/src/core/server/graph/resolvers/Notification.ts +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -28,6 +28,6 @@ export const NotificationResolver: Required< return null; } - return await ctx.loaders.DSAReports.find.load({ id: reportID }); + return await ctx.loaders.DSAReports.dsaReport.load({ id: reportID }); }, }; From 1f9b5d0a4dd2bc17e2b7c8827c2e6bb9e195509c Mon Sep 17 00:00:00 2001 From: nick-funk Date: Thu, 2 Nov 2023 10:34:30 -0600 Subject: [PATCH 31/35] persist commentStatus onto notification allows us to retain state of comment snapshotted at the time the notification was created. --- .../NotificationCommentContainer.tsx | 15 ++++++++++----- .../tabs/Notifications/NotificationContainer.tsx | 9 +++++++-- .../core/server/graph/resolvers/Notification.ts | 1 + .../src/core/server/graph/schema/schema.graphql | 8 ++++++++ .../server/models/notifications/notification.ts | 4 ++++ .../services/notifications/internal/context.ts | 3 +++ 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx index b42105ded0..701d8636fa 100644 --- a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx +++ b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx @@ -7,16 +7,21 @@ import { GQLCOMMENT_STATUS } from "coral-framework/schema"; import HTMLContent from "coral-stream/common/HTMLContent"; import { Timestamp } from "coral-ui/components/v2"; -import { NotificationCommentContainer_comment } from "coral-stream/__generated__/NotificationCommentContainer_comment.graphql"; +import { + COMMENT_STATUS, + NotificationCommentContainer_comment, +} from "coral-stream/__generated__/NotificationCommentContainer_comment.graphql"; import styles from "./NotificationCommentContainer.css"; interface Props { comment: NotificationCommentContainer_comment; + status: COMMENT_STATUS; } const NotificationCommentContainer: FunctionComponent = ({ comment, + status, }) => { const [isOpen, setIsOpen] = useState(false); @@ -26,28 +31,28 @@ const NotificationCommentContainer: FunctionComponent = ({ return ( <> - {comment.status === GQLCOMMENT_STATUS.APPROVED && isOpen && ( + {status === GQLCOMMENT_STATUS.APPROVED && isOpen && ( )} - {comment.status === GQLCOMMENT_STATUS.APPROVED && !isOpen && ( + {status === GQLCOMMENT_STATUS.APPROVED && !isOpen && ( )} - {comment.status === GQLCOMMENT_STATUS.REJECTED && isOpen && ( + {status === GQLCOMMENT_STATUS.REJECTED && isOpen && ( )} - {comment.status === GQLCOMMENT_STATUS.REJECTED && !isOpen && ( + {status === GQLCOMMENT_STATUS.REJECTED && !isOpen && (