diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index 3c9aa1ecd..b1d881e2c 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -647,6 +647,12 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err)) } + // Broadcast live refresh event for the parent memo so subscribers see the new comment. + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoCommentCreated, + Name: request.Name, + }) + return memoComment, nil } diff --git a/server/router/api/v1/sse_handler.go b/server/router/api/v1/sse_handler.go index 09379804c..07b36d01c 100644 --- a/server/router/api/v1/sse_handler.go +++ b/server/router/api/v1/sse_handler.go @@ -26,18 +26,10 @@ func RegisterSSERoutes(echoServer *echo.Echo, hub *SSEHub, storeInstance *store. } // handleSSE handles the SSE connection for live memo refresh. -// Authentication is done via Bearer token in the Authorization header, -// or via the "token" query parameter (for EventSource which cannot set headers). +// Authentication is done via Bearer token in the Authorization header. func handleSSE(c *echo.Context, hub *SSEHub, authenticator *auth.Authenticator) error { // Authenticate the request. authHeader := c.Request().Header.Get("Authorization") - if authHeader == "" { - // Fall back to query parameter for native EventSource support. - if token := c.QueryParam("token"); token != "" { - authHeader = "Bearer " + token - } - } - result := authenticator.Authenticate(c.Request().Context(), authHeader) if result == nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) diff --git a/server/router/api/v1/sse_hub.go b/server/router/api/v1/sse_hub.go index 245ac7b56..1fecad8af 100644 --- a/server/router/api/v1/sse_hub.go +++ b/server/router/api/v1/sse_hub.go @@ -10,11 +10,12 @@ import ( type SSEEventType string const ( - SSEEventMemoCreated SSEEventType = "memo.created" - SSEEventMemoUpdated SSEEventType = "memo.updated" - SSEEventMemoDeleted SSEEventType = "memo.deleted" - SSEEventReactionUpserted SSEEventType = "reaction.upserted" - SSEEventReactionDeleted SSEEventType = "reaction.deleted" + SSEEventMemoCreated SSEEventType = "memo.created" + SSEEventMemoUpdated SSEEventType = "memo.updated" + SSEEventMemoDeleted SSEEventType = "memo.deleted" + SSEEventMemoCommentCreated SSEEventType = "memo.comment.created" + SSEEventReactionUpserted SSEEventType = "reaction.upserted" + SSEEventReactionDeleted SSEEventType = "reaction.deleted" ) // SSEEvent represents a change event sent to SSE clients. diff --git a/server/router/api/v1/test/sse_handler_test.go b/server/router/api/v1/test/sse_handler_test.go index d182b279a..c06f9e3fd 100644 --- a/server/router/api/v1/test/sse_handler_test.go +++ b/server/router/api/v1/test/sse_handler_test.go @@ -69,18 +69,10 @@ func TestSSEHandler_Authentication(t *testing.T) { require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type")) }) - t.Run("token in query param returns 200", func(t *testing.T) { - reqCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - req := httptest.NewRequest(http.MethodGet, "/api/v1/sse?token="+token, nil).WithContext(reqCtx) + t.Run("token in query param returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/sse?token="+token, nil) rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - defer close(done) - e.ServeHTTP(rec, req) - }() - cancel() - <-done - require.Equal(t, http.StatusOK, rec.Code) + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code) }) } diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index faf07c55b..484f0837b 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -8,7 +8,6 @@ import { Routes } from "@/router"; import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "./MemosLogo"; -import SSEStatusIndicator from "./SSEStatusIndicator"; import UserMenu from "./UserMenu"; interface NavLinkItem { @@ -115,10 +114,7 @@ const Navigation = (props: Props) => { ))} {currentUser && ( -
-
- -
+
)} diff --git a/web/src/components/SSEStatusIndicator.tsx b/web/src/components/SSEStatusIndicator.tsx deleted file mode 100644 index ca4df407d..000000000 --- a/web/src/components/SSEStatusIndicator.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; -import { cn } from "@/lib/utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; - -/** - * A small colored dot that indicates the SSE live-refresh connection status. - * - Green = connected (live updates active) - * - Yellow/pulsing = connecting - * - Red = disconnected (updates not live) - */ -const SSEStatusIndicator = () => { - const status = useSSEConnectionStatus(); - - const label = - status === "connected" ? "Live updates active" : status === "connecting" ? "Connecting to live updates..." : "Live updates unavailable"; - - return ( - - - - - - - {label} - - ); -}; - -export default SSEStatusIndicator; diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 9d90b79a4..8a9324b68 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -1,6 +1,7 @@ import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; +import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; import useNavigateTo from "@/hooks/useNavigateTo"; import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries"; import { locales } from "@/i18n"; @@ -18,6 +19,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface Props { collapsed?: boolean; @@ -30,6 +32,7 @@ const UserMenu = (props: Props) => { const currentUser = useCurrentUser(); const { userGeneralSetting, refetchSettings, logout } = useAuth(); const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name); + const sseStatus = useSSEConnectionStatus(); const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale); const currentTheme = getThemeWithFallback(userGeneralSetting?.theme); @@ -93,11 +96,26 @@ const UserMenu = (props: Props) => {
- {currentUser?.avatarUrl ? ( - - ) : ( - - )} +
+ {currentUser?.avatarUrl ? ( + + ) : ( + + )} + {sseStatus !== "connected" && ( + + + + + {t(`sse.${sseStatus}` as Parameters[0])} + + )} +
{!collapsed && ( {currentUser?.displayName || currentUser?.username} diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts index 6b57bce96..3c0d8bfdb 100644 --- a/web/src/hooks/useLiveMemoRefresh.ts +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -64,6 +64,7 @@ export function useLiveMemoRefresh() { const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS); const abortControllerRef = useRef(null); + const currentUserName = currentUser?.name; const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]); useEffect(() => { @@ -158,6 +159,7 @@ export function useLiveMemoRefresh() { return () => { mounted = false; setSSEStatus("disconnected"); + retryDelayRef.current = INITIAL_RETRY_DELAY_MS; if (retryTimeout) { clearTimeout(retryTimeout); } @@ -165,7 +167,7 @@ export function useLiveMemoRefresh() { abortControllerRef.current.abort(); } }; - }, [handleEvent, currentUser]); + }, [handleEvent, currentUserName]); } // --------------------------------------------------------------------------- @@ -195,6 +197,11 @@ function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType