From a876384dcd7dc16bd69679c48abfdf9e5a8113e7 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Tue, 15 Oct 2024 14:20:00 +0100 Subject: [PATCH 1/8] Messing about with reactions --- app/models/entity/comment.go | 23 +++++--- app/models/entity/reaction.go | 12 +++++ app/services/sqlstore/postgres/comment.go | 53 +++++++++++++++---- .../202410122105_create_reactions_up.sql | 9 ++++ public/models/post.ts | 3 ++ .../pages/ShowPost/components/ShowComment.tsx | 9 ++++ 6 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 app/models/entity/reaction.go create mode 100644 migrations/202410122105_create_reactions_up.sql diff --git a/app/models/entity/comment.go b/app/models/entity/comment.go index 43be67fdd..12912b488 100644 --- a/app/models/entity/comment.go +++ b/app/models/entity/comment.go @@ -2,13 +2,20 @@ package entity import "time" -//Comment represents an user comment on an post +type ReactionCounts struct { + Emoji string `json:"emoji"` + Count int `json:"count"` +} + +// Comment represents an user comment on an post type Comment struct { - ID int `json:"id"` - Content string `json:"content"` - CreatedAt time.Time `json:"createdAt"` - User *User `json:"user"` - Attachments []string `json:"attachments,omitempty"` - EditedAt *time.Time `json:"editedAt,omitempty"` - EditedBy *User `json:"editedBy,omitempty"` + ID int `json:"id"` + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + User *User `json:"user"` + Attachments []string `json:"attachments,omitempty"` + EditedAt *time.Time `json:"editedAt,omitempty"` + EditedBy *User `json:"editedBy,omitempty"` + ReactionCounts map[string]int `json:"reactionCounts,omitempty"` + Reactions string `json:"reactions,omitempty"` } diff --git a/app/models/entity/reaction.go b/app/models/entity/reaction.go new file mode 100644 index 000000000..3fbbd5a61 --- /dev/null +++ b/app/models/entity/reaction.go @@ -0,0 +1,12 @@ +package entity + +import "time" + +// Reaction represents a user's emoji reaction to a comment +type Reaction struct { + ID int `json:"id"` + Emoji string `json:"emoji"` + Comment *Comment `json:"-"` + User *User `json:"user"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index 868eef81e..84163a521 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "time" "github.com/getfider/fider/app/models/cmd" @@ -11,14 +12,21 @@ import ( "github.com/getfider/fider/app/pkg/errors" ) +type ReactionCounts struct { + Emoji string `db:"emoji"` + Count int `db:"count"` +} + type dbComment struct { - ID int `db:"id"` - Content string `db:"content"` - CreatedAt time.Time `db:"created_at"` - User *dbUser `db:"user"` - Attachments []string `db:"attachment_bkeys"` - EditedAt dbx.NullTime `db:"edited_at"` - EditedBy *dbUser `db:"edited_by"` + ID int `db:"id"` + Content string `db:"content"` + CreatedAt time.Time `db:"created_at"` + User *dbUser `db:"user"` + Attachments []string `db:"attachment_bkeys"` + EditedAt dbx.NullTime `db:"edited_at"` + EditedBy *dbUser `db:"edited_by"` + ReactionCounts dbx.NullString `db:"reaction_counts"` + // Reactions dbx.NullString `db:"reactions"` } func (c *dbComment) toModel(ctx context.Context) *entity.Comment { @@ -28,11 +36,22 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment { CreatedAt: c.CreatedAt, User: c.User.toModel(ctx), Attachments: c.Attachments, + // Reactions: c.Reactions.String, } if c.EditedAt.Valid { comment.EditedBy = c.EditedBy.toModel(ctx) comment.EditedAt = &c.EditedAt.Time } + + comment.ReactionCounts = make(map[string]int) + if c.ReactionCounts.Valid { + var reactionCounts map[string]int + err := json.Unmarshal([]byte(c.ReactionCounts.String), &reactionCounts) + if err == nil { + comment.ReactionCounts = reactionCounts + } + } + return comment } @@ -131,7 +150,8 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { comments := []*dbComment{} err := trx.Select(&comments, - `WITH agg_attachments AS ( + ` + WITH agg_attachments AS ( SELECT c.id as comment_id, ARRAY_REMOVE(ARRAY_AGG(at.attachment_bkey), NULL) as attachment_bkeys @@ -144,6 +164,18 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { AND at.tenant_id = $2 AND at.comment_id IS NOT NULL GROUP BY c.id + ), + agg_reactions AS ( + SELECT + comment_id, + json_object_agg(emoji, count) as reaction_counts + FROM ( + SELECT comment_id, emoji, COUNT(*) as count + FROM reactions + WHERE comment_id IN (SELECT id FROM comments WHERE post_id = $1) + GROUP BY comment_id, emoji + ) r + GROUP BY comment_id ) SELECT c.id, c.content, @@ -163,7 +195,8 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { e.status AS edited_by_status, e.avatar_type AS edited_by_avatar_type, e.avatar_bkey AS edited_by_avatar_bkey, - at.attachment_bkeys + at.attachment_bkeys, + ar.reaction_counts FROM comments c INNER JOIN posts p ON p.id = c.post_id @@ -176,6 +209,8 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { AND e.tenant_id = c.tenant_id LEFT JOIN agg_attachments at ON at.comment_id = c.id + LEFT JOIN agg_reactions ar + ON ar.comment_id = c.id WHERE p.id = $1 AND p.tenant_id = $2 AND c.deleted_at IS NULL diff --git a/migrations/202410122105_create_reactions_up.sql b/migrations/202410122105_create_reactions_up.sql new file mode 100644 index 000000000..7f3307f55 --- /dev/null +++ b/migrations/202410122105_create_reactions_up.sql @@ -0,0 +1,9 @@ +create table if not exists reactions ( + id serial primary key, + emoji varchar(8) not null, + comment_id int not null, + user_id int not null, + created_on timestamptz not null, + foreign key (comment_id) references comments(id), + foreign key (user_id) references users(id) +); diff --git a/public/models/post.ts b/public/models/post.ts index 57bc2b372..efc454580 100644 --- a/public/models/post.ts +++ b/public/models/post.ts @@ -57,6 +57,9 @@ export interface Comment { createdAt: string user: User attachments?: string[] + reactionCounts: { + [key: string]: number + } editedAt?: string editedBy?: User } diff --git a/public/pages/ShowPost/components/ShowComment.tsx b/public/pages/ShowPost/components/ShowComment.tsx index 429ee42e2..94349bb0d 100644 --- a/public/pages/ShowPost/components/ShowComment.tsx +++ b/public/pages/ShowPost/components/ShowComment.tsx @@ -178,6 +178,15 @@ export const ShowComment = (props: ShowCommentProps) => { <> {comment.attachments && comment.attachments.map((x) => )} + {comment.reactionCounts !== undefined && ( +
+ {Object.entries(comment.reactionCounts).map(([emoji, count]) => ( + + {emoji} {count} + + ))} +
+ )} )} From e1a6ebdef6d485c90bc7b68b30529ee7811f7684 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Thu, 17 Oct 2024 21:45:07 +0100 Subject: [PATCH 2/8] Working reactions UI and backend --- app/actions/post.go | 17 ++++ app/cmd/routes.go | 1 + app/handlers/apiv1/post.go | 28 ++++++ app/handlers/apiv1/post_test.go | 95 ++++++++++++++++--- app/models/cmd/reaction.go | 10 ++ app/models/entity/comment.go | 22 ++--- app/services/sqlstore/postgres/comment.go | 61 ++++++++---- app/services/sqlstore/postgres/common_test.go | 49 ++++++++++ app/services/sqlstore/postgres/postgres.go | 1 + .../202410122105_create_reactions_up.sql | 2 + public/assets/images/reaction-add.svg | 6 ++ public/assets/styles/utility/display.scss | 4 + public/components/Reactions.scss | 20 ++++ public/components/Reactions.tsx | 81 ++++++++++++++++ public/components/index.tsx | 1 + public/models/post.ts | 10 +- .../pages/ShowPost/components/ShowComment.tsx | 57 +++++++++-- public/services/actions/post.ts | 7 ++ 18 files changed, 416 insertions(+), 56 deletions(-) create mode 100644 app/models/cmd/reaction.go create mode 100644 public/assets/images/reaction-add.svg create mode 100644 public/components/Reactions.scss create mode 100644 public/components/Reactions.tsx diff --git a/app/actions/post.go b/app/actions/post.go index fa7ba65a7..1ee204788 100644 --- a/app/actions/post.go +++ b/app/actions/post.go @@ -133,6 +133,23 @@ func (action *UpdatePost) Validate(ctx context.Context, user *entity.User) *vali return result } +type ToggleCommentReaction struct { + Number int `route:"number"` + Comment int `route:"id"` + Reaction string `route:"reaction"` +} + +// IsAuthorized returns true if current user is authorized to perform this action +func (action *ToggleCommentReaction) IsAuthorized(ctx context.Context, user *entity.User) bool { + return user != nil +} + +// Validate if current model is valid +func (action *ToggleCommentReaction) Validate(ctx context.Context, user *entity.User) *validate.Result { + result := validate.Success() + return result +} + // AddNewComment represents a new comment to be added type AddNewComment struct { Number int `route:"number"` diff --git a/app/cmd/routes.go b/app/cmd/routes.go index 87da777f7..7744caa62 100644 --- a/app/cmd/routes.go +++ b/app/cmd/routes.go @@ -196,6 +196,7 @@ func routes(r *web.Engine) *web.Engine { membersApi.Post("/api/v1/posts", apiv1.CreatePost()) membersApi.Put("/api/v1/posts/:number", apiv1.UpdatePost()) + membersApi.Post("/api/v1/posts/:number/comments/:id/reactions/:reaction", apiv1.ToggleReaction()) membersApi.Post("/api/v1/posts/:number/comments", apiv1.PostComment()) membersApi.Put("/api/v1/posts/:number/comments/:id", apiv1.UpdateComment()) membersApi.Delete("/api/v1/posts/:number/comments/:id", apiv1.DeleteComment()) diff --git a/app/handlers/apiv1/post.go b/app/handlers/apiv1/post.go index bf33153ef..3718315ff 100644 --- a/app/handlers/apiv1/post.go +++ b/app/handlers/apiv1/post.go @@ -218,6 +218,34 @@ func GetComment() web.HandlerFunc { } } +// ToggleReaction adds or removes a reaction on a comment +func ToggleReaction() web.HandlerFunc { + return func(c *web.Context) error { + action := new(actions.ToggleCommentReaction) + if result := c.BindTo(action); !result.Ok { + return c.HandleValidation(result) + } + + getComment := &query.GetCommentByID{CommentID: action.Comment} + if err := bus.Dispatch(c, getComment); err != nil { + return c.Failure(err) + } + + toggleReaction := &cmd.ToggleCommentReaction{ + Comment: getComment.Result, + Emoji: action.Reaction, + User: c.User(), + } + if err := bus.Dispatch(c, toggleReaction); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{ + "added": toggleReaction.Result, + }) + } +} + // PostComment creates a new comment on given post func PostComment() web.HandlerFunc { return func(c *web.Context) error { diff --git a/app/handlers/apiv1/post_test.go b/app/handlers/apiv1/post_test.go index 72e9c9572..9790e537b 100644 --- a/app/handlers/apiv1/post_test.go +++ b/app/handlers/apiv1/post_test.go @@ -124,11 +124,11 @@ func TestUpdatePostHandler_NonAuthorized(t *testing.T) { RegisterT(t) post := &entity.Post{ - ID: 5, - Number: 5, - Title: "My First Post", + ID: 5, + Number: 5, + Title: "My First Post", Description: "Such an amazing description", - User: mock.JonSnow, + User: mock.JonSnow, } bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error { if q.Number == post.Number { @@ -151,12 +151,12 @@ func TestUpdatePostHandler_IsOwner_AfterGracePeriod(t *testing.T) { RegisterT(t) post := &entity.Post{ - ID: 5, - Number: 5, - Title: "My First Post", + ID: 5, + Number: 5, + Title: "My First Post", Description: "Such an amazing description", - User: mock.AryaStark, - CreatedAt: time.Now().UTC().Add(-2 * time.Hour), + User: mock.AryaStark, + CreatedAt: time.Now().UTC().Add(-2 * time.Hour), } bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error { if q.Number == post.Number { @@ -175,17 +175,16 @@ func TestUpdatePostHandler_IsOwner_AfterGracePeriod(t *testing.T) { Expect(code).Equals(http.StatusForbidden) } - func TestUpdatePostHandler_IsOwner_WithinGracePeriod(t *testing.T) { RegisterT(t) post := &entity.Post{ - ID: 5, - Number: 5, - Title: "My First Post", + ID: 5, + Number: 5, + Title: "My First Post", Description: "Such an amazing description", - User: mock.AryaStark, - CreatedAt: time.Now().UTC(), + User: mock.AryaStark, + CreatedAt: time.Now().UTC(), } bus.AddHandler(func(ctx context.Context, q *query.GetPostByNumber) error { if q.Number == post.Number { @@ -654,3 +653,69 @@ func TestListCommentHandler(t *testing.T) { Expect(query.IsArray()).IsTrue() Expect(query.ArrayLength()).Equals(2) } + +func TestCommentReactionToggleHandler(t *testing.T) { + RegisterT(t) + + comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark} + + bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error { + q.Result = comment + return nil + }) + + testCases := []struct { + name string + user *entity.User + reaction string + }{ + {"JonSnow reacts with like", mock.JonSnow, "like"}, + {"AryaStark reacts with smile", mock.AryaStark, "smile"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var toggleReaction *cmd.ToggleCommentReaction + bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error { + toggleReaction = c + return nil + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(tc.user). + AddParam("number", 1). + AddParam("id", comment.ID). + AddParam("reaction", tc.reaction). + ExecutePost(apiv1.ToggleReaction(), ``) + + Expect(code).Equals(http.StatusOK) + Expect(toggleReaction.Emoji).Equals(tc.reaction) + Expect(toggleReaction.Comment).Equals(comment) + Expect(toggleReaction.User).Equals(tc.user) + }) + } +} + +func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) { + RegisterT(t) + + comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark} + bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error { + q.Result = comment + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error { + return nil + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AddParam("number", 1). + AddParam("id", comment.ID). + AddParam("reaction", "like"). + ExecutePost(apiv1.ToggleReaction(), ``) + + Expect(code).Equals(http.StatusForbidden) +} diff --git a/app/models/cmd/reaction.go b/app/models/cmd/reaction.go new file mode 100644 index 000000000..36782deb9 --- /dev/null +++ b/app/models/cmd/reaction.go @@ -0,0 +1,10 @@ +package cmd + +import "github.com/getfider/fider/app/models/entity" + +type ToggleCommentReaction struct { + Comment *entity.Comment + Emoji string + User *entity.User + Result bool +} diff --git a/app/models/entity/comment.go b/app/models/entity/comment.go index 12912b488..b0ef76b98 100644 --- a/app/models/entity/comment.go +++ b/app/models/entity/comment.go @@ -3,19 +3,19 @@ package entity import "time" type ReactionCounts struct { - Emoji string `json:"emoji"` - Count int `json:"count"` + Emoji string `json:"emoji"` + Count int `json:"count"` + IncludesMe bool `json:"includesMe"` } // Comment represents an user comment on an post type Comment struct { - ID int `json:"id"` - Content string `json:"content"` - CreatedAt time.Time `json:"createdAt"` - User *User `json:"user"` - Attachments []string `json:"attachments,omitempty"` - EditedAt *time.Time `json:"editedAt,omitempty"` - EditedBy *User `json:"editedBy,omitempty"` - ReactionCounts map[string]int `json:"reactionCounts,omitempty"` - Reactions string `json:"reactions,omitempty"` + ID int `json:"id"` + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + User *User `json:"user"` + Attachments []string `json:"attachments,omitempty"` + EditedAt *time.Time `json:"editedAt,omitempty"` + EditedBy *User `json:"editedBy,omitempty"` + ReactionCounts []ReactionCounts `json:"reactionCounts,omitempty"` } diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index 84163a521..3bbbf6f98 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -12,11 +12,6 @@ import ( "github.com/getfider/fider/app/pkg/errors" ) -type ReactionCounts struct { - Emoji string `db:"emoji"` - Count int `db:"count"` -} - type dbComment struct { ID int `db:"id"` Content string `db:"content"` @@ -26,7 +21,6 @@ type dbComment struct { EditedAt dbx.NullTime `db:"edited_at"` EditedBy *dbUser `db:"edited_by"` ReactionCounts dbx.NullString `db:"reaction_counts"` - // Reactions dbx.NullString `db:"reactions"` } func (c *dbComment) toModel(ctx context.Context) *entity.Comment { @@ -36,22 +30,15 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment { CreatedAt: c.CreatedAt, User: c.User.toModel(ctx), Attachments: c.Attachments, - // Reactions: c.Reactions.String, } if c.EditedAt.Valid { comment.EditedBy = c.EditedBy.toModel(ctx) comment.EditedAt = &c.EditedAt.Time } - comment.ReactionCounts = make(map[string]int) if c.ReactionCounts.Valid { - var reactionCounts map[string]int - err := json.Unmarshal([]byte(c.ReactionCounts.String), &reactionCounts) - if err == nil { - comment.ReactionCounts = reactionCounts - } + json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) } - return comment } @@ -76,6 +63,38 @@ func addNewComment(ctx context.Context, c *cmd.AddNewComment) error { }) } +func toggleCommentReaction(ctx context.Context, c *cmd.ToggleCommentReaction) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + var added bool + err := trx.Scalar(&added, ` + WITH toggle_reaction AS ( + INSERT INTO reactions (comment_id, user_id, emoji, created_on) + VALUES ($1, $2, $3, $4) + ON CONFLICT (comment_id, user_id, emoji) DO NOTHING + RETURNING true AS added + ), + delete_existing AS ( + DELETE FROM reactions + WHERE comment_id = $1 AND user_id = $2 AND emoji = $3 + AND NOT EXISTS (SELECT 1 FROM toggle_reaction) + RETURNING false AS added + ) + SELECT COALESCE( + (SELECT added FROM toggle_reaction), + (SELECT added FROM delete_existing), + false + ) + `, c.Comment.ID, user.ID, c.Emoji, time.Now()) + + if err != nil { + return errors.Wrap(err, "failed to toggle reaction") + } + + c.Result = added + return nil + }) +} + func updateComment(ctx context.Context, c *cmd.UpdateComment) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { _, err := trx.Execute(` @@ -168,9 +187,17 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { agg_reactions AS ( SELECT comment_id, - json_object_agg(emoji, count) as reaction_counts + json_agg(json_build_object( + 'emoji', emoji, + 'count', count, + 'includesMe', CASE WHEN $3 = ANY(user_ids) THEN true ELSE false END + )) as reaction_counts FROM ( - SELECT comment_id, emoji, COUNT(*) as count + SELECT + comment_id, + emoji, + COUNT(*) as count, + array_agg(user_id) as user_ids FROM reactions WHERE comment_id IN (SELECT id FROM comments WHERE post_id = $1) GROUP BY comment_id, emoji @@ -214,7 +241,7 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { WHERE p.id = $1 AND p.tenant_id = $2 AND c.deleted_at IS NULL - ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID) + ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID, user.ID) if err != nil { return errors.Wrap(err, "failed get comments of post with id '%d'", q.Post.ID) } diff --git a/app/services/sqlstore/postgres/common_test.go b/app/services/sqlstore/postgres/common_test.go index 15ffee531..078658b3e 100644 --- a/app/services/sqlstore/postgres/common_test.go +++ b/app/services/sqlstore/postgres/common_test.go @@ -6,8 +6,11 @@ import ( "github.com/getfider/fider/app" + "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/query" . "github.com/getfider/fider/app/pkg/assert" + "github.com/getfider/fider/app/pkg/bus" "github.com/getfider/fider/app/services/sqlstore/postgres" ) @@ -45,3 +48,49 @@ func withUser(ctx context.Context, user *entity.User) context.Context { ctx = context.WithValue(ctx, app.UserCtxKey, user) return ctx } + +func TestToggleReaction_Add(t *testing.T) { + SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + newPost := &cmd.AddNewPost{Title: "My new post", Description: "with this description"} + err := bus.Dispatch(jonSnowCtx, newPost) + Expect(err).IsNil() + + newComment := &cmd.AddNewComment{Post: newPost.Result, Content: "This is my comment"} + err = bus.Dispatch(jonSnowCtx, newComment) + Expect(err).IsNil() + + // Now add a reaction + reaction := &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} + err = bus.Dispatch(jonSnowCtx, reaction) + Expect(err).IsNil() + Expect(reaction.Result).IsTrue() + + // Get the comment, and check that the reaction was added + commentByID := &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} + err = bus.Dispatch(jonSnowCtx, commentByID) + Expect(err).IsNil() + + Expect(commentByID.Result).IsNotNil() + Expect(commentByID.Result[0].ReactionCounts).IsNotNil() + Expect(len(commentByID.Result[0].ReactionCounts)).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].Emoji).Equals("👍") + Expect(commentByID.Result[0].ReactionCounts[0].Count).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].IncludesMe).IsTrue() + + // Now remove the reaction + reaction = &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} + err = bus.Dispatch(jonSnowCtx, reaction) + Expect(err).IsNil() + Expect(reaction.Result).IsFalse() + + // Get the comment, and check that the reaction was removed + commentByID = &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} + err = bus.Dispatch(jonSnowCtx, commentByID) + Expect(err).IsNil() + + Expect(commentByID.Result).IsNotNil() + Expect(commentByID.Result[0].ReactionCounts).IsNil() + +} diff --git a/app/services/sqlstore/postgres/postgres.go b/app/services/sqlstore/postgres/postgres.go index 0f64e4709..8d0f3103a 100644 --- a/app/services/sqlstore/postgres/postgres.go +++ b/app/services/sqlstore/postgres/postgres.go @@ -76,6 +76,7 @@ func (s Service) Init() { bus.AddHandler(addNewComment) bus.AddHandler(updateComment) + bus.AddHandler(toggleCommentReaction) bus.AddHandler(deleteComment) bus.AddHandler(getCommentByID) bus.AddHandler(getCommentsByPost) diff --git a/migrations/202410122105_create_reactions_up.sql b/migrations/202410122105_create_reactions_up.sql index 7f3307f55..ca8fb6e72 100644 --- a/migrations/202410122105_create_reactions_up.sql +++ b/migrations/202410122105_create_reactions_up.sql @@ -7,3 +7,5 @@ create table if not exists reactions ( foreign key (comment_id) references comments(id), foreign key (user_id) references users(id) ); + +ALTER TABLE reactions ADD CONSTRAINT unique_reaction UNIQUE (comment_id, user_id, emoji); diff --git a/public/assets/images/reaction-add.svg b/public/assets/images/reaction-add.svg new file mode 100644 index 000000000..5761c7e7f --- /dev/null +++ b/public/assets/images/reaction-add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/styles/utility/display.scss b/public/assets/styles/utility/display.scss index 814c5db7e..2bb8b2a2d 100644 --- a/public/assets/styles/utility/display.scss +++ b/public/assets/styles/utility/display.scss @@ -10,6 +10,10 @@ position: relative; } +.absolute { + position: absolute; +} + .inline-block { display: inline-block; } diff --git a/public/components/Reactions.scss b/public/components/Reactions.scss new file mode 100644 index 000000000..056f154b1 --- /dev/null +++ b/public/components/Reactions.scss @@ -0,0 +1,20 @@ +@import "~@fider/assets/styles/variables.scss"; + +.c-reactions { + + &-add-reaction { + svg { + position: relative; + top:1px; + left:0; + } + } + + &-emojis { + top: -30px; + } + + button { + background-color: 'pink'; + } +} \ No newline at end of file diff --git a/public/components/Reactions.tsx b/public/components/Reactions.tsx new file mode 100644 index 000000000..a06a0233e --- /dev/null +++ b/public/components/Reactions.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react" +import { ReactionCount } from "@fider/models" +import { Icon } from "@fider/components" +import ReactionAdd from "@fider/assets/images/reaction-add.svg" +import { HStack } from "@fider/components/layout" +import { useFider } from "@fider/hooks" +import "./Reactions.scss" + +interface ReactionsProps { + emojiSelectorRef: React.RefObject + toggleReaction: (emoji: string) => void + reactions?: ReactionCount[] +} + +const availableEmojis = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"] + +export const Reactions: React.FC = ({ emojiSelectorRef, toggleReaction, reactions }) => { + const fider = useFider() + const [isEmojiSelectorOpen, setIsEmojiSelectorOpen] = useState(false) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (emojiSelectorRef.current && !emojiSelectorRef.current.contains(event.target as Node)) { + setIsEmojiSelectorOpen(false) + } + } + + document.addEventListener("click", handleClickOutside) + return () => { + document.removeEventListener("click", handleClickOutside) + } + }, []) + + return ( +
+ + {fider.session.isAuthenticated && ( + <> + setIsEmojiSelectorOpen(!isEmojiSelectorOpen)} + className="c-reactions-add-reaction relative text-gray-600 clickable inline-flex items-center px-1 py-1 rounded-full text-xs bg-blue-100 hover:bg-blue-200" + > + + + {isEmojiSelectorOpen && ( + + )} + + )} + {reactions !== undefined && ( + <> + {reactions.map((reaction) => ( + toggleReaction(reaction.emoji)} + className={`clickable inline-flex items-center px-2 py-1 rounded-full text-xs ${ + reaction.includesMe ? "bg-blue-100 hover:bg-blue-200" : "bg-gray-100 hover:bg-gray-200" + }`} + > + {reaction.emoji} {reaction.count} + + ))} + + )} + +
+ ) +} diff --git a/public/components/index.tsx b/public/components/index.tsx index c4fc4afce..0e5109b29 100644 --- a/public/components/index.tsx +++ b/public/components/index.tsx @@ -7,5 +7,6 @@ export * from "./SignInModal" export * from "./VoteCounter" export * from "./NotificationIndicator" export * from "./UserMenu" +export * from "./Reactions" export * from "./ReadOnlyNotice" export * from "./common" diff --git a/public/models/post.ts b/public/models/post.ts index efc454580..0f12c52af 100644 --- a/public/models/post.ts +++ b/public/models/post.ts @@ -51,15 +51,19 @@ export interface PostResponse { } } +export interface ReactionCount { + emoji: string + count: number + includesMe: boolean +} + export interface Comment { id: number content: string createdAt: string user: User attachments?: string[] - reactionCounts: { - [key: string]: number - } + reactionCounts?: ReactionCount[] editedAt?: string editedBy?: User } diff --git a/public/pages/ShowPost/components/ShowComment.tsx b/public/pages/ShowPost/components/ShowComment.tsx index 94349bb0d..2ef8b8ad9 100644 --- a/public/pages/ShowPost/components/ShowComment.tsx +++ b/public/pages/ShowPost/components/ShowComment.tsx @@ -1,6 +1,20 @@ import React, { useEffect, useRef, useState } from "react" import { Comment, Post, ImageUpload } from "@fider/models" -import { Avatar, UserName, Moment, Form, TextArea, Button, Markdown, Modal, ImageViewer, MultiImageUploader, Dropdown, Icon } from "@fider/components" +import { + Reactions, + Avatar, + UserName, + Moment, + Form, + TextArea, + Button, + Markdown, + Modal, + ImageViewer, + MultiImageUploader, + Dropdown, + Icon, +} from "@fider/components" import { HStack } from "@fider/components/layout" import { formatDate, Failure, actions, notify, copyToClipboard, classSet, clearUrlHash } from "@fider/services" import { useFider } from "@fider/hooks" @@ -11,6 +25,7 @@ interface ShowCommentProps { post: Post comment: Comment highlighted?: boolean + onToggleReaction?: () => void } export const ShowComment = (props: ShowCommentProps) => { @@ -20,6 +35,9 @@ export const ShowComment = (props: ShowCommentProps) => { const [newContent, setNewContent] = useState("") const [isDeleteConfirmationModalOpen, setIsDeleteConfirmationModalOpen] = useState(false) const [attachments, setAttachments] = useState([]) + const [localReactionCounts, setLocalReactionCounts] = useState(props.comment.reactionCounts) + const emojiSelectorRef = useRef(null) + const [error, setError] = useState() const handleClick = (e: MouseEvent) => { @@ -72,6 +90,33 @@ export const ShowComment = (props: ShowCommentProps) => { } } + const toggleReaction = async (emoji: string) => { + const response = await actions.toggleCommentReaction(props.post.number, comment.id, emoji) + if (response.ok) { + const added = response.data.added + + setLocalReactionCounts((prevCounts) => { + const newCounts = [...(prevCounts ?? [])] + const reactionIndex = newCounts.findIndex((r) => r.emoji === emoji) + if (reactionIndex !== -1) { + const newCount = added ? newCounts[reactionIndex].count + 1 : newCounts[reactionIndex].count - 1 + if (newCount === 0) { + newCounts.splice(reactionIndex, 1) + } else { + newCounts[reactionIndex] = { + ...newCounts[reactionIndex], + count: newCount, + includesMe: added, + } + } + } else if (added) { + newCounts.push({ emoji, count: 1, includesMe: true }) + } + return newCounts + }) + } + } + const onActionSelected = (action: string) => () => { if (action === "copylink") { window.location.hash = `#comment-${props.comment.id}` @@ -178,15 +223,7 @@ export const ShowComment = (props: ShowCommentProps) => { <> {comment.attachments && comment.attachments.map((x) => )} - {comment.reactionCounts !== undefined && ( -
- {Object.entries(comment.reactionCounts).map(([emoji, count]) => ( - - {emoji} {count} - - ))} -
- )} + )} diff --git a/public/services/actions/post.ts b/public/services/actions/post.ts index 0c4d3bdf4..39b382565 100644 --- a/public/services/actions/post.ts +++ b/public/services/actions/post.ts @@ -62,6 +62,13 @@ export const updateComment = async (postNumber: number, commentID: number, conte export const deleteComment = async (postNumber: number, commentID: number): Promise => { return http.delete(`/api/v1/posts/${postNumber}/comments/${commentID}`).then(http.event("comment", "delete")) } +interface ToggleReactionResponse { + added: boolean +} + +export const toggleCommentReaction = async (postNumber: number, commentID: number, emoji: string): Promise> => { + return http.post(`/api/v1/posts/${postNumber}/comments/${commentID}/reactions/${emoji}`) +} interface SetResponseInput { status: string From bb947ddf1bc30d1c8ee18077a218e0cd001dfc5e Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Thu, 17 Oct 2024 21:55:04 +0100 Subject: [PATCH 3/8] Lint checks --- app/services/sqlstore/postgres/comment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index 3bbbf6f98..f2deb495b 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -37,7 +37,7 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment { } if c.ReactionCounts.Valid { - json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) + _ = json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) } return comment } From eb04f98cb145da0ab273b1dec6739fe8eb792f63 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Fri, 18 Oct 2024 14:05:31 +0100 Subject: [PATCH 4/8] Viewing reactions for anonymous users --- app/services/sqlstore/postgres/comment.go | 6 +- app/services/sqlstore/postgres/common_test.go | 49 ------------ app/services/sqlstore/postgres/post_test.go | 77 +++++++++++++++++++ public/components/Reactions.tsx | 13 +++- 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index f2deb495b..74e238794 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -168,6 +168,10 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { q.Result = make([]*entity.Comment, 0) comments := []*dbComment{} + userId := 0 + if user != nil { + userId = user.ID + } err := trx.Select(&comments, ` WITH agg_attachments AS ( @@ -241,7 +245,7 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { WHERE p.id = $1 AND p.tenant_id = $2 AND c.deleted_at IS NULL - ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID, user.ID) + ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID, userId) if err != nil { return errors.Wrap(err, "failed get comments of post with id '%d'", q.Post.ID) } diff --git a/app/services/sqlstore/postgres/common_test.go b/app/services/sqlstore/postgres/common_test.go index 078658b3e..15ffee531 100644 --- a/app/services/sqlstore/postgres/common_test.go +++ b/app/services/sqlstore/postgres/common_test.go @@ -6,11 +6,8 @@ import ( "github.com/getfider/fider/app" - "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/models/entity" - "github.com/getfider/fider/app/models/query" . "github.com/getfider/fider/app/pkg/assert" - "github.com/getfider/fider/app/pkg/bus" "github.com/getfider/fider/app/services/sqlstore/postgres" ) @@ -48,49 +45,3 @@ func withUser(ctx context.Context, user *entity.User) context.Context { ctx = context.WithValue(ctx, app.UserCtxKey, user) return ctx } - -func TestToggleReaction_Add(t *testing.T) { - SetupDatabaseTest(t) - defer TeardownDatabaseTest() - - newPost := &cmd.AddNewPost{Title: "My new post", Description: "with this description"} - err := bus.Dispatch(jonSnowCtx, newPost) - Expect(err).IsNil() - - newComment := &cmd.AddNewComment{Post: newPost.Result, Content: "This is my comment"} - err = bus.Dispatch(jonSnowCtx, newComment) - Expect(err).IsNil() - - // Now add a reaction - reaction := &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} - err = bus.Dispatch(jonSnowCtx, reaction) - Expect(err).IsNil() - Expect(reaction.Result).IsTrue() - - // Get the comment, and check that the reaction was added - commentByID := &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} - err = bus.Dispatch(jonSnowCtx, commentByID) - Expect(err).IsNil() - - Expect(commentByID.Result).IsNotNil() - Expect(commentByID.Result[0].ReactionCounts).IsNotNil() - Expect(len(commentByID.Result[0].ReactionCounts)).Equals(1) - Expect(commentByID.Result[0].ReactionCounts[0].Emoji).Equals("👍") - Expect(commentByID.Result[0].ReactionCounts[0].Count).Equals(1) - Expect(commentByID.Result[0].ReactionCounts[0].IncludesMe).IsTrue() - - // Now remove the reaction - reaction = &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} - err = bus.Dispatch(jonSnowCtx, reaction) - Expect(err).IsNil() - Expect(reaction.Result).IsFalse() - - // Get the comment, and check that the reaction was removed - commentByID = &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} - err = bus.Dispatch(jonSnowCtx, commentByID) - Expect(err).IsNil() - - Expect(commentByID.Result).IsNotNil() - Expect(commentByID.Result[0].ReactionCounts).IsNil() - -} diff --git a/app/services/sqlstore/postgres/post_test.go b/app/services/sqlstore/postgres/post_test.go index 554f38e42..81f2ab7f4 100644 --- a/app/services/sqlstore/postgres/post_test.go +++ b/app/services/sqlstore/postgres/post_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/getfider/fider/app/models/dto" + "github.com/getfider/fider/app/models/entity" "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" @@ -651,3 +652,79 @@ func TestPostStorage_Attachments(t *testing.T) { Expect(err).IsNil() Expect(getAttachments1.Result).HasLen(0) } + +func TestToggleReaction_Add(t *testing.T) { + SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + newPost := &cmd.AddNewPost{Title: "My new post", Description: "with this description"} + err := bus.Dispatch(jonSnowCtx, newPost) + Expect(err).IsNil() + + newComment := &cmd.AddNewComment{Post: newPost.Result, Content: "This is my comment"} + err = bus.Dispatch(jonSnowCtx, newComment) + Expect(err).IsNil() + + // Now add a reaction + reaction := &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} + err = bus.Dispatch(jonSnowCtx, reaction) + Expect(err).IsNil() + Expect(reaction.Result).IsTrue() + + // Get the comment, and check that the reaction was added + commentByID := &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} + err = bus.Dispatch(jonSnowCtx, commentByID) + Expect(err).IsNil() + + Expect(commentByID.Result).IsNotNil() + Expect(commentByID.Result[0].ReactionCounts).IsNotNil() + Expect(len(commentByID.Result[0].ReactionCounts)).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].Emoji).Equals("👍") + Expect(commentByID.Result[0].ReactionCounts[0].Count).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].IncludesMe).IsTrue() + + // Now remove the reaction + reaction = &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} + err = bus.Dispatch(jonSnowCtx, reaction) + Expect(err).IsNil() + Expect(reaction.Result).IsFalse() + + // Get the comment, and check that the reaction was removed + commentByID = &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} + err = bus.Dispatch(jonSnowCtx, commentByID) + Expect(err).IsNil() + + Expect(commentByID.Result).IsNotNil() + Expect(commentByID.Result[0].ReactionCounts).IsNil() +} + +func TestViewReactions_AnonymousUser(t *testing.T) { + SetupDatabaseTest(t) + defer TeardownDatabaseTest() + + newPost := &cmd.AddNewPost{Title: "My new post", Description: "with this description"} + err := bus.Dispatch(jonSnowCtx, newPost) + Expect(err).IsNil() + + newComment := &cmd.AddNewComment{Post: newPost.Result, Content: "This is my comment"} + err = bus.Dispatch(jonSnowCtx, newComment) + Expect(err).IsNil() + + // Now add a reaction + reaction := &cmd.ToggleCommentReaction{Comment: newComment.Result, Emoji: "👍", User: jonSnow} + err = bus.Dispatch(jonSnowCtx, reaction) + Expect(err).IsNil() + Expect(reaction.Result).IsTrue() + + // Get the comment as an anonymous user, and check that the reaction was added + commentByID := &query.GetCommentsByPost{Post: &entity.Post{ID: newPost.Result.ID}} + err = bus.Dispatch(demoTenantCtx, commentByID) + Expect(err).IsNil() + + Expect(commentByID.Result).IsNotNil() + Expect(commentByID.Result[0].ReactionCounts).IsNotNil() + Expect(len(commentByID.Result[0].ReactionCounts)).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].Emoji).Equals("👍") + Expect(commentByID.Result[0].ReactionCounts[0].Count).Equals(1) + Expect(commentByID.Result[0].ReactionCounts[0].IncludesMe).IsFalse() +} diff --git a/public/components/Reactions.tsx b/public/components/Reactions.tsx index a06a0233e..613030a75 100644 --- a/public/components/Reactions.tsx +++ b/public/components/Reactions.tsx @@ -3,6 +3,7 @@ import { ReactionCount } from "@fider/models" import { Icon } from "@fider/components" import ReactionAdd from "@fider/assets/images/reaction-add.svg" import { HStack } from "@fider/components/layout" +import { classSet } from "@fider/services" import { useFider } from "@fider/hooks" import "./Reactions.scss" @@ -65,10 +66,14 @@ export const Reactions: React.FC = ({ emojiSelectorRef, toggleRe {reactions.map((reaction) => ( toggleReaction(reaction.emoji)} - className={`clickable inline-flex items-center px-2 py-1 rounded-full text-xs ${ - reaction.includesMe ? "bg-blue-100 hover:bg-blue-200" : "bg-gray-100 hover:bg-gray-200" - }`} + {...(fider.session.isAuthenticated && { onClick: () => toggleReaction(reaction.emoji) })} + className={classSet({ + "inline-flex items-center px-2 py-1 rounded-full text-xs": true, + "bg-blue-100": reaction.includesMe, + "bg-gray-100": !reaction.includesMe, + "clickable hover:bg-blue-200": fider.session.isAuthenticated && reaction.includesMe, + "clickable hover:bg-gray-200": fider.session.isAuthenticated && !reaction.includesMe, + })} > {reaction.emoji} {reaction.count} From 35ddba8c94c62c0b8998618c748bdf521a00b4b4 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Fri, 18 Oct 2024 14:10:06 +0100 Subject: [PATCH 5/8] Sort the reactions by count --- app/services/sqlstore/postgres/comment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index 74e238794..eaaa17d73 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -195,7 +195,7 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { 'emoji', emoji, 'count', count, 'includesMe', CASE WHEN $3 = ANY(user_ids) THEN true ELSE false END - )) as reaction_counts + ) ORDER BY count DESC) as reaction_counts FROM ( SELECT comment_id, From d5f0492c1b5a08f82fd48e98b7b45e91c83cdae9 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Sat, 19 Oct 2024 20:23:45 +0100 Subject: [PATCH 6/8] Make comments stand out more Techincally this is part of the UI stuff, but it stands out when you have the reactions in there so adding it here. --- public/pages/ShowPost/components/ShowComment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/pages/ShowPost/components/ShowComment.tsx b/public/pages/ShowPost/components/ShowComment.tsx index 5b1f2a5fa..73ef4ca1a 100644 --- a/public/pages/ShowPost/components/ShowComment.tsx +++ b/public/pages/ShowPost/components/ShowComment.tsx @@ -167,7 +167,7 @@ export const ShowComment = (props: ShowCommentProps) => { const classList = classSet({ "flex-grow rounded-md p-2": true, - "bg-white": !props.highlighted, + "bg-gray-50": !props.highlighted, "bg-gray-100": props.highlighted, }) From bbda83a6306feaff8fd5bce13021a60c1c4ca70d Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Sun, 20 Oct 2024 13:33:47 +0100 Subject: [PATCH 7/8] More tests --- app/handlers/apiv1/post_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/handlers/apiv1/post_test.go b/app/handlers/apiv1/post_test.go index 9790e537b..3b7454f8a 100644 --- a/app/handlers/apiv1/post_test.go +++ b/app/handlers/apiv1/post_test.go @@ -719,3 +719,25 @@ func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) { Expect(code).Equals(http.StatusForbidden) } + +func TestCommentReactionToggleHandler_MismatchingTenantAndComment(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error { + return app.ErrNotFound + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error { + return nil + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + AddParam("number", 1). + AddParam("id", 1). + AddParam("reaction", "like"). + ExecutePost(apiv1.ToggleReaction(), ``) + + Expect(code).Equals(http.StatusNotFound) +} From bb44060c3292be5db246ec04981e77b3aae9a2b3 Mon Sep 17 00:00:00 2001 From: Matt Roberts Date: Sun, 20 Oct 2024 16:11:22 +0100 Subject: [PATCH 8/8] Validate that the reaction is one of the allowed emoji responses. --- app/actions/post.go | 15 +++++++++++++++ app/handlers/apiv1/post_test.go | 32 ++++++++++++++++++++++++++++---- locale/en/server.json | 1 + 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/actions/post.go b/app/actions/post.go index 1ee204788..89314a8dd 100644 --- a/app/actions/post.go +++ b/app/actions/post.go @@ -146,7 +146,22 @@ func (action *ToggleCommentReaction) IsAuthorized(ctx context.Context, user *ent // Validate if current model is valid func (action *ToggleCommentReaction) Validate(ctx context.Context, user *entity.User) *validate.Result { + result := validate.Success() + + allowedEmojis := []string{"👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"} + isAllowed := false + for _, emoji := range allowedEmojis { + if action.Reaction == emoji { + isAllowed = true + break + } + } + + if !isAllowed { + result.AddFieldFailure("reaction", i18n.T(ctx, "validation.custom.invalidemoji")) + } + return result } diff --git a/app/handlers/apiv1/post_test.go b/app/handlers/apiv1/post_test.go index 3b7454f8a..1bb77621e 100644 --- a/app/handlers/apiv1/post_test.go +++ b/app/handlers/apiv1/post_test.go @@ -669,8 +669,8 @@ func TestCommentReactionToggleHandler(t *testing.T) { user *entity.User reaction string }{ - {"JonSnow reacts with like", mock.JonSnow, "like"}, - {"AryaStark reacts with smile", mock.AryaStark, "smile"}, + {"JonSnow reacts with like", mock.JonSnow, "👍"}, + {"AryaStark reacts with smile", mock.AryaStark, "👍"}, } for _, tc := range testCases { @@ -697,7 +697,7 @@ func TestCommentReactionToggleHandler(t *testing.T) { } } -func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) { +func TestCommentReactionToggleHandler_InvalidEmoji(t *testing.T) { RegisterT(t) comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark} @@ -712,11 +712,35 @@ func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) { code, _ := mock.NewServer(). OnTenant(mock.DemoTenant). + AsUser(mock.AryaStark). AddParam("number", 1). AddParam("id", comment.ID). AddParam("reaction", "like"). ExecutePost(apiv1.ToggleReaction(), ``) + Expect(code).Equals(http.StatusBadRequest) +} + +func TestCommentReactionToggleHandler_UnAuthorised(t *testing.T) { + RegisterT(t) + + comment := &entity.Comment{ID: 5, Content: "Old comment text", User: mock.AryaStark} + bus.AddHandler(func(ctx context.Context, q *query.GetCommentByID) error { + q.Result = comment + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.ToggleCommentReaction) error { + return nil + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AddParam("number", 1). + AddParam("id", comment.ID). + AddParam("reaction", "👍"). + ExecutePost(apiv1.ToggleReaction(), ``) + Expect(code).Equals(http.StatusForbidden) } @@ -736,7 +760,7 @@ func TestCommentReactionToggleHandler_MismatchingTenantAndComment(t *testing.T) AsUser(mock.JonSnow). AddParam("number", 1). AddParam("id", 1). - AddParam("reaction", "like"). + AddParam("reaction", "👍"). ExecutePost(apiv1.ToggleReaction(), ``) Expect(code).Equals(http.StatusNotFound) diff --git a/locale/en/server.json b/locale/en/server.json index aac2d1e7b..3f31a50d8 100644 --- a/locale/en/server.json +++ b/locale/en/server.json @@ -29,6 +29,7 @@ "validation.custom.minimagedimensions": "The image must have minimum dimensions of {width}x{height} pixels.", "validation.custom.imagesquareratio": "The image must have an aspect ratio of 1:1.", "validation.custom.maximagesize": "The image size must be smaller than {kilobytes}KB.", + "validation.custom.invalidemoji": "Invalid reaction emoji.", "enum.poststatus.open": "Open", "enum.poststatus.started": "Started", "enum.poststatus.completed": "Completed",