mirror of https://github.com/usememos/memos.git
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:
parent
8c743c72ba
commit
bab7a53d7a
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue