mirror of https://github.com/usememos/memos.git
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:
parent
ea0892a8b2
commit
0cf8805184
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue