diff --git a/app/actions/post.go b/app/actions/post.go index fa7ba65a7..89314a8dd 100644 --- a/app/actions/post.go +++ b/app/actions/post.go @@ -133,6 +133,38 @@ 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() + + 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 +} + // 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 8790da472..4406917e2 100644 --- a/app/cmd/routes.go +++ b/app/cmd/routes.go @@ -197,6 +197,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..1bb77621e 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,115 @@ 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, "👍"}, + {"AryaStark reacts with smile", mock.AryaStark, "👍"}, + } + + 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_InvalidEmoji(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). + 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) +} + +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", "👍"). + ExecutePost(apiv1.ToggleReaction(), ``) + + Expect(code).Equals(http.StatusNotFound) +} 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 43be67fdd..b0ef76b98 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"` + 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"` + 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/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..eaaa17d73 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" @@ -12,13 +13,14 @@ import ( ) 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"` } func (c *dbComment) toModel(ctx context.Context) *entity.Comment { @@ -33,6 +35,10 @@ func (c *dbComment) toModel(ctx context.Context) *entity.Comment { comment.EditedBy = c.EditedBy.toModel(ctx) comment.EditedAt = &c.EditedAt.Time } + + if c.ReactionCounts.Valid { + _ = json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) + } return comment } @@ -57,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(` @@ -130,8 +168,13 @@ 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 ( + ` + WITH agg_attachments AS ( SELECT c.id as comment_id, ARRAY_REMOVE(ARRAY_AGG(at.attachment_bkey), NULL) as attachment_bkeys @@ -144,6 +187,26 @@ 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_agg(json_build_object( + 'emoji', emoji, + 'count', count, + 'includesMe', CASE WHEN $3 = ANY(user_ids) THEN true ELSE false END + ) ORDER BY count DESC) as reaction_counts + FROM ( + 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 + ) r + GROUP BY comment_id ) SELECT c.id, c.content, @@ -163,7 +226,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,10 +240,12 @@ 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 - ORDER BY c.created_at ASC`, q.Post.ID, tenant.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/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/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/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", diff --git a/migrations/202410122105_create_reactions_up.sql b/migrations/202410122105_create_reactions_up.sql new file mode 100644 index 000000000..ca8fb6e72 --- /dev/null +++ b/migrations/202410122105_create_reactions_up.sql @@ -0,0 +1,11 @@ +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) +); + +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 30ca95ec8..51866812a 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..613030a75 --- /dev/null +++ b/public/components/Reactions.tsx @@ -0,0 +1,86 @@ +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 { classSet } from "@fider/services" +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 && ( + + {availableEmojis.map((emoji) => ( + { + toggleReaction(emoji) + setIsEmojiSelectorOpen(false) + }} + > + {emoji} + + ))} + + )} + > + )} + {reactions !== undefined && ( + <> + {reactions.map((reaction) => ( + 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} + + ))} + > + )} + + + ) +} 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 57bc2b372..0f12c52af 100644 --- a/public/models/post.ts +++ b/public/models/post.ts @@ -51,12 +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?: ReactionCount[] editedAt?: string editedBy?: User } diff --git a/public/pages/ShowPost/components/ShowComment.tsx b/public/pages/ShowPost/components/ShowComment.tsx index ee8f8d385..73ef4ca1a 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}` @@ -122,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, }) @@ -178,6 +223,7 @@ export const ShowComment = (props: ShowCommentProps) => { <> {comment.attachments && comment.attachments.map((x) => )} + > )} 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