refactor(sse): move status indicator to avatar badge

Replace the standalone SSE dot above UserMenu with a small badge
overlaid on the bottom-right of the user avatar. Only visible when
status is connecting (yellow) or disconnected (red) — invisible in the
normal connected state, removing constant visual noise.
This commit is contained in:
Steven 2026-03-03 23:25:01 +08:00
parent ea0892a8b2
commit 0cf8805184
9 changed files with 54 additions and 73 deletions

View File

@ -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
}

View File

@ -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"})

View File

@ -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.

View File

@ -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)
})
}

View File

@ -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) => {
))}
</div>
{currentUser && (
<div className={cn("w-full flex flex-col justify-end gap-1", props.collapsed ? "items-center" : "items-start pl-3")}>
<div className={cn("flex items-center", props.collapsed ? "justify-center" : "pl-1")}>
<SSEStatusIndicator />
</div>
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
<UserMenu collapsed={collapsed} />
</div>
)}

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-center size-5 cursor-default" aria-label={label}>
<span
className={cn(
"block size-2 rounded-full transition-colors",
status === "connected" && "bg-green-500",
status === "connecting" && "bg-yellow-500 animate-pulse",
status === "disconnected" && "bg-red-500",
)}
/>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
};
export default SSEStatusIndicator;

View File

@ -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) => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={!currentUser}>
<div className={cn("w-auto flex flex-row justify-start items-center cursor-pointer text-foreground", collapsed ? "px-1" : "px-3")}>
{currentUser?.avatarUrl ? (
<UserAvatar className="shrink-0" avatarUrl={currentUser?.avatarUrl} />
) : (
<User2Icon className="w-6 mx-auto h-auto text-muted-foreground" />
)}
<div className="relative shrink-0">
{currentUser?.avatarUrl ? (
<UserAvatar avatarUrl={currentUser?.avatarUrl} />
) : (
<User2Icon className="w-6 mx-auto h-auto text-muted-foreground" />
)}
{sseStatus !== "connected" && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background",
sseStatus === "connecting" ? "bg-muted-foreground animate-pulse" : "bg-destructive",
)}
/>
</TooltipTrigger>
<TooltipContent side="right">{t(`sse.${sseStatus}` as Parameters<typeof t>[0])}</TooltipContent>
</Tooltip>
)}
</div>
{!collapsed && (
<span className="ml-2 text-lg font-medium text-foreground grow truncate">
{currentUser?.displayName || currentUser?.username}

View File

@ -64,6 +64,7 @@ export function useLiveMemoRefresh() {
const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS);
const abortControllerRef = useRef<AbortController | null>(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<typeof us
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
break;
case "memo.comment.created":
queryClient.invalidateQueries({ queryKey: memoKeys.comments(event.name) });
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });
break;
case "reaction.upserted":
case "reaction.deleted":
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });

View File

@ -463,5 +463,10 @@
"select-visibility": "Visibility",
"tags": "Tags",
"upload-attachment": "Upload Attachment(s)"
},
"sse": {
"connected": "Live updates active",
"connecting": "Connecting to live updates...",
"disconnected": "Live updates unavailable"
}
}