feat(reactions): add emoji reactions for comments and issue descriptions

Add Slack-style emoji reactions to comments and issue descriptions with
full-stack support: database tables, REST API endpoints, real-time
WebSocket sync, optimistic UI updates, and inbox notifications.

- New `comment_reaction` and `issue_reaction` tables with migrations
- POST/DELETE endpoints for adding/removing reactions on both comments
  and issue descriptions
- Real-time WS events (reaction:added/removed, issue_reaction:added/removed)
- Shared ReactionBar component with quick emoji picker and full emoji-mart
  picker (lazy-loaded)
- Optimistic add/remove with rollback on failure
- Inbox notifications for comment author and issue creator when reacted to
- Reactions included in timeline, comment list, and issue detail responses
This commit is contained in:
Jiayuan 2026-03-30 22:37:59 +08:00
parent 72e3ccfe33
commit 7c1aabbe3a
32 changed files with 1221 additions and 49 deletions

View file

@ -473,6 +473,76 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) {
}
})
// issue_reaction:added — notify the issue creator
bus.Subscribe(protocol.EventIssueReactionAdded, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
reaction, ok := payload["reaction"].(handler.IssueReactionResponse)
if !ok {
return
}
creatorType, _ := payload["creator_type"].(string)
creatorID, _ := payload["creator_id"].(string)
issueID, _ := payload["issue_id"].(string)
issueTitle, _ := payload["issue_title"].(string)
issueStatus, _ := payload["issue_status"].(string)
if creatorType == "" || creatorID == "" {
return
}
details, _ := json.Marshal(map[string]string{
"emoji": reaction.Emoji,
})
notifyDirect(ctx, queries, bus,
creatorType, creatorID,
e.WorkspaceID, e, issueID, issueStatus,
"reaction_added", "info",
issueTitle, "",
details,
)
})
// reaction:added — notify the comment author
bus.Subscribe(protocol.EventReactionAdded, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
reaction, ok := payload["reaction"].(handler.ReactionResponse)
if !ok {
return
}
commentAuthorType, _ := payload["comment_author_type"].(string)
commentAuthorID, _ := payload["comment_author_id"].(string)
issueID, _ := payload["issue_id"].(string)
issueTitle, _ := payload["issue_title"].(string)
issueStatus, _ := payload["issue_status"].(string)
if commentAuthorType == "" || commentAuthorID == "" {
return
}
details, _ := json.Marshal(map[string]string{
"emoji": reaction.Emoji,
})
notifyDirect(ctx, queries, bus,
commentAuthorType, commentAuthorID,
e.WorkspaceID, e, issueID, issueStatus,
"reaction_added", "info",
issueTitle, "",
details,
)
})
// task:completed — notify all subscribers except the agent
bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)

View file

@ -164,6 +164,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Get("/subscribers", h.ListIssueSubscribers)
r.Post("/subscribe", h.SubscribeToIssue)
r.Post("/unsubscribe", h.UnsubscribeFromIssue)
r.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction)
})
})
@ -171,6 +173,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Route("/api/comments/{commentId}", func(r chi.Router) {
r.Put("/", h.UpdateComment)
r.Delete("/", h.DeleteComment)
r.Post("/reactions", h.AddReaction)
r.Delete("/reactions", h.RemoveReaction)
})
// Agents