{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 {