From d5b9e8334e73217ea3bba298fbb3d4e06eb7dc03 Mon Sep 17 00:00:00 2001 From: Ellis Green Date: Sat, 12 Sep 2026 17:14:38 +0100 Subject: [PATCH] Add emoji reactions to notes during discuss Gives the discuss stage a lightweight, fun signal separate from voting: a fixed set of six emoji can be toggled on any note, broadcast live over the same socket/broker pattern notes and tasks already use. Scoped to discuss only, matching when votes themselves become visible, so nothing earlier in the flow is influenced by visible reaction counts. Co-Authored-By: Claude Sonnet 5 --- cmd/thoughts/controllers/notes.go | 13 +- cmd/thoughts/dal/reaction.go | 89 +++++++++ cmd/thoughts/event/broker.go | 1 + cmd/thoughts/event/notes.go | 16 +- cmd/thoughts/event/reactions.go | 91 +++++++++ cmd/thoughts/event/reactions_test.go | 187 ++++++++++++++++++ cmd/thoughts/model/reaction.go | 17 ++ cmd/thoughts/resources/note.go | 66 ++++++- .../20260912120000_create_reactions_table.sql | 22 +++ ui/src/components/retro/discuss.tsx | 14 +- ui/src/components/retro/note.tsx | 74 ++++++- ui/src/events/index.ts | 13 +- ui/src/hooks/use-notes.test.ts | 31 +++ ui/src/hooks/use-notes.ts | 15 ++ ui/src/lib/reactions.ts | 3 + ui/src/types.ts | 7 + 16 files changed, 643 insertions(+), 16 deletions(-) create mode 100644 cmd/thoughts/dal/reaction.go create mode 100644 cmd/thoughts/event/reactions.go create mode 100644 cmd/thoughts/event/reactions_test.go create mode 100644 cmd/thoughts/model/reaction.go create mode 100644 migrations/20260912120000_create_reactions_table.sql create mode 100644 ui/src/lib/reactions.ts diff --git a/cmd/thoughts/controllers/notes.go b/cmd/thoughts/controllers/notes.go index 30d01e0..a13383e 100644 --- a/cmd/thoughts/controllers/notes.go +++ b/cmd/thoughts/controllers/notes.go @@ -46,12 +46,23 @@ func RetroNotesIndex(db *sqlx.DB) http.Handler { return } + reactions, err := dal.ReactionsForRetro(r.Context(), db, retroID) + if err != nil { + slog.Error("problem fetching reactions", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + reactionsByNote := lo.GroupBy(reactions, func(reaction *model.Reaction) uuid.UUID { + return reaction.NoteID + }) + user := auth.UserFromRequest(r) obfuscate := retro.Status == model.RetroStatusBrainstorm writeJSON(w, lo.Map(notes, func(note *model.Note, _ int) *resources.Note { - return resources.NoteFromModel(note, userMap[note.UserID], user.ID, obfuscate) + return resources.NoteFromModel(note, userMap[note.UserID], user.ID, obfuscate, reactionsByNote[note.ID]) })) }) } diff --git a/cmd/thoughts/dal/reaction.go b/cmd/thoughts/dal/reaction.go new file mode 100644 index 0000000..b77aade --- /dev/null +++ b/cmd/thoughts/dal/reaction.go @@ -0,0 +1,89 @@ +package dal + +import ( + "context" + "fmt" + "time" + + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +func ReactionsForNote( + ctx context.Context, + db *sqlx.DB, + noteID uuid.UUID, +) ([]*model.Reaction, error) { + reactions := []*model.Reaction{} + if err := db.SelectContext(ctx, &reactions, "select * from reactions where note_id = ?", noteID); err != nil { + return nil, fmt.Errorf("%w: failed to get reactions for note: %w", ErrExecution, err) + } + + return reactions, nil +} + +func ReactionsForRetro( + ctx context.Context, + db *sqlx.DB, + retroID uuid.UUID, +) ([]*model.Reaction, error) { + reactions := []*model.Reaction{} + if err := db.SelectContext(ctx, &reactions, "select * from reactions where retro_id = ?", retroID); err != nil { + return nil, fmt.Errorf("%w: failed to get reactions for retro: %w", ErrExecution, err) + } + + return reactions, nil +} + +func ReactionInsert( + ctx context.Context, + db *sqlx.DB, + retroID uuid.UUID, + noteID uuid.UUID, + userID uuid.UUID, + emoji string, +) (*model.Reaction, error) { + reaction := &model.Reaction{ + ID: uuid.New(), + RetroID: retroID, + NoteID: noteID, + UserID: userID, + Emoji: emoji, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + _, err := db.NamedExecContext(ctx, ` + insert or ignore into reactions + (id, retro_id, note_id, user_id, emoji, created_at, updated_at) + values + (:id, :retro_id, :note_id, :user_id, :emoji, :created_at, :updated_at) + `, reaction) + + if err != nil { + return nil, fmt.Errorf("%w: failed to insert reaction: %w", ErrExecution, err) + } + + return reaction, nil +} + +func ReactionDelete( + ctx context.Context, + db *sqlx.DB, + retroID uuid.UUID, + noteID uuid.UUID, + userID uuid.UUID, + emoji string, +) error { + _, err := db.ExecContext(ctx, ` + delete from reactions + where retro_id = $1 and note_id = $2 and user_id = $3 and emoji = $4 + `, retroID, noteID, userID, emoji) + + if err != nil { + return fmt.Errorf("%w: failed to delete reaction: %w", ErrExecution, err) + } + + return nil +} diff --git a/cmd/thoughts/event/broker.go b/cmd/thoughts/event/broker.go index ef77da6..bbf76fc 100644 --- a/cmd/thoughts/event/broker.go +++ b/cmd/thoughts/event/broker.go @@ -44,6 +44,7 @@ func NewBroker(db *sqlx.DB, retroID uuid.UUID) *Broker { b.register("task_create", b.handleTaskCreate(db, retroID)) b.register("task_update", b.handleTaskUpdate(db)) b.register("task_complete", b.handleTaskComplete(db)) + b.register("reaction_toggle", b.handleReactionToggle(db, retroID)) return b } diff --git a/cmd/thoughts/event/notes.go b/cmd/thoughts/event/notes.go index 193185a..a11dd89 100644 --- a/cmd/thoughts/event/notes.go +++ b/cmd/thoughts/event/notes.go @@ -109,7 +109,15 @@ func (b *Broker) handleNoteUpdate(db *sqlx.DB, retroID uuid.UUID) Handler { return newErrorEvent("problem updating note") } - b.dispatchUserDependent(newNoteUpdatedEvent(note, author, retro, refFrom(payload))) + // The frontend replaces the whole note on this event, so its + // reactions have to come along or they'd vanish for everyone. + reactions, err := dal.ReactionsForNote(ctx, db, note.ID) + if err != nil { + slog.Error("problem getting note reactions", "error", err) + return newErrorEvent("problem updating note") + } + + b.dispatchUserDependent(newNoteUpdatedEvent(note, author, retro, reactions, refFrom(payload))) return nil } @@ -186,7 +194,7 @@ func payloadHasAny(payload Payload, keys ...string) bool { // "unknown" for everyone once it was moved or edited. func newNoteCreatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent { return func(user *model.User) *Event { - resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming()) + resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming(), nil) payload := resources.StructToMap(resource) return &Event{ @@ -196,9 +204,9 @@ func newNoteCreatedEvent(note *model.Note, author *model.User, retro *model.Retr } } -func newNoteUpdatedEvent(note *model.Note, author *model.User, retro *model.Retro, ref string) UserDependentEvent { +func newNoteUpdatedEvent(note *model.Note, author *model.User, retro *model.Retro, reactions []*model.Reaction, ref string) UserDependentEvent { return func(user *model.User) *Event { - resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming()) + resource := resources.NoteFromModel(note, author, user.ID, retro.IsBrainstorming(), reactions) payload := resources.StructToMap(resource) return &Event{ diff --git a/cmd/thoughts/event/reactions.go b/cmd/thoughts/event/reactions.go new file mode 100644 index 0000000..8218014 --- /dev/null +++ b/cmd/thoughts/event/reactions.go @@ -0,0 +1,91 @@ +package event + +import ( + "context" + "log/slog" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/model" + "github.com/ellgreen/thoughts/cmd/thoughts/requests" + "github.com/ellgreen/thoughts/cmd/thoughts/resources" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// Kept in step with ui/src/lib/reactions.ts: reactions are a fixed set, not +// free-form emoji, so both sides list the same six. +var allowedReactionEmoji = map[string]bool{ + "👍": true, + "🎉": true, + "😂": true, + "😮": true, + "👀": true, + "❤️": true, +} + +type reactionToggleRequest struct { + NoteID uuid.UUID `json:"note_id" validate:"required,uuid"` + Emoji string `json:"emoji" validate:"required"` + Value bool `json:"value"` +} + +func (b *Broker) handleReactionToggle(db *sqlx.DB, retroID uuid.UUID) Handler { + return func(ctx context.Context, user *model.User, payload Payload) error { + req, err := requests.FromMap[reactionToggleRequest](payload) + if err != nil { + return newErrorEvent(err.Error()) + } + + if !allowedReactionEmoji[req.Emoji] { + return newErrorEvent("that's not a reaction we support") + } + + if err := authoriseNote(ctx, db, user, retroID, req.NoteID, false); err != nil { + return err + } + + retro, err := dal.RetroGet(ctx, db, retroID) + if err != nil { + slog.Error("problem getting retro", "error", err) + return newErrorEvent("problem getting retro") + } + + if retro.Status != model.RetroStatusDiscuss { + return newErrorEvent("reactions are only available while discussing") + } + + if req.Value { + if _, err := dal.ReactionInsert(ctx, db, retroID, req.NoteID, user.ID, req.Emoji); err != nil { + slog.Error("problem inserting reaction", "error", err) + return newErrorEvent("problem adding reaction") + } + } else { + if err := dal.ReactionDelete(ctx, db, retroID, req.NoteID, user.ID, req.Emoji); err != nil { + slog.Error("problem deleting reaction", "error", err) + return newErrorEvent("problem removing reaction") + } + } + + reactions, err := dal.ReactionsForNote(ctx, db, req.NoteID) + if err != nil { + slog.Error("problem getting note reactions", "error", err) + return newErrorEvent("problem updating reactions") + } + + b.dispatchUserDependent(newNoteReactionsUpdatedEvent(req.NoteID, reactions, refFrom(payload))) + + return nil + } +} + +func newNoteReactionsUpdatedEvent(noteID uuid.UUID, reactions []*model.Reaction, ref string) UserDependentEvent { + return func(user *model.User) *Event { + return &Event{ + Name: "note_reactions_updated", + Payload: withRef(Payload{ + "id": noteID, + "reactions": resources.ReactionSummariesFromModel(reactions, user.ID), + }, ref), + } + } +} diff --git a/cmd/thoughts/event/reactions_test.go b/cmd/thoughts/event/reactions_test.go new file mode 100644 index 0000000..aaeecd2 --- /dev/null +++ b/cmd/thoughts/event/reactions_test.go @@ -0,0 +1,187 @@ +package event_test + +import ( + "context" + "testing" + + "github.com/ellgreen/thoughts/cmd/thoughts/dal" + "github.com/ellgreen/thoughts/cmd/thoughts/model" +) + +func (h *harness) advanceToDiscuss(t *testing.T) { + t.Helper() + + if err := dal.RetroUpdateStatus(context.Background(), h.db, h.retro.ID, model.RetroStatusDiscuss); err != nil { + t.Fatalf("failed to advance to discuss: %v", err) + } +} + +func TestReactionToggleAddsThenRemoves(t *testing.T) { + h := newHarness(t) + h.advanceToDiscuss(t) + + author := h.user(t, "Author") + noteID := h.createNote(t, author, "a thought") + + if err := h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "👍", + "value": true, + }); err != nil { + t.Fatalf("reaction_toggle (add) failed: %v", err) + } + + evt := h.next(t) + if evt.Name != "note_reactions_updated" { + t.Fatalf("expected note_reactions_updated, got %s", evt.Name) + } + + if evt.Payload["id"] != noteID { + t.Errorf("expected reactions for note %s, got %v", noteID, evt.Payload["id"]) + } + + if err := h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "👍", + "value": false, + }); err != nil { + t.Fatalf("reaction_toggle (remove) failed: %v", err) + } + + if evt := h.next(t); evt.Name != "note_reactions_updated" { + t.Errorf("expected note_reactions_updated, got %s", evt.Name) + } + + reactions, err := dal.ReactionsForNote(context.Background(), h.db, noteID) + if err != nil { + t.Fatalf("failed to re-read reactions: %v", err) + } + + if len(reactions) != 0 { + t.Errorf("expected no reactions left, got %d", len(reactions)) + } +} + +func TestReactionAddIsIdempotent(t *testing.T) { + h := newHarness(t) + h.advanceToDiscuss(t) + + author := h.user(t, "Author") + noteID := h.createNote(t, author, "a thought") + + for range 2 { + if err := h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "👍", + "value": true, + }); err != nil { + t.Fatalf("reaction_toggle (add) failed: %v", err) + } + + h.next(t) + } + + reactions, err := dal.ReactionsForNote(context.Background(), h.db, noteID) + if err != nil { + t.Fatalf("failed to re-read reactions: %v", err) + } + + if len(reactions) != 1 { + t.Errorf("expected exactly one reaction row, got %d", len(reactions)) + } +} + +func TestReactionCountsAcrossUsers(t *testing.T) { + h := newHarness(t) + h.advanceToDiscuss(t) + + author := h.user(t, "Author") + other := h.user(t, "Someone Else") + noteID := h.createNote(t, author, "a thought") + + for _, user := range []*model.User{author, other} { + if err := h.handle(t, user, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "🎉", + "value": true, + }); err != nil { + t.Fatalf("reaction_toggle failed for %s: %v", user.Name, err) + } + + h.next(t) + } + + reactions, err := dal.ReactionsForNote(context.Background(), h.db, noteID) + if err != nil { + t.Fatalf("failed to re-read reactions: %v", err) + } + + if len(reactions) != 2 { + t.Errorf("expected two reaction rows, got %d", len(reactions)) + } +} + +func TestReactionRejectedOutsideDiscuss(t *testing.T) { + h := newHarness(t) + + author := h.user(t, "Author") + noteID := h.createNote(t, author, "a thought") + + assertErrorEvent( + t, + h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "👍", + "value": true, + }), + "reactions are only available while discussing", + ) +} + +func TestReactionRejectsUnknownEmoji(t *testing.T) { + h := newHarness(t) + h.advanceToDiscuss(t) + + author := h.user(t, "Author") + noteID := h.createNote(t, author, "a thought") + + assertErrorEvent( + t, + h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": noteID.String(), + "emoji": "💩", + "value": true, + }), + "that's not a reaction we support", + ) +} + +func TestReactionOnNoteFromAnotherRetroIsRejected(t *testing.T) { + h := newHarness(t) + h.advanceToDiscuss(t) + ctx := context.Background() + + author := h.user(t, "Author") + + otherRetro, err := dal.RetroInsert(ctx, h.db, "Another retro", model.RetroColumns{ + {Title: "One", Description: ""}, + }, false) + if err != nil { + t.Fatalf("failed to seed the second retro: %v", err) + } + + foreign, err := dal.NoteInsert(ctx, h.db, otherRetro.ID, author.ID, otherRetro.GetColumns()[0].ID, "elsewhere") + if err != nil { + t.Fatalf("failed to seed the foreign note: %v", err) + } + + assertErrorEvent( + t, + h.handle(t, author, "reaction_toggle", map[string]any{ + "note_id": foreign.ID.String(), + "emoji": "👍", + "value": true, + }), + "note not found", + ) +} diff --git a/cmd/thoughts/model/reaction.go b/cmd/thoughts/model/reaction.go new file mode 100644 index 0000000..2b80049 --- /dev/null +++ b/cmd/thoughts/model/reaction.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +type Reaction struct { + ID uuid.UUID `db:"id"` + RetroID uuid.UUID `db:"retro_id"` + NoteID uuid.UUID `db:"note_id"` + UserID uuid.UUID `db:"user_id"` + Emoji string `db:"emoji"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/cmd/thoughts/resources/note.go b/cmd/thoughts/resources/note.go index 91dfb1c..e33aaee 100644 --- a/cmd/thoughts/resources/note.go +++ b/cmd/thoughts/resources/note.go @@ -6,17 +6,30 @@ import ( "github.com/google/uuid" ) +type ReactionSummary struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + ReactedByMe bool `json:"reacted_by_me"` +} + type Note struct { - ID uuid.UUID `json:"id"` - CreatedByMe bool `json:"created_by_me"` - CreatedByName string `json:"created_by_name"` - ColumnID uuid.UUID `json:"column_id"` - GroupID uuid.UUID `json:"group_id"` - Content string `json:"content"` - ImgURL string `json:"img_url"` + ID uuid.UUID `json:"id"` + CreatedByMe bool `json:"created_by_me"` + CreatedByName string `json:"created_by_name"` + ColumnID uuid.UUID `json:"column_id"` + GroupID uuid.UUID `json:"group_id"` + Content string `json:"content"` + ImgURL string `json:"img_url"` + Reactions []ReactionSummary `json:"reactions"` } -func NoteFromModel(note *model.Note, noteUser *model.User, authUserID uuid.UUID, obfuscate bool) *Note { +func NoteFromModel( + note *model.Note, + noteUser *model.User, + authUserID uuid.UUID, + obfuscate bool, + reactions []*model.Reaction, +) *Note { createdByMe := note.UserID == authUserID content := note.Content @@ -37,5 +50,42 @@ func NoteFromModel(note *model.Note, noteUser *model.User, authUserID uuid.UUID, GroupID: note.GroupID, Content: content, ImgURL: note.ImgURL.V, + Reactions: ReactionSummariesFromModel(reactions, authUserID), + } +} + +func ReactionSummariesFromModel(reactions []*model.Reaction, authUserID uuid.UUID) []ReactionSummary { + type tally struct { + count int + reactedByMe bool + } + + byEmoji := map[string]*tally{} + order := []string{} + + for _, reaction := range reactions { + t, ok := byEmoji[reaction.Emoji] + if !ok { + t = &tally{} + byEmoji[reaction.Emoji] = t + order = append(order, reaction.Emoji) + } + + t.count++ + if reaction.UserID == authUserID { + t.reactedByMe = true + } } + + summaries := make([]ReactionSummary, 0, len(order)) + for _, emoji := range order { + t := byEmoji[emoji] + summaries = append(summaries, ReactionSummary{ + Emoji: emoji, + Count: t.count, + ReactedByMe: t.reactedByMe, + }) + } + + return summaries } diff --git a/migrations/20260912120000_create_reactions_table.sql b/migrations/20260912120000_create_reactions_table.sql new file mode 100644 index 0000000..7568205 --- /dev/null +++ b/migrations/20260912120000_create_reactions_table.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin +create table reactions ( + id text primary key, + retro_id text not null, + note_id text not null, + user_id text not null, + emoji text not null, + created_at timestamp not null default current_timestamp, + updated_at timestamp not null default current_timestamp, + foreign key (retro_id) references retros(id), + foreign key (note_id) references notes(id), + foreign key (user_id) references users(id) +); + +create unique index unique_reaction on reactions (note_id, user_id, emoji); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop table reactions; +-- +goose StatementEnd diff --git a/ui/src/components/retro/discuss.tsx b/ui/src/components/retro/discuss.tsx index 45f03f3..48b84d3 100644 --- a/ui/src/components/retro/discuss.tsx +++ b/ui/src/components/retro/discuss.tsx @@ -154,7 +154,19 @@ export default function Discuss() { authors={authorsOf(groupNotes)} > {groupNotes.map((note) => ( - + + dispatch( + createSocketEvent("reaction_toggle", { + note_id: note.id, + emoji, + value, + }), + ) + } + /> ))} ))} diff --git a/ui/src/components/retro/note.tsx b/ui/src/components/retro/note.tsx index caf7daa..d32523c 100644 --- a/ui/src/components/retro/note.tsx +++ b/ui/src/components/retro/note.tsx @@ -1,6 +1,7 @@ import { accentForName } from "@/lib/column-accent"; import { cardVariants, spring } from "@/lib/motion"; -import { Note as NoteType } from "@/types"; +import { REACTION_EMOJI } from "@/lib/reactions"; +import { Note as NoteType, Reaction } from "@/types"; import { DraggableAttributes, DraggableSyntheticListeners, @@ -11,6 +12,7 @@ import { Image, ImageOff, Pencil, + SmilePlus, Trash2, Ungroup, } from "lucide-react"; @@ -18,6 +20,7 @@ import { m } from "motion/react"; import React, { useState } from "react"; import { twMerge } from "tailwind-merge"; import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipContent, @@ -39,6 +42,7 @@ interface NoteProps { onGifSelected?: (url: string) => void; onGifRemoved?: () => void; onUngroup?: () => void; + onReact?: (emoji: string, value: boolean) => void; } const shellClassName = @@ -56,6 +60,7 @@ export const Note = ({ onGifSelected, onGifRemoved, onUngroup, + onReact, className, ref, ...props @@ -91,6 +96,7 @@ export const Note = ({ onGifSelected={onGifSelected} onGifRemoved={onGifRemoved} onUngroup={onUngroup} + onReact={onReact} /> ); @@ -128,6 +134,7 @@ function NoteBody({ onGifSelected, onGifRemoved, onUngroup, + onReact, }: NoteProps) { const hasActions = !!( onGifSelected || @@ -159,6 +166,8 @@ function NoteBody({ )} + {onReact && } + {hasActions && (
{onUngroup && ( @@ -265,6 +274,69 @@ function NoteImage({ src, blur }: { src: string; blur?: boolean }) { ); } +function ReactionBar({ + reactions, + onReact, +}: { + reactions: Reaction[]; + onReact: (emoji: string, value: boolean) => void; +}) { + const reacted = new Set( + reactions.filter((r) => r.reacted_by_me).map((r) => r.emoji), + ); + + return ( +
+ {reactions.map((reaction) => ( + + ))} + + + + + + +
+ {REACTION_EMOJI.map((emoji) => ( + + ))} +
+
+
+
+ ); +} + function Author({ name }: { name: string }) { return (
diff --git a/ui/src/events/index.ts b/ui/src/events/index.ts index 6de7e3e..99e8810 100644 --- a/ui/src/events/index.ts +++ b/ui/src/events/index.ts @@ -1,4 +1,4 @@ -import { RetroStatus } from "@/types"; +import { Reaction, RetroStatus } from "@/types"; export type Payload = object; @@ -63,6 +63,17 @@ export interface PayloadConnectionInfo { users: string[]; } +export interface PayloadReactionToggle { + note_id: string; + emoji: string; + value: boolean; +} + +export interface PayloadNoteReactionsUpdated { + id: string; + reactions: Reaction[]; +} + export function createSocketEvent( name: string, payload: object = {}, diff --git a/ui/src/hooks/use-notes.test.ts b/ui/src/hooks/use-notes.test.ts index ebe883f..dcdb2a1 100644 --- a/ui/src/hooks/use-notes.test.ts +++ b/ui/src/hooks/use-notes.test.ts @@ -11,6 +11,7 @@ function note(overrides: Partial = {}): Note { group_id: "group-1", content: "a thought", img_url: "", + reactions: [], ...overrides, }; } @@ -195,6 +196,36 @@ describe("notesReducer", () => { expect(state.notes.map((n) => n.id)).toEqual(["n2"]); }); + it("updates a note's reactions on note_reactions_updated", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1" }), note({ id: "n2" })] }, + { + name: "note_reactions_updated", + payload: { + id: "n1", + reactions: [{ emoji: "👍", count: 1, reacted_by_me: true }], + }, + }, + ); + + expect(state.notes[0].reactions).toEqual([ + { emoji: "👍", count: 1, reacted_by_me: true }, + ]); + expect(state.notes[1].reactions).toEqual([]); + }); + + it("ignores reactions for a note it has never seen", () => { + const state = replay( + { name: "note_index", payload: [note({ id: "n1" })] }, + { + name: "note_reactions_updated", + payload: { id: "ghost", reactions: [] }, + }, + ); + + expect(state.notes.map((n) => n.id)).toEqual(["n1"]); + }); + it("leaves state untouched for events it does not handle", () => { const state = replay({ name: "connection_info", payload: { users: [] } }); diff --git a/ui/src/hooks/use-notes.ts b/ui/src/hooks/use-notes.ts index d8c6d77..50ee987 100644 --- a/ui/src/hooks/use-notes.ts +++ b/ui/src/hooks/use-notes.ts @@ -1,6 +1,7 @@ import { PayloadError, PayloadNoteCreate, + PayloadNoteReactionsUpdated, PayloadNoteUpdate, Ref, SocketEvent, @@ -81,6 +82,7 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { column_id: payload.column_id, group_id: payload.ref, img_url: "", + reactions: [], }, ], rollbacks: { ...state.rollbacks, [payload.ref]: null }, @@ -157,6 +159,19 @@ function notesReducer(state: NotesState, event: SocketEvent): NotesState { }; } + case "note_reactions_updated": { + const payload = event.payload as PayloadNoteReactionsUpdated; + + return { + ...state, + notes: state.notes.map((note) => + note.id === payload.id + ? { ...note, reactions: payload.reactions } + : note, + ), + }; + } + case "error": { const { ref } = event.payload as PayloadError; if (!ref || !(ref in state.rollbacks)) return state; diff --git a/ui/src/lib/reactions.ts b/ui/src/lib/reactions.ts new file mode 100644 index 0000000..8b6bc2e --- /dev/null +++ b/ui/src/lib/reactions.ts @@ -0,0 +1,3 @@ +// Kept in step with cmd/thoughts/event/reactions.go's allowedReactionEmoji: +// reactions are a fixed set, not free-form emoji, so both sides list the same six. +export const REACTION_EMOJI = ["👍", "🎉", "😂", "😮", "👀", "❤️"]; diff --git a/ui/src/types.ts b/ui/src/types.ts index c3c15f0..4d09052 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -26,6 +26,12 @@ export interface RetroColumn { description: string; } +export interface Reaction { + emoji: string; + count: number; + reacted_by_me: boolean; +} + export interface Note { id: string; created_by_me: boolean; @@ -34,6 +40,7 @@ export interface Note { group_id: string; content: string; img_url: string; + reactions: Reaction[]; } export interface Task {