feat: extend live refresh to sync reactions across instances

Add SSE event broadcasting for reaction changes so that when a user
adds or removes a reaction on one device, all other open instances
see the update in real-time.

Backend:
- Rename MemoEvent/MemoEventType to SSEEvent/SSEEventType for generality
- Add reaction.upserted and reaction.deleted event types
- Broadcast events from UpsertMemoReaction and DeleteMemoReaction,
  using the reaction's ContentID (memo name) as the event name

Frontend:
- Handle reaction.upserted and reaction.deleted SSE events by
  invalidating the affected memo detail cache and memo lists
- Rename internal handler to handleSSEEvent to reflect broader scope

Co-authored-by: milvasic <milvasic@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-02-06 21:41:44 +00:00
parent 8c743c72ba
commit bab7a53d7a
4 changed files with 43 additions and 20 deletions

View File

@ -142,8 +142,8 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
}
// Broadcast live refresh event.
s.SSEHub.Broadcast(&MemoEvent{
Type: MemoEventCreated,
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoCreated,
Name: memoMessage.Name,
})
@ -478,8 +478,8 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
}
// Broadcast live refresh event.
s.SSEHub.Broadcast(&MemoEvent{
Type: MemoEventUpdated,
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoUpdated,
Name: memoMessage.Name,
})
@ -552,8 +552,8 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
}
// Broadcast live refresh event.
s.SSEHub.Broadcast(&MemoEvent{
Type: MemoEventDeleted,
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoDeleted,
Name: request.Name,
})

View File

@ -97,6 +97,12 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
reactionMessage := convertReactionFromStore(reaction)
// Broadcast live refresh event (reaction belongs to a memo).
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventReactionUpserted,
Name: request.Reaction.ContentId,
})
return reactionMessage, nil
}
@ -136,6 +142,12 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del
return nil, status.Errorf(codes.Internal, "failed to delete reaction")
}
// Broadcast live refresh event (reaction belongs to a memo).
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventReactionDeleted,
Name: reaction.ContentID,
})
return &emptypb.Empty{}, nil
}

View File

@ -5,24 +5,27 @@ import (
"sync"
)
// MemoEventType represents the type of memo change event.
type MemoEventType string
// SSEEventType represents the type of change event.
type SSEEventType string
const (
MemoEventCreated MemoEventType = "memo.created"
MemoEventUpdated MemoEventType = "memo.updated"
MemoEventDeleted MemoEventType = "memo.deleted"
SSEEventMemoCreated SSEEventType = "memo.created"
SSEEventMemoUpdated SSEEventType = "memo.updated"
SSEEventMemoDeleted SSEEventType = "memo.deleted"
SSEEventReactionUpserted SSEEventType = "reaction.upserted"
SSEEventReactionDeleted SSEEventType = "reaction.deleted"
)
// MemoEvent represents a memo change event sent to SSE clients.
type MemoEvent struct {
Type MemoEventType `json:"type"`
// Name is the memo resource name (e.g., "memos/xxxx").
// SSEEvent represents a change event sent to SSE clients.
type SSEEvent struct {
Type SSEEventType `json:"type"`
// Name is the affected resource name (e.g., "memos/xxxx").
// For reaction events, this is the memo resource name that the reaction belongs to.
Name string `json:"name"`
}
// JSON returns the JSON representation of the event.
func (e *MemoEvent) JSON() []byte {
func (e *SSEEvent) JSON() []byte {
data, _ := json.Marshal(e)
return data
}
@ -72,7 +75,7 @@ func (h *SSEHub) Unsubscribe(c *sseClient) {
// Broadcast sends an event to all connected clients.
// Slow clients that have a full buffer will have the event dropped
// to avoid blocking the broadcaster.
func (h *SSEHub) Broadcast(event *MemoEvent) {
func (h *SSEHub) Broadcast(event *SSEEvent) {
data := event.JSON()
h.mu.RLock()
defer h.mu.RUnlock()

View File

@ -81,7 +81,7 @@ export function useLiveMemoRefresh() {
const jsonStr = line.slice(6);
try {
const event = JSON.parse(jsonStr) as { type: string; name: string };
handleMemoEvent(event, queryClient);
handleSSEEvent(event, queryClient);
} catch {
// Ignore malformed JSON.
}
@ -119,12 +119,12 @@ export function useLiveMemoRefresh() {
}, [queryClient]);
}
interface MemoChangeEvent {
interface SSEChangeEvent {
type: string;
name: string;
}
function handleMemoEvent(event: MemoChangeEvent, queryClient: ReturnType<typeof useQueryClient>) {
function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType<typeof useQueryClient>) {
switch (event.type) {
case "memo.created":
// Invalidate memo lists so new memos appear.
@ -148,5 +148,13 @@ function handleMemoEvent(event: MemoChangeEvent, queryClient: ReturnType<typeof
// Invalidate user stats (memo count changed).
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
break;
case "reaction.upserted":
case "reaction.deleted":
// Reactions are embedded in the memo object, so invalidate the memo detail
// and lists to reflect the updated reaction state.
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
break;
}
}