From d06cb207d57d5d5712ea3cbd7fc26bf670a11f1a Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Sat, 11 Jan 2025 20:46:39 +0000 Subject: [PATCH] Add mark read button for individual notifications --- .../mutations/MarkNotificationRead.gql | 3 + frontend/src/graphql/mutations/index.ts | 15 +++ frontend/src/graphql/types.ts | 66 +++++++++++ .../src/pages/notifications/Notification.tsx | 93 +++++++++++++-- frontend/src/pages/notifications/styles.scss | 39 ++++++- graphql/schema/schema.graphql | 2 +- graphql/schema/types/notifications.graphql | 5 + pkg/api/resolver_mutation_notifications.go | 9 +- pkg/models/generated_exec.go | 108 +++++++++++++++++- pkg/models/generated_models.go | 5 + pkg/models/notification.go | 3 +- pkg/sqlx/querybuilder_notifications.go | 10 +- 12 files changed, 336 insertions(+), 22 deletions(-) create mode 100644 frontend/src/graphql/mutations/MarkNotificationRead.gql diff --git a/frontend/src/graphql/mutations/MarkNotificationRead.gql b/frontend/src/graphql/mutations/MarkNotificationRead.gql new file mode 100644 index 000000000..9dc1d5375 --- /dev/null +++ b/frontend/src/graphql/mutations/MarkNotificationRead.gql @@ -0,0 +1,3 @@ +mutation MarkNotificationRead($notification: MarkNotificationReadInput!) { + markNotificationsRead(notification: $notification) +} diff --git a/frontend/src/graphql/mutations/index.ts b/frontend/src/graphql/mutations/index.ts index 9d61af91b..6ad801932 100644 --- a/frontend/src/graphql/mutations/index.ts +++ b/frontend/src/graphql/mutations/index.ts @@ -137,6 +137,8 @@ import { UpdateNotificationSubscriptionsMutation, UpdateNotificationSubscriptionsMutationVariables, MarkNotificationsReadDocument, + MarkNotificationReadDocument, + MarkNotificationReadMutationVariables, MeQuery, } from "../types"; @@ -466,3 +468,16 @@ export const useMarkNotificationsRead = () => } }, }); + +export const useMarkNotificationRead = ( + variables: MarkNotificationReadMutationVariables, +) => + useMutation(MarkNotificationReadDocument, { + variables, + update(cache, { data }) { + if (data?.markNotificationsRead) { + cache.evict({ fieldName: "queryNotifications" }); + cache.evict({ fieldName: "getUnreadNotificationCount" }); + } + }, + }); diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index 72acadf66..e5e58fe99 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -496,6 +496,11 @@ export type InviteKey = { uses?: Maybe; }; +export type MarkNotificationReadInput = { + id: Scalars["ID"]["input"]; + type: NotificationEnum; +}; + export type Measurements = { __typename: "Measurements"; band_size?: Maybe; @@ -660,6 +665,10 @@ export type MutationImageDestroyArgs = { input: ImageDestroyInput; }; +export type MutationMarkNotificationsReadArgs = { + notification?: InputMaybe; +}; + export type MutationNewUserArgs = { input: NewUserInput; }; @@ -4739,6 +4748,15 @@ export type GrantInviteMutation = { grantInvite: number; }; +export type MarkNotificationReadMutationVariables = Exact<{ + notification: MarkNotificationReadInput; +}>; + +export type MarkNotificationReadMutation = { + __typename: "Mutation"; + markNotificationsRead: boolean; +}; + export type MarkNotificationsReadMutationVariables = Exact<{ [key: string]: never; }>; @@ -38652,6 +38670,54 @@ export const GrantInviteDocument = { }, ], } as unknown as DocumentNode; +export const MarkNotificationReadDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "MarkNotificationRead" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "notification" }, + }, + type: { + kind: "NonNullType", + type: { + kind: "NamedType", + name: { kind: "Name", value: "MarkNotificationReadInput" }, + }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "markNotificationsRead" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "notification" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "notification" }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + MarkNotificationReadMutation, + MarkNotificationReadMutationVariables +>; export const MarkNotificationsReadDocument = { kind: "Document", definitions: [ diff --git a/frontend/src/pages/notifications/Notification.tsx b/frontend/src/pages/notifications/Notification.tsx index 1161f7e18..16472a5cd 100644 --- a/frontend/src/pages/notifications/Notification.tsx +++ b/frontend/src/pages/notifications/Notification.tsx @@ -1,6 +1,10 @@ import React from "react"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; import { faEnvelope, faEnvelopeOpen } from "@fortawesome/free-solid-svg-icons"; import { Icon } from "src/components/fragments"; +import { editHref } from "src/utils"; +import { useMarkNotificationRead, NotificationEnum } from "src/graphql"; import { NotificationType, isSceneNotification, @@ -10,13 +14,71 @@ import { import { CommentNotification } from "./CommentNotification"; import { SceneNotification } from "./sceneNotification"; import { EditNotification } from "./EditNotification"; -import { editHref } from "src/utils"; -import { Link } from "react-router-dom"; interface Props { notification: NotificationType; } +const createMarkNotificationReadInput = (notification: NotificationType) => { + switch (notification.data.__typename) { + case "CommentOwnEdit": + return { + type: NotificationEnum.COMMENT_OWN_EDIT, + id: notification.data.comment.id, + }; + case "CommentCommentedEdit": + return { + type: NotificationEnum.COMMENT_COMMENTED_EDIT, + id: notification.data.comment.id, + }; + case "CommentVotedEdit": + return { + type: NotificationEnum.COMMENT_VOTED_EDIT, + id: notification.data.comment.id, + }; + case "DownvoteOwnEdit": + return { + type: NotificationEnum.DOWNVOTE_OWN_EDIT, + id: notification.data.edit.id, + }; + case "FailedOwnEdit": + return { + type: NotificationEnum.FAILED_OWN_EDIT, + id: notification.data.edit.id, + }; + case "FavoritePerformerEdit": + return { + type: NotificationEnum.FAVORITE_PERFORMER_EDIT, + id: notification.data.edit.id, + }; + case "FavoriteStudioEdit": + return { + type: NotificationEnum.FAVORITE_STUDIO_EDIT, + id: notification.data.edit.id, + }; + case "FingerprintedSceneEdit": + return { + type: NotificationEnum.FINGERPRINTED_SCENE_EDIT, + id: notification.data.edit.id, + }; + case "UpdatedEdit": + return { + type: NotificationEnum.UPDATED_EDIT, + id: notification.data.edit.id, + }; + case "FavoritePerformerScene": + return { + type: NotificationEnum.FAVORITE_PERFORMER_SCENE, + id: notification.data.scene.id, + }; + case "FavoriteStudioScene": + return { + type: NotificationEnum.FAVORITE_STUDIO_SCENE, + id: notification.data.scene.id, + }; + } +}; + const NotificationBody = ({ notification, }: { @@ -35,6 +97,10 @@ const NotificationHeader = ({ }: { notification: NotificationType; }) => { + const [markRead, { loading }] = useMarkNotificationRead({ + notification: createMarkNotificationReadInput(notification), + }); + const headerText = () => { if (isCommentNotification(notification)) { const editLink = ( @@ -97,12 +163,21 @@ const NotificationHeader = ({ }; return ( -
- +
+
+ {notification.read && } + {!notification.read && ( + + )} +
{headerText()}
); @@ -110,7 +185,7 @@ const NotificationHeader = ({ export const Notification: React.FC = ({ notification }) => { return ( -
+
diff --git a/frontend/src/pages/notifications/styles.scss b/frontend/src/pages/notifications/styles.scss index 90354f919..1c7d72854 100644 --- a/frontend/src/pages/notifications/styles.scss +++ b/frontend/src/pages/notifications/styles.scss @@ -1,5 +1,42 @@ -.notification { +.Notification { .SceneCard { width: 300px; } + + &-read-state { + width: 20px; + height: 20px; + display: flex; + align-self: center; + align-items: center; + justify-content: center; + + .btn { + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 4px 0; + + .fa-envelope { + display: block; + } + + .fa-envelope-open { + display: none; + } + + &:hover { + border-color: white; + + .fa-envelope { + display: none; + } + + .fa-envelope-open { + display: block; + color: white !important; + } + } + } + } } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index ba88335fd..dcd4d665e 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -188,7 +188,7 @@ type Mutation { favoriteStudio(id: ID!, favorite: Boolean!): Boolean! @hasRole(role: READ) """Mark all of the current users notifications as read.""" - markNotificationsRead: Boolean! @hasRole(role: READ) + markNotificationsRead(notification: MarkNotificationReadInput): Boolean! @hasRole(role: READ) """Update notification subscriptions for current user.""" updateNotificationSubscriptions(subscriptions: [NotificationEnum!]!): Boolean! @hasRole(role: EDIT) } diff --git a/graphql/schema/types/notifications.graphql b/graphql/schema/types/notifications.graphql index 5a23bb20a..e304b04a6 100644 --- a/graphql/schema/types/notifications.graphql +++ b/graphql/schema/types/notifications.graphql @@ -86,3 +86,8 @@ type QueryNotificationsResult { count: Int! notifications: [Notification!]! } + +input MarkNotificationReadInput { + type: NotificationEnum! + id: ID! +} diff --git a/pkg/api/resolver_mutation_notifications.go b/pkg/api/resolver_mutation_notifications.go index 746e62cf1..841a2eaef 100644 --- a/pkg/api/resolver_mutation_notifications.go +++ b/pkg/api/resolver_mutation_notifications.go @@ -6,12 +6,17 @@ import ( "github.com/stashapp/stash-box/pkg/models" ) -func (r *mutationResolver) MarkNotificationsRead(ctx context.Context) (bool, error) { +func (r *mutationResolver) MarkNotificationsRead(ctx context.Context, notification *models.MarkNotificationReadInput) (bool, error) { user := getCurrentUser(ctx) fac := r.getRepoFactory(ctx) err := fac.WithTxn(func() error { qb := fac.Notification() - return qb.MarkRead(user.ID) + + if notification == nil { + return qb.MarkAllRead(user.ID) + } else { + return qb.MarkRead(user.ID, notification.Type, notification.ID) + } }) return err == nil, err } diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index 858b5016a..e75c91252 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -234,7 +234,7 @@ type ComplexityRoot struct { GrantInvite func(childComplexity int, input GrantInviteInput) int ImageCreate func(childComplexity int, input ImageCreateInput) int ImageDestroy func(childComplexity int, input ImageDestroyInput) int - MarkNotificationsRead func(childComplexity int) int + MarkNotificationsRead func(childComplexity int, notification *MarkNotificationReadInput) int NewUser func(childComplexity int, input NewUserInput) int PerformerCreate func(childComplexity int, input PerformerCreateInput) int PerformerDestroy func(childComplexity int, input PerformerDestroyInput) int @@ -783,7 +783,7 @@ type MutationResolver interface { DestroyDraft(ctx context.Context, id uuid.UUID) (bool, error) FavoritePerformer(ctx context.Context, id uuid.UUID, favorite bool) (bool, error) FavoriteStudio(ctx context.Context, id uuid.UUID, favorite bool) (bool, error) - MarkNotificationsRead(ctx context.Context) (bool, error) + MarkNotificationsRead(ctx context.Context, notification *MarkNotificationReadInput) (bool, error) UpdateNotificationSubscriptions(ctx context.Context, subscriptions []NotificationEnum) (bool, error) } type NotificationResolver interface { @@ -1732,7 +1732,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in break } - return e.complexity.Mutation.MarkNotificationsRead(childComplexity), true + args, err := ec.field_Mutation_markNotificationsRead_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.MarkNotificationsRead(childComplexity, args["notification"].(*MarkNotificationReadInput)), true case "Mutation.newUser": if e.complexity.Mutation.NewUser == nil { @@ -4520,6 +4525,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputImageDestroyInput, ec.unmarshalInputImageUpdateInput, ec.unmarshalInputIntCriterionInput, + ec.unmarshalInputMarkNotificationReadInput, ec.unmarshalInputMultiIDCriterionInput, ec.unmarshalInputMultiStringCriterionInput, ec.unmarshalInputNewUserInput, @@ -5055,6 +5061,11 @@ type QueryNotificationsResult { count: Int! notifications: [Notification!]! } + +input MarkNotificationReadInput { + type: NotificationEnum! + id: ID! +} `, BuiltIn: false}, {Name: "../../graphql/schema/types/performer.graphql", Input: `enum GenderEnum { MALE @@ -6413,7 +6424,7 @@ type Mutation { favoriteStudio(id: ID!, favorite: Boolean!): Boolean! @hasRole(role: READ) """Mark all of the current users notifications as read.""" - markNotificationsRead: Boolean! @hasRole(role: READ) + markNotificationsRead(notification: MarkNotificationReadInput): Boolean! @hasRole(role: READ) """Update notification subscriptions for current user.""" updateNotificationSubscriptions(subscriptions: [NotificationEnum!]!): Boolean! @hasRole(role: EDIT) } @@ -6964,6 +6975,38 @@ func (ec *executionContext) field_Mutation_imageDestroy_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_markNotificationsRead_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_markNotificationsRead_argsNotification(ctx, rawArgs) + if err != nil { + return nil, err + } + args["notification"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_markNotificationsRead_argsNotification( + ctx context.Context, + rawArgs map[string]interface{}, +) (*MarkNotificationReadInput, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["notification"] + if !ok { + var zeroVal *MarkNotificationReadInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("notification")) + if tmp, ok := rawArgs["notification"]; ok { + return ec.unmarshalOMarkNotificationReadInput2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐMarkNotificationReadInput(ctx, tmp) + } + + var zeroVal *MarkNotificationReadInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_newUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -18432,7 +18475,7 @@ func (ec *executionContext) _Mutation_markNotificationsRead(ctx context.Context, resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().MarkNotificationsRead(rctx) + return ec.resolvers.Mutation().MarkNotificationsRead(rctx, fc.Args["notification"].(*MarkNotificationReadInput)) } directive1 := func(ctx context.Context) (interface{}, error) { @@ -18475,7 +18518,7 @@ func (ec *executionContext) _Mutation_markNotificationsRead(ctx context.Context, return ec.marshalNBoolean2bool(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_markNotificationsRead(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Mutation_markNotificationsRead(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, @@ -18485,6 +18528,17 @@ func (ec *executionContext) fieldContext_Mutation_markNotificationsRead(_ contex return nil, errors.New("field of type Boolean does not have child fields") }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_markNotificationsRead_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -38133,6 +38187,40 @@ func (ec *executionContext) unmarshalInputIntCriterionInput(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputMarkNotificationReadInput(ctx context.Context, obj interface{}) (MarkNotificationReadInput, error) { + var it MarkNotificationReadInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"type", "id"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("type")) + data, err := ec.unmarshalNNotificationEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐNotificationEnum(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.ID = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputMultiIDCriterionInput(ctx context.Context, obj interface{}) (MultiIDCriterionInput, error) { var it MultiIDCriterionInput asMap := map[string]interface{}{} @@ -54865,6 +54953,14 @@ func (ec *executionContext) marshalOInviteKey2ᚕᚖgithubᚗcomᚋstashappᚋst return ret } +func (ec *executionContext) unmarshalOMarkNotificationReadInput2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐMarkNotificationReadInput(ctx context.Context, v interface{}) (*MarkNotificationReadInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputMarkNotificationReadInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOMultiIDCriterionInput2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐMultiIDCriterionInput(ctx context.Context, v interface{}) (*MultiIDCriterionInput, error) { if v == nil { return nil, nil diff --git a/pkg/models/generated_models.go b/pkg/models/generated_models.go index f1ebf0bc5..beb9e50de 100644 --- a/pkg/models/generated_models.go +++ b/pkg/models/generated_models.go @@ -291,6 +291,11 @@ type IntCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type MarkNotificationReadInput struct { + Type NotificationEnum `json:"type"` + ID uuid.UUID `json:"id"` +} + type Measurements struct { CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 41fd8f5cf..be3967bb1 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -5,7 +5,8 @@ import "github.com/gofrs/uuid" type NotificationRepo interface { GetNotifications(userID uuid.UUID, filter QueryNotificationsInput) ([]*Notification, error) GetNotificationsCount(userID uuid.UUID, filter QueryNotificationsInput) (int, error) - MarkRead(userID uuid.UUID) error + MarkRead(userID uuid.UUID, notificationType NotificationEnum, id uuid.UUID) error + MarkAllRead(userID uuid.UUID) error TriggerSceneCreationNotifications(sceneID uuid.UUID) error TriggerPerformerEditNotifications(editID uuid.UUID) error diff --git a/pkg/sqlx/querybuilder_notifications.go b/pkg/sqlx/querybuilder_notifications.go index 526d87c69..6f5fc2346 100644 --- a/pkg/sqlx/querybuilder_notifications.go +++ b/pkg/sqlx/querybuilder_notifications.go @@ -218,7 +218,8 @@ func (qb *notificationsQueryBuilder) GetNotificationsCount(userID uuid.UUID, fil func (qb *notificationsQueryBuilder) GetNotifications(userID uuid.UUID, filter models.QueryNotificationsInput) ([]*models.Notification, error) { query := buildQuery(userID, filter) query.Pagination = getPagination(filter.Page, filter.PerPage) - query.Sort = getSort("created_at", models.SortDirectionEnumDesc.String(), notificationDBTable.name, nil) + secondarySort := "type" + query.Sort = getSort("created_at", models.SortDirectionEnumDesc.String(), notificationDBTable.name, &secondarySort) var notifications models.Notifications _, err := qb.dbi.Query(*query, ¬ifications) @@ -230,11 +231,16 @@ func (qb *notificationsQueryBuilder) GetNotifications(userID uuid.UUID, filter m return notifications, nil } -func (qb *notificationsQueryBuilder) MarkRead(userID uuid.UUID) error { +func (qb *notificationsQueryBuilder) MarkAllRead(userID uuid.UUID) error { args := []interface{}{userID} return qb.dbi.RawExec("UPDATE notifications SET read_at = now() WHERE user_id = ? AND read_at IS NULL", args) } +func (qb *notificationsQueryBuilder) MarkRead(userID uuid.UUID, notificationType models.NotificationEnum, id uuid.UUID) error { + args := []interface{}{userID, notificationType, id} + return qb.dbi.RawExec("UPDATE notifications SET read_at = now() WHERE user_id = ? AND type = ? AND id = ? AND read_at IS NULL", args) +} + func buildQuery(userID uuid.UUID, filter models.QueryNotificationsInput) *queryBuilder { query := newQueryBuilder(notificationDBTable)