From 6aa68a32bc1c823c3b9cd726a8ca07c85a0c3b44 Mon Sep 17 00:00:00 2001 From: Jacqueline Henriksen Date: Fri, 17 Jul 2026 21:11:12 -0700 Subject: [PATCH 1/8] feat: add message reactions --- .../internal/store/postgres/message_pages.go | 36 ++ apps/api/internal/store/postgres/postgres.go | 10 + .../internal/store/postgres/sqlc/queries.sql | 6 + .../store/postgres/storedb/queries.sql.go | 42 ++ .../store/postgres/thread_reactions_test.go | 53 ++ .../store/postgres/thread_windows_test.go | 20 +- .../internal/store/sqlite/message_pages.go | 36 ++ .../internal/store/sqlite/sqlc/queries.sql | 6 + apps/api/internal/store/sqlite/sqlite.go | 10 + .../store/sqlite/storedb/queries.sql.go | 52 ++ .../store/sqlite/thread_reactions_test.go | 51 ++ .../store/sqlite/thread_windows_test.go | 20 +- apps/api/internal/store/types.go | 9 +- apps/web/src/ChatApp.svelte | 77 ++- .../src/components/messages/MessageRow.svelte | 8 + .../components/messages/ReactionsBar.svelte | 339 +++++++++++ apps/web/src/lib/types.ts | 7 + docs/proof/message-reactions-proof.md | 41 ++ docs/proof/message-reactions-real-app.png | Bin 0 -> 69248 bytes tests/e2e/message-reactions.spec.ts | 546 ++++++++++++++++++ 20 files changed, 1359 insertions(+), 10 deletions(-) create mode 100644 apps/api/internal/store/postgres/thread_reactions_test.go create mode 100644 apps/api/internal/store/sqlite/thread_reactions_test.go create mode 100644 apps/web/src/components/messages/ReactionsBar.svelte create mode 100644 docs/proof/message-reactions-proof.md create mode 100644 docs/proof/message-reactions-real-app.png create mode 100644 tests/e2e/message-reactions.spec.ts diff --git a/apps/api/internal/store/postgres/message_pages.go b/apps/api/internal/store/postgres/message_pages.go index 3f9f19d8..7610b623 100644 --- a/apps/api/internal/store/postgres/message_pages.go +++ b/apps/api/internal/store/postgres/message_pages.go @@ -84,9 +84,45 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } + messages, err = s.hydrateReactions(ctx, messages) + if err != nil { + return store.MessagePage{}, err + } return s.buildMessagePage(ctx, scope, messages) } +func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) ([]store.Message, error) { + ids := make([]string, len(messages)) + for i, m := range messages { + ids[i] = m.ID + } + if len(ids) == 0 { + return messages, nil + } + + rows, err := s.q.ListReactionsForMessages(ctx, ids) + if err != nil { + return nil, fmt.Errorf("load reactions: %w", err) + } + + reactionsByMsg := make(map[string][]store.Reaction, len(ids)) + for _, row := range rows { + reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.Reaction{ + Emoji: row.Emoji, + UserID: row.UserID, + CreatedAt: row.CreatedAt, + }) + } + + for i := range messages { + messages[i].Reactions = reactionsByMsg[messages[i].ID] + if messages[i].Reactions == nil { + messages[i].Reactions = []store.Reaction{} + } + } + return messages, nil +} + func (s *Store) hydrateThreadStates(ctx context.Context, messages []store.Message) ([]store.Message, error) { rootIDs := make([]string, 0, len(messages)) for _, message := range messages { diff --git a/apps/api/internal/store/postgres/postgres.go b/apps/api/internal/store/postgres/postgres.go index 1c419ef5..b1ac336d 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -498,6 +498,10 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } + messages, err = s.hydrateReactions(ctx, messages) + if err != nil { + return store.Message{}, err + } return messages[0], nil } @@ -704,6 +708,12 @@ func (s *Store) getThread(ctx context.Context, rootMessageID, userID string, lim if err != nil { return store.Message{}, nil, store.ThreadState{}, err } + threadMessages := append([]store.Message{root}, replies...) + threadMessages, err = s.hydrateReactions(ctx, threadMessages) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + root, replies = threadMessages[0], threadMessages[1:] state, err := getThreadState(ctx, s.db, rootMessageID) return root, replies, state, err } diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index 20bcd148..21f8c528 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -1242,6 +1242,12 @@ WHERE message_id = sqlc.arg(message_id) AND user_id = sqlc.arg(user_id) AND emoji = sqlc.arg(emoji); +-- name: ListReactionsForMessages :many +SELECT r.message_id, r.emoji, r.user_id, r.created_at +FROM reactions r +WHERE r.message_id = ANY($1::text[]) +ORDER BY r.created_at; + -- name: ListEventsAfter :many SELECT e.id, e.cursor, e.workspace_id, COALESCE(e.channel_id, '') AS channel_id, e.type, e.seq, e.payload_json, e.created_at FROM events e diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index 2a1c1115..a4af94d3 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -3547,6 +3547,48 @@ func (q *Queries) ListPendingUploadCleanups(ctx context.Context, rowLimit int32) return items, nil } +const listReactionsForMessages = `-- name: ListReactionsForMessages :many +SELECT r.message_id, r.emoji, r.user_id, r.created_at +FROM reactions r +WHERE r.message_id = ANY($1::text[]) +ORDER BY r.created_at +` + +type ListReactionsForMessagesRow struct { + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` +} + +func (q *Queries) ListReactionsForMessages(ctx context.Context, dollar_1 []string) ([]ListReactionsForMessagesRow, error) { + rows, err := q.db.QueryContext(ctx, listReactionsForMessages, pq.Array(dollar_1)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReactionsForMessagesRow + for rows.Next() { + var i ListReactionsForMessagesRow + if err := rows.Scan( + &i.MessageID, + &i.Emoji, + &i.UserID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listThreadStates = `-- name: ListThreadStates :many SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state diff --git a/apps/api/internal/store/postgres/thread_reactions_test.go b/apps/api/internal/store/postgres/thread_reactions_test.go new file mode 100644 index 00000000..7e82090f --- /dev/null +++ b/apps/api/internal/store/postgres/thread_reactions_test.go @@ -0,0 +1,53 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestGetThreadHydratesReactions(t *testing.T) { + ctx := context.Background() + st := newIsolatedPostgresTestStore(t) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Reaction Owner", "postgres-reaction-owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: reply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { + t.Fatal(err) + } + + threadRoot, replies, _, err := st.GetThread(ctx, root.ID, owner.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) + } + if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" { + t.Fatalf("expected hydrated reply reaction, got %#v", replies) + } +} diff --git a/apps/api/internal/store/postgres/thread_windows_test.go b/apps/api/internal/store/postgres/thread_windows_test.go index 3e5cf6f6..6d49a443 100644 --- a/apps/api/internal/store/postgres/thread_windows_test.go +++ b/apps/api/internal/store/postgres/thread_windows_test.go @@ -30,21 +30,35 @@ func TestGetThreadLatestReturnsBoundedChronologicalWindow(t *testing.T) { if err != nil { t.Fatal(err) } + var lastReply store.Message for index := 1; index <= 5; index++ { - if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ + lastReply, _, _, err = st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ RootMessageID: root.ID, AuthorID: owner.ID, Body: fmt.Sprintf("reply-%d", index), - }); err != nil { + }) + if err != nil { t.Fatal(err) } } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: lastReply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { + t.Fatal(err) + } - _, latest, state, err := st.GetThreadLatest(ctx, root.ID, owner.ID, 2) + threadRoot, latest, state, err := st.GetThreadLatest(ctx, root.ID, owner.ID, 2) if err != nil { t.Fatal(err) } + if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) + } if state.ReplyCount != 5 || len(latest) != 2 || latest[0].Body != "reply-4" || latest[1].Body != "reply-5" { t.Fatalf("unexpected latest thread window: state=%#v replies=%#v", state, latest) } + if len(latest[1].Reactions) != 1 || latest[1].Reactions[0].Emoji != "๐Ÿ”ฅ" { + t.Fatalf("expected hydrated reply reaction, got %#v", latest[1].Reactions) + } } diff --git a/apps/api/internal/store/sqlite/message_pages.go b/apps/api/internal/store/sqlite/message_pages.go index 8a9e70ab..54d4ac11 100644 --- a/apps/api/internal/store/sqlite/message_pages.go +++ b/apps/api/internal/store/sqlite/message_pages.go @@ -84,9 +84,45 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } + messages, err = s.hydrateReactions(ctx, messages) + if err != nil { + return store.MessagePage{}, err + } return s.buildMessagePage(ctx, scope, messages) } +func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) ([]store.Message, error) { + ids := make([]string, len(messages)) + for i, m := range messages { + ids[i] = m.ID + } + if len(ids) == 0 { + return messages, nil + } + + rows, err := s.q.ListReactionsForMessages(ctx, ids) + if err != nil { + return nil, fmt.Errorf("load reactions: %w", err) + } + + reactionsByMsg := make(map[string][]store.Reaction, len(ids)) + for _, row := range rows { + reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.Reaction{ + Emoji: row.Emoji, + UserID: row.UserID, + CreatedAt: row.CreatedAt, + }) + } + + for i := range messages { + messages[i].Reactions = reactionsByMsg[messages[i].ID] + if messages[i].Reactions == nil { + messages[i].Reactions = []store.Reaction{} + } + } + return messages, nil +} + func (s *Store) hydrateThreadStates(ctx context.Context, messages []store.Message) ([]store.Message, error) { rootIDs := make([]string, 0, len(messages)) for _, message := range messages { diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index 943afbfe..fcb4fb9d 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -1214,6 +1214,12 @@ WHERE message_id = sqlc.arg(message_id) AND user_id = sqlc.arg(user_id) AND emoji = sqlc.arg(emoji); +-- name: ListReactionsForMessages :many +SELECT r.message_id, r.emoji, r.user_id, r.created_at +FROM reactions r +WHERE r.message_id IN (sqlc.slice(message_ids)) +ORDER BY r.created_at; + -- name: ListEventsAfter :many SELECT e.id, e.cursor, e.workspace_id, COALESCE(e.channel_id, '') AS channel_id, e.type, e.seq, e.payload_json, e.created_at FROM events e diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go index 168c609e..0be0e23b 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -512,6 +512,10 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } + messages, err = s.hydrateReactions(ctx, messages) + if err != nil { + return store.Message{}, err + } return messages[0], nil } @@ -718,6 +722,12 @@ func (s *Store) getThread(ctx context.Context, rootMessageID, userID string, lim if err != nil { return store.Message{}, nil, store.ThreadState{}, err } + threadMessages := append([]store.Message{root}, replies...) + threadMessages, err = s.hydrateReactions(ctx, threadMessages) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + root, replies = threadMessages[0], threadMessages[1:] state, err := getThreadState(ctx, s.db, rootMessageID) return root, replies, state, err } diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index 7ba70070..6c4cd512 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -3538,6 +3538,58 @@ func (q *Queries) ListPendingUploadCleanups(ctx context.Context, rowLimit int64) return items, nil } +const listReactionsForMessages = `-- name: ListReactionsForMessages :many +SELECT r.message_id, r.emoji, r.user_id, r.created_at +FROM reactions r +WHERE r.message_id IN (/*SLICE:message_ids*/?) +ORDER BY r.created_at +` + +type ListReactionsForMessagesRow struct { + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` +} + +func (q *Queries) ListReactionsForMessages(ctx context.Context, messageIds []string) ([]ListReactionsForMessagesRow, error) { + query := listReactionsForMessages + var queryParams []interface{} + if len(messageIds) > 0 { + for _, v := range messageIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:message_ids*/?", strings.Repeat(",?", len(messageIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:message_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReactionsForMessagesRow + for rows.Next() { + var i ListReactionsForMessagesRow + if err := rows.Scan( + &i.MessageID, + &i.Emoji, + &i.UserID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listThreadStates = `-- name: ListThreadStates :many SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state diff --git a/apps/api/internal/store/sqlite/thread_reactions_test.go b/apps/api/internal/store/sqlite/thread_reactions_test.go new file mode 100644 index 00000000..f949a1f4 --- /dev/null +++ b/apps/api/internal/store/sqlite/thread_reactions_test.go @@ -0,0 +1,51 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestGetThreadHydratesReactions(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Reaction Owner", "reaction-owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: reply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { + t.Fatal(err) + } + + threadRoot, replies, _, err := st.GetThread(ctx, root.ID, owner.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) + } + if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" { + t.Fatalf("expected hydrated reply reaction, got %#v", replies) + } +} diff --git a/apps/api/internal/store/sqlite/thread_windows_test.go b/apps/api/internal/store/sqlite/thread_windows_test.go index 8707dfc5..5835f98f 100644 --- a/apps/api/internal/store/sqlite/thread_windows_test.go +++ b/apps/api/internal/store/sqlite/thread_windows_test.go @@ -28,20 +28,31 @@ func TestGetThreadLatestReturnsBoundedChronologicalWindow(t *testing.T) { if err != nil { t.Fatal(err) } + var lastReply store.Message for index := 1; index <= 5; index++ { - if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ + lastReply, _, _, err = st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ RootMessageID: root.ID, AuthorID: owner.ID, Body: fmt.Sprintf("reply-%d", index), - }); err != nil { + }) + if err != nil { t.Fatal(err) } } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: lastReply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { + t.Fatal(err) + } - _, first, _, err := st.GetThread(ctx, root.ID, owner.ID, 2) + threadRoot, first, _, err := st.GetThread(ctx, root.ID, owner.ID, 2) if err != nil { t.Fatal(err) } + if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) + } if first[0].Body != "reply-1" || first[1].Body != "reply-2" { t.Fatalf("expected earliest replies, got %#v", first) } @@ -52,4 +63,7 @@ func TestGetThreadLatestReturnsBoundedChronologicalWindow(t *testing.T) { if state.ReplyCount != 5 || len(latest) != 2 || latest[0].Body != "reply-4" || latest[1].Body != "reply-5" { t.Fatalf("unexpected latest thread window: state=%#v replies=%#v", state, latest) } + if len(latest[1].Reactions) != 1 || latest[1].Reactions[0].Emoji != "๐Ÿ”ฅ" { + t.Fatalf("expected hydrated reply reaction, got %#v", latest[1].Reactions) + } } diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go index 3515d80a..d2ce2002 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -210,6 +210,12 @@ type Channel struct { UnreadCount int64 `json:"unread_count"` } +type Reaction struct { + Emoji string `json:"emoji"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` +} + type Message struct { ID string `json:"id"` RouteID string `json:"route_id,omitempty"` @@ -246,7 +252,8 @@ type Message struct { // Nonce is a client-supplied idempotency key used by optimistic UIs to match // the server response to a pending placeholder and safely retry after a lost // response. - Nonce string `json:"nonce,omitempty"` + Nonce string `json:"nonce,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` } type MessagePageRequest struct { diff --git a/apps/web/src/ChatApp.svelte b/apps/web/src/ChatApp.svelte index 6e5cbc0b..2e2e9a32 100644 --- a/apps/web/src/ChatApp.svelte +++ b/apps/web/src/ChatApp.svelte @@ -169,6 +169,13 @@ let slashCommandsLoadSerial = 0; let botCommandsLoadSerial = 0; let realtimeReconcileSerial = 0; + let reactionRefreshSerial = 0; + const reactionRefreshGenerations = new Map(); + const pendingReactionSnapshots = new Map< + string, + { reactions: NonNullable; viewSerial: number } + >(); + const MAX_PENDING_REACTION_SNAPSHOTS = 100; let slashDispatchGeneration = 0; let hiddenDirectUndo: HiddenDirectUndo | null = null; let hiddenDirectUndoTimer: ReturnType | undefined; @@ -615,6 +622,7 @@ async function applyRoute(workspaceIDParam = "", targetIDParam = "") { const serial = ++routeApplySerial; + pendingReactionSnapshots.clear(); const requestedRouteKey = routeKey(workspaceIDParam, targetIDParam); const routeTarget = targetIDParam.trim() ? await resolveRouteTarget(workspaceIDParam, targetIDParam) @@ -1703,7 +1711,7 @@ ); const localByID = new Map(localOptimistic.map((m) => [m.id, m])); const localByNonce = new Map(localOptimistic.filter((m) => m.nonce).map((m) => [m.nonce, m])); - const merged = msgs.map((m) => { + const merged = applyPendingReactionSnapshots(msgs).map((m) => { const local = localByID.get(m.id) || (m.nonce ? localByNonce.get(m.nonce) : undefined); if (!local) return m; if (m.nonce && pendingDrafts.has(m.nonce)) { @@ -1740,6 +1748,15 @@ } } + function applyPendingReactionSnapshots(nextMessages: Message[]): Message[] { + return nextMessages.map((message) => { + const pending = pendingReactionSnapshots.get(message.id); + if (!pending || pending.viewSerial !== routeApplySerial) return message; + pendingReactionSnapshots.delete(message.id); + return { ...message, reactions: pending.reactions }; + }); + } + function setActiveMessages(nextMessages: Message[], direction: MessageWindowDirection = "append") { const key = currentConversationKey(); const window = key ? messageWindows.get(key) : undefined; @@ -2084,11 +2101,14 @@ } const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${messageID}/thread`); if (!shouldCommit()) return false; - const root = { ...data.root, thread_state: data.thread_state }; + const [root, ...loadedReplies] = applyPendingReactionSnapshots([ + { ...data.root, thread_state: data.thread_state }, + ...data.replies, + ]); selectedThread = root; activeComposerContext = "thread"; setActiveMessages(messages.map((message) => message.id === root.id ? root : message)); - replies = data.replies; + replies = loadedReplies; selectedThreadState = data.thread_state; return true; } @@ -2660,6 +2680,57 @@ if (messageEventAlreadyAccounted(event)) return; const affectsActiveView = event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID; + if ( + affectsActiveView && + (event.type === "reaction.added" || event.type === "reaction.removed") + ) { + const messageID = event.payload.message_id || ""; + if (messageID) { + const generation = ++reactionRefreshSerial; + const viewSerial = routeApplySerial; + reactionRefreshGenerations.set(messageID, generation); + try { + const data = await api<{ message: Message }>(`/api/messages/${messageID}`); + if ( + reactionRefreshGenerations.get(messageID) !== generation || + routeApplySerial !== viewSerial + ) { + return; + } + const reactions = data.message.reactions ?? []; + const isLoaded = + messages.some((message) => message.id === messageID) || + replies.some((reply) => reply.id === messageID) || + selectedThread?.id === messageID; + if (!isLoaded) { + pendingReactionSnapshots.delete(messageID); + pendingReactionSnapshots.set(messageID, { reactions, viewSerial }); + while (pendingReactionSnapshots.size > MAX_PENDING_REACTION_SNAPSHOTS) { + const oldestMessageID = pendingReactionSnapshots.keys().next().value; + if (!oldestMessageID) break; + pendingReactionSnapshots.delete(oldestMessageID); + } + return; + } + setActiveMessages( + messages.map((message) => + message.id === messageID ? { ...message, reactions } : message, + ), + ); + replies = replies.map((reply) => + reply.id === messageID ? { ...reply, reactions } : reply, + ); + if (selectedThread?.id === messageID) { + selectedThread = { ...selectedThread, reactions }; + } + } finally { + if (reactionRefreshGenerations.get(messageID) === generation) { + reactionRefreshGenerations.delete(messageID); + } + } + } + return; + } maybeShowBrowserNotification(event, affectsActiveView); if (event.type === "message.created" && !affectsActiveView) { const loadedConversation = await loadUnknownDirectConversationFromEvent(event); diff --git a/apps/web/src/components/messages/MessageRow.svelte b/apps/web/src/components/messages/MessageRow.svelte index 61a4c03d..2845b2fe 100644 --- a/apps/web/src/components/messages/MessageRow.svelte +++ b/apps/web/src/components/messages/MessageRow.svelte @@ -3,6 +3,7 @@ import { enhanceMarkdownGifs } from "../../lib/actions/markdownGifs"; import { time, markdown } from "../../lib/format"; import { uploadURL } from "../../lib/uploads"; + import ReactionsBar from "./ReactionsBar.svelte"; import type { Message, Upload } from "../../lib/types"; import MediaAttachment from "../MediaAttachment.svelte"; import QuoteBlock from "./QuoteBlock.svelte"; @@ -130,6 +131,13 @@ {:else}
{@html markdown(message.body)}
+ {#if !isPending && !isFailed} + + {/if} {#if message.attachments?.length}
{#each message.attachments as attachment (attachment.id)} diff --git a/apps/web/src/components/messages/ReactionsBar.svelte b/apps/web/src/components/messages/ReactionsBar.svelte new file mode 100644 index 00000000..222aba0a --- /dev/null +++ b/apps/web/src/components/messages/ReactionsBar.svelte @@ -0,0 +1,339 @@ + + +
+ {#each groupedEntries as [emoji, { count, me }]} + + {/each} + +
+ + + {#if showPicker} + + {/if} +
+ {#if errorMessage}{errorMessage}{/if} +
+ + diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index c57b79a7..8782f0f5 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -73,6 +73,12 @@ export type PreambleBlock = { final: boolean; }; +export type Reaction = { + emoji: string; + user_id: string; + created_at: string; +}; + export type Message = { id: string; route_id?: string; @@ -108,6 +114,7 @@ export type Message = { // Optimistic-send: client-supplied id, echoed by server. Used to swap // pending placeholder with the real message on response/WS event. nonce?: string; + reactions?: Reaction[]; // Client-only status. Absent for sent messages. status?: "pending" | "failed"; }; diff --git a/docs/proof/message-reactions-proof.md b/docs/proof/message-reactions-proof.md new file mode 100644 index 00000000..8d746c54 --- /dev/null +++ b/docs/proof/message-reactions-proof.md @@ -0,0 +1,41 @@ +# Message reactions behavior proof + +## Real ClickClack run + +![A real ClickClack channel showing a sent message with a thumbs-up reaction](https://raw.githubusercontent.com/openclaw/clickclack/main/docs/proof/message-reactions-real-app.png) + +The screenshot was captured by `tests/e2e/message-reactions.spec.ts` against a +locally built ClickClack production bundle served by the Go API. The test creates +a real workspace and channel through the ClickClack API, sends a message through +the ClickClack composer, and adds the thumbs-up reaction through the rendered +reaction picker. + +The same run then reloads the page and asserts that the reaction is still +present, proving it was persisted rather than only painted optimistically. +It removes the reaction through the authenticated API and asserts that +the open ClickClack UI removes the reaction without a reload, exercising the +realtime event path. The remaining focused scenarios cover rollback isolation, +persistence of an in-flight optimistic intent across concurrent authoritative updates, +paged-history preservation, out-of-order reaction events, concurrent message +edits, navigation while a reaction refresh is in flight, and a reaction event +arriving while the active message page is still loading. +It also proves that an ambiguous failed removal reconciles with the committed +server state instead of resurrecting a ghost reaction, and that a failed +optimistic add rolls back from the latest realtime snapshot even when its +recovery request fails too. Finally, it holds an older successful add response, +applies a newer authenticated removal, and confirms the late response cannot +resurrect the reaction. A forced add conflict also reconciles the already- +committed server reaction before clearing the optimistic intent. +Reaction controls are disabled while a write is pending, preventing concurrent +emoji mutations from racing their authoritative refreshes. +It also proves a committed mutation remains visible, without a false error, +when an unrelated realtime update lands first and the follow-up refresh fails. + +## Reproduction + +```sh +REACTION_PROOF_PATH="$PWD/docs/proof/message-reactions-real-app.png" \ + pnpm exec playwright test tests/e2e/message-reactions.spec.ts +``` + +Expected result: `10 passed (10.9s)`. diff --git a/docs/proof/message-reactions-real-app.png b/docs/proof/message-reactions-real-app.png new file mode 100644 index 0000000000000000000000000000000000000000..e220ba8fc74d4fe35ef755678d3f270ae3ef0eac GIT binary patch literal 69248 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 IrvK^x07#=0N&o-= literal 0 HcmV?d00001 diff --git a/tests/e2e/message-reactions.spec.ts b/tests/e2e/message-reactions.spec.ts new file mode 100644 index 00000000..b6da50f1 --- /dev/null +++ b/tests/e2e/message-reactions.spec.ts @@ -0,0 +1,546 @@ +import { expect, test, type Page } from "@playwright/test"; +import { randomUUID } from "node:crypto"; +import { waitForAppReady } from "./app-ready"; + +async function openReactionChannel(page: Page) { + const suffix = randomUUID().replaceAll("-", "").slice(0, 12); + const workspaceResponse = await page.request.post("/api/workspaces", { + data: { name: `Reaction Proof ${suffix}` }, + }); + expect(workspaceResponse.ok()).toBe(true); + const { workspace } = (await workspaceResponse.json()) as { + workspace: { id: string; route_id: string }; + }; + const channelResponse = await page.request.post(`/api/workspaces/${workspace.id}/channels`, { + data: { name: `reaction-proof-${suffix}`, kind: "public" }, + }); + expect(channelResponse.ok()).toBe(true); + const { channel } = (await channelResponse.json()) as { + channel: { id: string; route_id: string; name: string }; + }; + + await page.goto(`/app/${workspace.route_id}/${channel.route_id}`); + await waitForAppReady(page); + await expect(page.getByRole("heading", { name: `#${channel.name}` })).toBeVisible(); + return { suffix, workspace, channel }; +} + +test("message reactions update immediately, persist, and reconcile through realtime", async ({ + page, +}) => { + const { suffix } = await openReactionChannel(page); + + const body = `Reaction behavior proof ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + await row.getByRole("button", { name: "Add reaction" }).click(); + const addReactionResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith(`/api/messages/${messageID}/reactions`), + ); + await row.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + const reaction = row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" }); + await expect(reaction).toBeVisible(); + if (process.env.REACTION_PROOF_PATH) { + await row.scrollIntoViewIfNeeded(); + await page.screenshot({ path: process.env.REACTION_PROOF_PATH, fullPage: true }); + } + expect((await addReactionResponse).ok()).toBe(true); + + await page.reload(); + await waitForAppReady(page); + const persistedRow = page.locator(`[data-message-id="${messageID}"]`); + await expect(persistedRow.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toBeVisible(); + + const removeResponse = await page.request.delete( + `/api/messages/${messageID}/reactions/${encodeURIComponent("๐Ÿ‘")}`, + ); + expect(removeResponse.ok()).toBe(true); + await expect(persistedRow.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); + + let releaseHeldAdd!: () => void; + let markHeldAddCommitted!: () => void; + const heldAddGate = new Promise((resolve) => { + releaseHeldAdd = resolve; + }); + const heldAddCommitted = new Promise((resolve) => { + markHeldAddCommitted = resolve; + }); + await page.route("**/api/messages/*/reactions", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + const response = await route.fetch(); + markHeldAddCommitted(); + await heldAddGate; + await route.fulfill({ response }); + }); + await persistedRow.getByRole("button", { name: "Add reaction" }).click(); + await persistedRow.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + await heldAddCommitted; + await expect(persistedRow.getByRole("button", { name: "Add reaction" })).toBeDisabled(); + const newerRemoval = await page.request.delete( + `/api/messages/${messageID}/reactions/${encodeURIComponent("๐Ÿ‘")}`, + ); + expect(newerRemoval.ok()).toBe(true); + releaseHeldAdd(); + await expect(persistedRow.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); +}); + +test("a conflicting add reconciles the already-committed reaction", async ({ page }) => { + const { suffix } = await openReactionChannel(page); + const body = `Conflicting reaction add ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + const meResponse = await page.request.get("/api/me"); + expect(meResponse.ok()).toBe(true); + const { user } = (await meResponse.json()) as { user: { id: string } }; + + await page.route(`**/api/messages/${messageID}/reactions`, async (route) => { + await route.fulfill({ + status: 409, + contentType: "application/json", + body: '{"error":"reaction already exists"}', + }); + }); + await page.route(`**/api/messages/${messageID}`, async (route) => { + const response = await route.fetch(); + const data = (await response.json()) as { message: { reactions?: unknown[] } }; + data.message.reactions = [ + { emoji: "๐Ÿ‘", user_id: user.id, created_at: new Date().toISOString() }, + ]; + await route.fulfill({ response, json: data }); + }); + + await row.getByRole("button", { name: "Add reaction" }).click(); + await row.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toBeVisible(); + await expect(row.getByRole("status")).toContainText("reaction already exists"); +}); + +test("a committed reaction survives a failed refresh after an unrelated update", async ({ + page, +}) => { + const { suffix } = await openReactionChannel(page); + const body = `Committed reaction refresh ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + await page.route(`**/api/messages/${messageID}/reactions`, async (route) => { + const requestBody = route.request().postDataJSON() as { emoji?: string } | null; + if (route.request().method() !== "POST" || requestBody?.emoji !== "๐Ÿ‘") { + await route.continue(); + return; + } + await mutationGate; + const response = await route.fetch(); + await route.fulfill({ response }); + }); + let refreshCount = 0; + await page.route(`**/api/messages/${messageID}`, async (route) => { + refreshCount += 1; + if (refreshCount === 1) { + await route.continue(); + return; + } + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error":"refresh failed"}', + }); + }); + + await row.getByRole("button", { name: "Add reaction" }).click(); + const committedResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith(`/api/messages/${messageID}/reactions`) && + (response.request().postDataJSON() as { emoji?: string } | null)?.emoji === "๐Ÿ‘", + ); + await row.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + const unrelatedResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "โค๏ธ" }, + }); + expect(unrelatedResponse.ok()).toBe(true); + await expect(row.getByRole("button", { name: "โค๏ธ โ€” 1 reaction" })).toBeVisible(); + releaseMutation(); + expect((await committedResponse).ok()).toBe(true); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toBeVisible(); + await expect(row.getByRole("status")).toHaveCount(0); +}); + +test("a failed optimistic reaction rolls back when recovery also fails after realtime", async ({ + page, +}) => { + const { suffix } = await openReactionChannel(page); + const body = `Concurrent reaction rollback ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + let releaseFailedRequest!: () => void; + const failedRequestGate = new Promise((resolve) => { + releaseFailedRequest = resolve; + }); + await page.route("**/api/messages/*/reactions", async (route) => { + const body = route.request().postDataJSON() as { emoji?: string } | null; + if (route.request().method() === "POST" && body?.emoji === "๐Ÿ‘") { + await failedRequestGate; + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error":"failed"}', + }); + return; + } + await route.continue(); + }); + let messageRefreshCount = 0; + await page.route(`**/api/messages/${messageID}`, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + messageRefreshCount += 1; + if (messageRefreshCount > 1) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error":"recovery failed"}', + }); + return; + } + await route.continue(); + }); + + await row.getByRole("button", { name: "Add reaction" }).click(); + await row.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toBeVisible(); + + const successfulResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "โค๏ธ" }, + }); + expect(successfulResponse.ok()).toBe(true); + const successfulReaction = row.getByRole("button", { name: "โค๏ธ โ€” 1 reaction" }); + await expect(successfulReaction).toBeVisible(); + await expect.poll(() => messageRefreshCount).toBe(1); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toBeVisible(); + + releaseFailedRequest(); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); + await expect(successfulReaction).toBeVisible(); + await expect(row.getByRole("status")).toContainText("failed"); +}); + +test("an ambiguous failed removal reconciles with the committed server state", async ({ page }) => { + const { suffix } = await openReactionChannel(page); + const body = `Ambiguous reaction removal ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + await row.getByRole("button", { name: "Add reaction" }).click(); + const addResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith(`/api/messages/${messageID}/reactions`), + ); + await row.getByRole("menuitem", { name: "React with ๐Ÿ‘" }).click(); + expect((await addResponse).ok()).toBe(true); + const reaction = row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" }); + await expect(reaction).toBeVisible(); + + await page.route( + `**/api/messages/${messageID}/reactions/${encodeURIComponent("๐Ÿ‘")}`, + async (route) => { + await route.fetch(); + await route.fulfill({ + status: 500, + contentType: "application/json", + body: '{"error":"response lost"}', + }); + }, + ); + await reaction.click(); + await expect(reaction).toHaveCount(0); + await expect(row.getByRole("status")).toContainText("response lost"); + await page.waitForTimeout(100); + await expect(reaction).toHaveCount(0); +}); + +test("realtime reactions preserve an around-search history window", async ({ page }) => { + const { channel } = await openReactionChannel(page); + const created: Array<{ id: string }> = []; + for (let index = 0; index < 140; index++) { + const response = await page.request.post(`/api/channels/${channel.id}/messages`, { + data: { body: `reaction-history-${String(index).padStart(3, "0")}` }, + }); + expect(response.ok()).toBe(true); + const data = (await response.json()) as { message: { id: string } }; + created.push(data.message); + } + await page.reload(); + await waitForAppReady(page); + + await page.getByLabel("Search messages").fill("reaction-history-010"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + const result = page + .getByLabel("Search results") + .locator(".search-result", { hasText: "reaction-history-010" }); + await expect(result).toBeVisible(); + await result.click(); + const target = page.locator(`[data-message-id="${created[10].id}"]`); + const neighbor = page.locator(`[data-message-id="${created[11].id}"]`); + await expect(target).toBeVisible(); + await expect(neighbor).toBeVisible(); + + const reactionResponse = await page.request.post(`/api/messages/${created[10].id}/reactions`, { + data: { emoji: "๐Ÿ‘€" }, + }); + expect(reactionResponse.ok()).toBe(true); + await expect(target.getByRole("button", { name: "๐Ÿ‘€ โ€” 1 reaction" })).toBeVisible(); + await expect(neighbor).toBeVisible(); +}); + +test("a stale realtime reaction refresh cannot overwrite a newer event", async ({ page }) => { + const { suffix } = await openReactionChannel(page); + const body = `Reaction refresh race ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + let releaseFirstRefresh!: () => void; + let markFirstRefreshStarted!: () => void; + let releaseThirdRefresh!: () => void; + let markThirdRefreshStarted!: () => void; + const firstRefreshGate = new Promise((resolve) => { + releaseFirstRefresh = resolve; + }); + const firstRefreshStarted = new Promise((resolve) => { + markFirstRefreshStarted = resolve; + }); + const thirdRefreshGate = new Promise((resolve) => { + releaseThirdRefresh = resolve; + }); + const thirdRefreshStarted = new Promise((resolve) => { + markThirdRefreshStarted = resolve; + }); + let refreshCount = 0; + await page.route(`**/api/messages/${messageID}`, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + refreshCount += 1; + const response = await route.fetch(); + if (refreshCount === 1) { + markFirstRefreshStarted(); + await firstRefreshGate; + } else if (refreshCount === 3) { + markThirdRefreshStarted(); + await thirdRefreshGate; + } + await route.fulfill({ response }); + }); + + const addResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "๐Ÿ‘" }, + }); + expect(addResponse.ok()).toBe(true); + await firstRefreshStarted; + + const removeResponse = await page.request.delete( + `/api/messages/${messageID}/reactions/${encodeURIComponent("๐Ÿ‘")}`, + ); + expect(removeResponse.ok()).toBe(true); + await expect.poll(() => refreshCount).toBe(2); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); + + const newerAddResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "๐Ÿ‘€" }, + }); + expect(newerAddResponse.ok()).toBe(true); + await thirdRefreshStarted; + releaseFirstRefresh(); + await expect.poll(() => refreshCount).toBe(3); + await page.waitForTimeout(100); + await expect(row.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); + releaseThirdRefresh(); + await expect(row.getByRole("button", { name: "๐Ÿ‘€ โ€” 1 reaction" })).toBeVisible(); +}); + +test("a reaction refresh cannot overwrite a concurrent message edit", async ({ page }) => { + const { suffix } = await openReactionChannel(page); + const originalBody = `Reaction edit race ${suffix}`; + const editedBody = `Edited during reaction refresh ${suffix}`; + await page.getByLabel("Message body").fill(originalBody); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: originalBody }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + const stableRow = page.locator(`[data-message-id="${messageID}"]`); + + let releaseRefresh!: () => void; + let markRefreshStarted!: () => void; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + await page.route(`**/api/messages/${messageID}`, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const response = await route.fetch(); + markRefreshStarted(); + await refreshGate; + await route.fulfill({ response }); + }); + + const addResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "๐Ÿ‘" }, + }); + expect(addResponse.ok()).toBe(true); + await refreshStarted; + const editResponse = await page.request.patch(`/api/messages/${messageID}`, { + data: { body: editedBody }, + }); + expect(editResponse.ok()).toBe(true); + await expect(stableRow.locator(".markdown")).toContainText(editedBody); + + releaseRefresh(); + await page.waitForTimeout(100); + await expect(stableRow.locator(".markdown")).toContainText(editedBody); +}); + +test("a reaction refresh started in an old view cannot overwrite fresh navigation state", async ({ + page, +}) => { + const { suffix, workspace, channel } = await openReactionChannel(page); + const otherChannelResponse = await page.request.post(`/api/workspaces/${workspace.id}/channels`, { + data: { name: `reaction-other-${suffix}`, kind: "public" }, + }); + expect(otherChannelResponse.ok()).toBe(true); + const { channel: otherChannel } = (await otherChannelResponse.json()) as { + channel: { route_id: string }; + }; + const body = `Reaction navigation race ${suffix}`; + await page.getByLabel("Message body").fill(body); + await page.getByRole("button", { name: "Send" }).click(); + const row = page.locator(".message-row:not(.is-pending)", { hasText: body }); + await expect(row).toBeVisible(); + const messageID = await row.getAttribute("data-message-id"); + expect(messageID).toBeTruthy(); + + let releaseRefresh!: () => void; + let markRefreshStarted!: () => void; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + await page.route(`**/api/messages/${messageID}`, async (route) => { + const response = await route.fetch(); + markRefreshStarted(); + await refreshGate; + await route.fulfill({ response }); + }); + + const addResponse = await page.request.post(`/api/messages/${messageID}/reactions`, { + data: { emoji: "๐Ÿ‘" }, + }); + expect(addResponse.ok()).toBe(true); + await refreshStarted; + await page.goto(`/app/${workspace.route_id}/${otherChannel.route_id}`); + await waitForAppReady(page); + const removeResponse = await page.request.delete( + `/api/messages/${messageID}/reactions/${encodeURIComponent("๐Ÿ‘")}`, + ); + expect(removeResponse.ok()).toBe(true); + await page.goto(`/app/${workspace.route_id}/${channel.route_id}`); + await waitForAppReady(page); + const freshRow = page.locator(`[data-message-id="${messageID}"]`); + await expect(freshRow.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); + + releaseRefresh(); + await page.waitForTimeout(100); + await expect(freshRow.getByRole("button", { name: "๐Ÿ‘ โ€” 1 reaction" })).toHaveCount(0); +}); + +test("a reaction event received during message loading survives the stale page response", async ({ + page, +}) => { + const { suffix, channel } = await openReactionChannel(page); + const body = `Reaction load race ${suffix}`; + const messageResponse = await page.request.post(`/api/channels/${channel.id}/messages`, { + data: { body }, + }); + expect(messageResponse.ok()).toBe(true); + const { message } = (await messageResponse.json()) as { message: { id: string } }; + + let releaseMessagePage!: () => void; + let markMessagePageStarted!: () => void; + const messagePageGate = new Promise((resolve) => { + releaseMessagePage = resolve; + }); + const messagePageStarted = new Promise((resolve) => { + markMessagePageStarted = resolve; + }); + let heldPage = false; + await page.route(`**/api/channels/${channel.id}/messages?*`, async (route) => { + if (heldPage) { + await route.continue(); + return; + } + heldPage = true; + const response = await route.fetch(); + markMessagePageStarted(); + await messagePageGate; + await route.fulfill({ response }); + }); + + await page.reload({ waitUntil: "domcontentloaded" }); + await messagePageStarted; + const reactionResponse = await page.request.post(`/api/messages/${message.id}/reactions`, { + data: { emoji: "๐Ÿ‘€" }, + }); + expect(reactionResponse.ok()).toBe(true); + await page.waitForTimeout(100); + releaseMessagePage(); + await waitForAppReady(page); + + const row = page.locator(`[data-message-id="${message.id}"]`); + await expect(row.getByRole("button", { name: "๐Ÿ‘€ โ€” 1 reaction" })).toBeVisible(); +}); From 2b57cae2d980ea970d90f33a08427cb983124792 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 20 Jul 2026 21:18:31 +0100 Subject: [PATCH 2/8] refactor: aggregate message reaction summaries --- apps/api/internal/store/postgres/dms.go | 5 +- .../internal/store/postgres/message_pages.go | 26 +++--- apps/api/internal/store/postgres/postgres.go | 9 +- .../internal/store/postgres/sqlc/queries.sql | 11 ++- .../store/postgres/storedb/queries.sql.go | 32 ++++--- .../store/postgres/thread_reactions_test.go | 32 ++++++- apps/api/internal/store/sqlite/dms.go | 5 +- .../internal/store/sqlite/message_pages.go | 26 +++--- .../internal/store/sqlite/sqlc/queries.sql | 9 +- apps/api/internal/store/sqlite/sqlite.go | 9 +- .../store/sqlite/storedb/queries.sql.go | 35 +++++--- .../store/sqlite/thread_reactions_test.go | 32 ++++++- apps/api/internal/store/types.go | 12 +-- .../components/messages/ReactionsBar.svelte | 85 ++++++++++--------- apps/web/src/lib/types.ts | 8 +- docs/features/reactions.md | 8 ++ packages/protocol/openapi.yaml | 16 ++++ packages/sdk-ts/src/generated/openapi.d.ts | 7 ++ packages/sdk-ts/src/index.ts | 5 ++ 19 files changed, 254 insertions(+), 118 deletions(-) diff --git a/apps/api/internal/store/postgres/dms.go b/apps/api/internal/store/postgres/dms.go index 0fa4ff9f..865fdd90 100644 --- a/apps/api/internal/store/postgres/dms.go +++ b/apps/api/internal/store/postgres/dms.go @@ -208,8 +208,9 @@ func (s *Store) ListDirectMessages(ctx context.Context, conversationID, userID s return store.MessagePage{}, err } return s.listMessagePage(ctx, messagePageScope{ - where: "m.direct_conversation_id = $1 AND m.parent_message_id IS NULL", - args: []any{conversationID}, + where: "m.direct_conversation_id = $1 AND m.parent_message_id IS NULL", + args: []any{conversationID}, + userID: userID, }, page) } diff --git a/apps/api/internal/store/postgres/message_pages.go b/apps/api/internal/store/postgres/message_pages.go index 7610b623..99d6781a 100644 --- a/apps/api/internal/store/postgres/message_pages.go +++ b/apps/api/internal/store/postgres/message_pages.go @@ -10,8 +10,9 @@ import ( ) type messagePageScope struct { - where string - args []any + where string + args []any + userID string } type messagePageMode string @@ -84,14 +85,14 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } - messages, err = s.hydrateReactions(ctx, messages) + messages, err = s.hydrateReactions(ctx, scope.userID, messages) if err != nil { return store.MessagePage{}, err } return s.buildMessagePage(ctx, scope, messages) } -func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) ([]store.Message, error) { +func (s *Store) hydrateReactions(ctx context.Context, userID string, messages []store.Message) ([]store.Message, error) { ids := make([]string, len(messages)) for i, m := range messages { ids[i] = m.ID @@ -100,24 +101,27 @@ func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) return messages, nil } - rows, err := s.q.ListReactionsForMessages(ctx, ids) + rows, err := s.q.ListReactionsForMessages(ctx, storedb.ListReactionsForMessagesParams{ + UserID: userID, + MessageIds: ids, + }) if err != nil { return nil, fmt.Errorf("load reactions: %w", err) } - reactionsByMsg := make(map[string][]store.Reaction, len(ids)) + reactionsByMsg := make(map[string][]store.ReactionSummary, len(ids)) for _, row := range rows { - reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.Reaction{ - Emoji: row.Emoji, - UserID: row.UserID, - CreatedAt: row.CreatedAt, + reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.ReactionSummary{ + Emoji: row.Emoji, + Count: row.ReactionCount, + ReactedByMe: row.ReactedByMe, }) } for i := range messages { messages[i].Reactions = reactionsByMsg[messages[i].ID] if messages[i].Reactions == nil { - messages[i].Reactions = []store.Reaction{} + messages[i].Reactions = []store.ReactionSummary{} } } return messages, nil diff --git a/apps/api/internal/store/postgres/postgres.go b/apps/api/internal/store/postgres/postgres.go index b1ac336d..079c1abc 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -481,8 +481,9 @@ func (s *Store) ListMessages(ctx context.Context, channelID, userID string, page return store.MessagePage{}, err } return s.listMessagePage(ctx, messagePageScope{ - where: "m.channel_id = $1 AND m.parent_message_id IS NULL", - args: []any{channelID}, + where: "m.channel_id = $1 AND m.parent_message_id IS NULL", + args: []any{channelID}, + userID: userID, }, page) } @@ -498,7 +499,7 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } - messages, err = s.hydrateReactions(ctx, messages) + messages, err = s.hydrateReactions(ctx, userID, messages) if err != nil { return store.Message{}, err } @@ -709,7 +710,7 @@ func (s *Store) getThread(ctx context.Context, rootMessageID, userID string, lim return store.Message{}, nil, store.ThreadState{}, err } threadMessages := append([]store.Message{root}, replies...) - threadMessages, err = s.hydrateReactions(ctx, threadMessages) + threadMessages, err = s.hydrateReactions(ctx, userID, threadMessages) if err != nil { return store.Message{}, nil, store.ThreadState{}, err } diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index 21f8c528..52f60122 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -1243,10 +1243,15 @@ WHERE message_id = sqlc.arg(message_id) AND emoji = sqlc.arg(emoji); -- name: ListReactionsForMessages :many -SELECT r.message_id, r.emoji, r.user_id, r.created_at +SELECT + r.message_id, + r.emoji, + COUNT(*)::bigint AS reaction_count, + BOOL_OR(r.user_id = sqlc.arg(user_id)) AS reacted_by_me FROM reactions r -WHERE r.message_id = ANY($1::text[]) -ORDER BY r.created_at; +WHERE r.message_id = ANY(sqlc.arg(message_ids)::text[]) +GROUP BY r.message_id, r.emoji +ORDER BY r.message_id, reaction_count DESC, r.emoji; -- name: ListEventsAfter :many SELECT e.id, e.cursor, e.workspace_id, COALESCE(e.channel_id, '') AS channel_id, e.type, e.seq, e.payload_json, e.created_at diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index a4af94d3..184aaa32 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -3548,21 +3548,31 @@ func (q *Queries) ListPendingUploadCleanups(ctx context.Context, rowLimit int32) } const listReactionsForMessages = `-- name: ListReactionsForMessages :many -SELECT r.message_id, r.emoji, r.user_id, r.created_at +SELECT + r.message_id, + r.emoji, + COUNT(*)::bigint AS reaction_count, + BOOL_OR(r.user_id = $1) AS reacted_by_me FROM reactions r -WHERE r.message_id = ANY($1::text[]) -ORDER BY r.created_at +WHERE r.message_id = ANY($2::text[]) +GROUP BY r.message_id, r.emoji +ORDER BY r.message_id, reaction_count DESC, r.emoji ` +type ListReactionsForMessagesParams struct { + UserID string `json:"user_id"` + MessageIds []string `json:"message_ids"` +} + type ListReactionsForMessagesRow struct { - MessageID string `json:"message_id"` - Emoji string `json:"emoji"` - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + ReactionCount int64 `json:"reaction_count"` + ReactedByMe bool `json:"reacted_by_me"` } -func (q *Queries) ListReactionsForMessages(ctx context.Context, dollar_1 []string) ([]ListReactionsForMessagesRow, error) { - rows, err := q.db.QueryContext(ctx, listReactionsForMessages, pq.Array(dollar_1)) +func (q *Queries) ListReactionsForMessages(ctx context.Context, arg ListReactionsForMessagesParams) ([]ListReactionsForMessagesRow, error) { + rows, err := q.db.QueryContext(ctx, listReactionsForMessages, arg.UserID, pq.Array(arg.MessageIds)) if err != nil { return nil, err } @@ -3573,8 +3583,8 @@ func (q *Queries) ListReactionsForMessages(ctx context.Context, dollar_1 []strin if err := rows.Scan( &i.MessageID, &i.Emoji, - &i.UserID, - &i.CreatedAt, + &i.ReactionCount, + &i.ReactedByMe, ); err != nil { return nil, err } diff --git a/apps/api/internal/store/postgres/thread_reactions_test.go b/apps/api/internal/store/postgres/thread_reactions_test.go index 7e82090f..8fbe0cd8 100644 --- a/apps/api/internal/store/postgres/thread_reactions_test.go +++ b/apps/api/internal/store/postgres/thread_reactions_test.go @@ -21,6 +21,13 @@ func TestGetThreadHydratesReactions(t *testing.T) { if err != nil { t.Fatal(err) } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Reaction Member", Email: "postgres-reaction-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) if err != nil { t.Fatal(err) @@ -36,6 +43,12 @@ func TestGetThreadHydratesReactions(t *testing.T) { if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { t.Fatal(err) } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: member.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: member.ID, Emoji: "๐Ÿ‘€"}); err != nil { + t.Fatal(err) + } if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: reply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { t.Fatal(err) } @@ -44,10 +57,25 @@ func TestGetThreadHydratesReactions(t *testing.T) { if err != nil { t.Fatal(err) } - if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + if len(threadRoot.Reactions) != 2 { t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) } - if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" { + if got := reactionSummary(threadRoot.Reactions, "๐Ÿ‘"); got.Count != 2 || !got.ReactedByMe { + t.Fatalf("expected owner summary for thumbs-up, got %#v", got) + } + if got := reactionSummary(threadRoot.Reactions, "๐Ÿ‘€"); got.Count != 1 || got.ReactedByMe { + t.Fatalf("expected owner summary for eyes, got %#v", got) + } + if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" || !replies[0].Reactions[0].ReactedByMe { t.Fatalf("expected hydrated reply reaction, got %#v", replies) } } + +func reactionSummary(reactions []store.ReactionSummary, emoji string) store.ReactionSummary { + for _, reaction := range reactions { + if reaction.Emoji == emoji { + return reaction + } + } + return store.ReactionSummary{} +} diff --git a/apps/api/internal/store/sqlite/dms.go b/apps/api/internal/store/sqlite/dms.go index 1fcb6e65..9da792a8 100644 --- a/apps/api/internal/store/sqlite/dms.go +++ b/apps/api/internal/store/sqlite/dms.go @@ -208,8 +208,9 @@ func (s *Store) ListDirectMessages(ctx context.Context, conversationID, userID s return store.MessagePage{}, err } return s.listMessagePage(ctx, messagePageScope{ - where: "m.direct_conversation_id = ? AND m.parent_message_id IS NULL", - args: []any{conversationID}, + where: "m.direct_conversation_id = ? AND m.parent_message_id IS NULL", + args: []any{conversationID}, + userID: userID, }, page) } diff --git a/apps/api/internal/store/sqlite/message_pages.go b/apps/api/internal/store/sqlite/message_pages.go index 54d4ac11..e180fe17 100644 --- a/apps/api/internal/store/sqlite/message_pages.go +++ b/apps/api/internal/store/sqlite/message_pages.go @@ -10,8 +10,9 @@ import ( ) type messagePageScope struct { - where string - args []any + where string + args []any + userID string } type messagePageMode string @@ -84,14 +85,14 @@ func (s *Store) listMessagePage(ctx context.Context, scope messagePageScope, req if err != nil { return store.MessagePage{}, err } - messages, err = s.hydrateReactions(ctx, messages) + messages, err = s.hydrateReactions(ctx, scope.userID, messages) if err != nil { return store.MessagePage{}, err } return s.buildMessagePage(ctx, scope, messages) } -func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) ([]store.Message, error) { +func (s *Store) hydrateReactions(ctx context.Context, userID string, messages []store.Message) ([]store.Message, error) { ids := make([]string, len(messages)) for i, m := range messages { ids[i] = m.ID @@ -100,24 +101,27 @@ func (s *Store) hydrateReactions(ctx context.Context, messages []store.Message) return messages, nil } - rows, err := s.q.ListReactionsForMessages(ctx, ids) + rows, err := s.q.ListReactionsForMessages(ctx, storedb.ListReactionsForMessagesParams{ + UserID: userID, + MessageIds: ids, + }) if err != nil { return nil, fmt.Errorf("load reactions: %w", err) } - reactionsByMsg := make(map[string][]store.Reaction, len(ids)) + reactionsByMsg := make(map[string][]store.ReactionSummary, len(ids)) for _, row := range rows { - reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.Reaction{ - Emoji: row.Emoji, - UserID: row.UserID, - CreatedAt: row.CreatedAt, + reactionsByMsg[row.MessageID] = append(reactionsByMsg[row.MessageID], store.ReactionSummary{ + Emoji: row.Emoji, + Count: row.ReactionCount, + ReactedByMe: row.ReactedByMe != 0, }) } for i := range messages { messages[i].Reactions = reactionsByMsg[messages[i].ID] if messages[i].Reactions == nil { - messages[i].Reactions = []store.Reaction{} + messages[i].Reactions = []store.ReactionSummary{} } } return messages, nil diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index fcb4fb9d..3e9707ba 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -1215,10 +1215,15 @@ WHERE message_id = sqlc.arg(message_id) AND emoji = sqlc.arg(emoji); -- name: ListReactionsForMessages :many -SELECT r.message_id, r.emoji, r.user_id, r.created_at +SELECT + r.message_id, + r.emoji, + COUNT(*) AS reaction_count, + CAST(MAX(CASE WHEN r.user_id = sqlc.arg(user_id) THEN 1 ELSE 0 END) AS INTEGER) AS reacted_by_me FROM reactions r WHERE r.message_id IN (sqlc.slice(message_ids)) -ORDER BY r.created_at; +GROUP BY r.message_id, r.emoji +ORDER BY r.message_id, reaction_count DESC, r.emoji; -- name: ListEventsAfter :many SELECT e.id, e.cursor, e.workspace_id, COALESCE(e.channel_id, '') AS channel_id, e.type, e.seq, e.payload_json, e.created_at diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go index 0be0e23b..a890c798 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -495,8 +495,9 @@ func (s *Store) ListMessages(ctx context.Context, channelID, userID string, page return store.MessagePage{}, err } return s.listMessagePage(ctx, messagePageScope{ - where: "m.channel_id = ? AND m.parent_message_id IS NULL", - args: []any{channelID}, + where: "m.channel_id = ? AND m.parent_message_id IS NULL", + args: []any{channelID}, + userID: userID, }, page) } @@ -512,7 +513,7 @@ func (s *Store) GetMessage(ctx context.Context, messageID, userID string) (store if err != nil { return store.Message{}, err } - messages, err = s.hydrateReactions(ctx, messages) + messages, err = s.hydrateReactions(ctx, userID, messages) if err != nil { return store.Message{}, err } @@ -723,7 +724,7 @@ func (s *Store) getThread(ctx context.Context, rootMessageID, userID string, lim return store.Message{}, nil, store.ThreadState{}, err } threadMessages := append([]store.Message{root}, replies...) - threadMessages, err = s.hydrateReactions(ctx, threadMessages) + threadMessages, err = s.hydrateReactions(ctx, userID, threadMessages) if err != nil { return store.Message{}, nil, store.ThreadState{}, err } diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index 6c4cd512..39f55351 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -3539,27 +3539,38 @@ func (q *Queries) ListPendingUploadCleanups(ctx context.Context, rowLimit int64) } const listReactionsForMessages = `-- name: ListReactionsForMessages :many -SELECT r.message_id, r.emoji, r.user_id, r.created_at +SELECT + r.message_id, + r.emoji, + COUNT(*) AS reaction_count, + CAST(MAX(CASE WHEN r.user_id = ?1 THEN 1 ELSE 0 END) AS INTEGER) AS reacted_by_me FROM reactions r WHERE r.message_id IN (/*SLICE:message_ids*/?) -ORDER BY r.created_at +GROUP BY r.message_id, r.emoji +ORDER BY r.message_id, reaction_count DESC, r.emoji ` +type ListReactionsForMessagesParams struct { + UserID string `json:"user_id"` + MessageIds []string `json:"message_ids"` +} + type ListReactionsForMessagesRow struct { - MessageID string `json:"message_id"` - Emoji string `json:"emoji"` - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + ReactionCount int64 `json:"reaction_count"` + ReactedByMe int64 `json:"reacted_by_me"` } -func (q *Queries) ListReactionsForMessages(ctx context.Context, messageIds []string) ([]ListReactionsForMessagesRow, error) { +func (q *Queries) ListReactionsForMessages(ctx context.Context, arg ListReactionsForMessagesParams) ([]ListReactionsForMessagesRow, error) { query := listReactionsForMessages var queryParams []interface{} - if len(messageIds) > 0 { - for _, v := range messageIds { + queryParams = append(queryParams, arg.UserID) + if len(arg.MessageIds) > 0 { + for _, v := range arg.MessageIds { queryParams = append(queryParams, v) } - query = strings.Replace(query, "/*SLICE:message_ids*/?", strings.Repeat(",?", len(messageIds))[1:], 1) + query = strings.Replace(query, "/*SLICE:message_ids*/?", strings.Repeat(",?", len(arg.MessageIds))[1:], 1) } else { query = strings.Replace(query, "/*SLICE:message_ids*/?", "NULL", 1) } @@ -3574,8 +3585,8 @@ func (q *Queries) ListReactionsForMessages(ctx context.Context, messageIds []str if err := rows.Scan( &i.MessageID, &i.Emoji, - &i.UserID, - &i.CreatedAt, + &i.ReactionCount, + &i.ReactedByMe, ); err != nil { return nil, err } diff --git a/apps/api/internal/store/sqlite/thread_reactions_test.go b/apps/api/internal/store/sqlite/thread_reactions_test.go index f949a1f4..3870c597 100644 --- a/apps/api/internal/store/sqlite/thread_reactions_test.go +++ b/apps/api/internal/store/sqlite/thread_reactions_test.go @@ -19,6 +19,13 @@ func TestGetThreadHydratesReactions(t *testing.T) { if err != nil { t.Fatal(err) } + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Reaction Member", Email: "reaction-member@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, member.ID, store.WorkspaceRoleMember); err != nil { + t.Fatal(err) + } channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) if err != nil { t.Fatal(err) @@ -34,6 +41,12 @@ func TestGetThreadHydratesReactions(t *testing.T) { if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "๐Ÿ‘"}); err != nil { t.Fatal(err) } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: member.ID, Emoji: "๐Ÿ‘"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: member.ID, Emoji: "๐Ÿ‘€"}); err != nil { + t.Fatal(err) + } if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: reply.ID, UserID: owner.ID, Emoji: "๐Ÿ”ฅ"}); err != nil { t.Fatal(err) } @@ -42,10 +55,25 @@ func TestGetThreadHydratesReactions(t *testing.T) { if err != nil { t.Fatal(err) } - if len(threadRoot.Reactions) != 1 || threadRoot.Reactions[0].Emoji != "๐Ÿ‘" { + if len(threadRoot.Reactions) != 2 { t.Fatalf("expected hydrated root reaction, got %#v", threadRoot.Reactions) } - if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" { + if got := reactionSummary(threadRoot.Reactions, "๐Ÿ‘"); got.Count != 2 || !got.ReactedByMe { + t.Fatalf("expected owner summary for thumbs-up, got %#v", got) + } + if got := reactionSummary(threadRoot.Reactions, "๐Ÿ‘€"); got.Count != 1 || got.ReactedByMe { + t.Fatalf("expected owner summary for eyes, got %#v", got) + } + if len(replies) != 1 || len(replies[0].Reactions) != 1 || replies[0].Reactions[0].Emoji != "๐Ÿ”ฅ" || !replies[0].Reactions[0].ReactedByMe { t.Fatalf("expected hydrated reply reaction, got %#v", replies) } } + +func reactionSummary(reactions []store.ReactionSummary, emoji string) store.ReactionSummary { + for _, reaction := range reactions { + if reaction.Emoji == emoji { + return reaction + } + } + return store.ReactionSummary{} +} diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go index d2ce2002..d89f4cb6 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -210,10 +210,10 @@ type Channel struct { UnreadCount int64 `json:"unread_count"` } -type Reaction struct { - Emoji string `json:"emoji"` - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` +type ReactionSummary struct { + Emoji string `json:"emoji"` + Count int64 `json:"count"` + ReactedByMe bool `json:"reacted_by_me"` } type Message struct { @@ -252,8 +252,8 @@ type Message struct { // Nonce is a client-supplied idempotency key used by optimistic UIs to match // the server response to a pending placeholder and safely retry after a lost // response. - Nonce string `json:"nonce,omitempty"` - Reactions []Reaction `json:"reactions,omitempty"` + Nonce string `json:"nonce,omitempty"` + Reactions []ReactionSummary `json:"reactions,omitempty"` } type MessagePageRequest struct { diff --git a/apps/web/src/components/messages/ReactionsBar.svelte b/apps/web/src/components/messages/ReactionsBar.svelte index 222aba0a..8ea648f3 100644 --- a/apps/web/src/components/messages/ReactionsBar.svelte +++ b/apps/web/src/components/messages/ReactionsBar.svelte @@ -1,41 +1,58 @@
- {#each groupedEntries as [emoji, { count, me }]} + {#each groupedEntries as { emoji, count, reacted_by_me }} {#if showPicker} - - {#if errorMessage}{errorMessage}{/if} + {#if error}{error}{/if}