mirror of https://github.com/usememos/memos.git
feat: table delete button, SSE status indicator, table editor UI polish
Five improvements: 1. Delete table button: A trash icon appears to the left of the edit pencil on rendered tables (on hover). Clicking it opens a confirmation dialog before removing the entire table from the memo content. 2. SSE connection status indicator: A small colored dot in the sidebar navigation (above the user menu) shows the live-refresh connection status: - Green = connected, live updates active - Yellow (pulsing) = connecting - Red = disconnected, updates not live Hover tooltip explains the current state. Uses useSyncExternalStore for efficient re-renders from a singleton status store. 3. Always-visible action buttons: Sort and delete buttons in the table editor are now always visible at 40% opacity (previously hidden until hover). They become fully opaque on hover for better discoverability. 4. Larger table editor dialog: Fixed size of 56rem x 44rem (capped to viewport) so the dialog is spacious regardless of table dimensions. The table area scrolls within the fixed frame. 5. Monospace font in table editor: All cell inputs use Fira Code with fallbacks to Fira Mono, JetBrains Mono, Cascadia Code, Consolas, and system monospace for better alignment when editing tabular data. Co-authored-by: milvasic <milvasic@users.noreply.github.com>
This commit is contained in:
parent
f95e4452a5
commit
61c78d0588
|
|
@ -1,6 +1,8 @@
|
|||
import { PencilIcon } from "lucide-react";
|
||||
import { PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import TableEditorDialog from "@/components/TableEditorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TableData } from "@/utils/markdown-table";
|
||||
|
|
@ -9,7 +11,7 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewCont
|
|||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table (root wrapper with edit button)
|
||||
// Table (root wrapper with edit + delete buttons)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
|
|
@ -19,6 +21,7 @@ interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdo
|
|||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableData, setTableData] = useState<TableData | null>(null);
|
||||
const [tableIndex, setTableIndex] = useState(-1);
|
||||
|
||||
|
|
@ -26,27 +29,26 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
|
|||
const { readonly } = useMemoViewDerived();
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
/** Resolve which markdown table index this rendered table corresponds to. */
|
||||
const resolveTableIndex = useCallback(() => {
|
||||
const container = tableRef.current?.closest('[class*="wrap-break-word"]');
|
||||
if (!container) return -1;
|
||||
|
||||
const allTables = container.querySelectorAll("table");
|
||||
for (let i = 0; i < allTables.length; i++) {
|
||||
if (tableRef.current?.contains(allTables[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}, []);
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Determine which table this is in the memo content by walking the DOM.
|
||||
const container = tableRef.current?.closest('[class*="wrap-break-word"]');
|
||||
if (!container) return;
|
||||
|
||||
const allTables = container.querySelectorAll("table");
|
||||
let idx = 0;
|
||||
for (let i = 0; i < allTables.length; i++) {
|
||||
if (tableRef.current?.contains(allTables[i])) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and parse the corresponding markdown table.
|
||||
const idx = resolveTableIndex();
|
||||
const tables = findAllTables(memo.content);
|
||||
if (idx >= tables.length) return;
|
||||
if (idx < 0 || idx >= tables.length) return;
|
||||
|
||||
const parsed = parseMarkdownTable(tables[idx].text);
|
||||
if (!parsed) return;
|
||||
|
|
@ -55,10 +57,24 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
|
|||
setTableIndex(idx);
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[memo.content],
|
||||
[memo.content, resolveTableIndex],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
const handleDeleteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const idx = resolveTableIndex();
|
||||
if (idx < 0) return;
|
||||
|
||||
setTableIndex(idx);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
[resolveTableIndex],
|
||||
);
|
||||
|
||||
const handleConfirmEdit = useCallback(
|
||||
(markdown: string) => {
|
||||
if (tableIndex < 0) return;
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, markdown);
|
||||
|
|
@ -70,6 +86,17 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
|
|||
[memo.content, memo.name, tableIndex, updateMemo],
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
if (tableIndex < 0) return;
|
||||
// Replace the table with an empty string to delete it.
|
||||
const newContent = replaceNthTable(memo.content, tableIndex, "");
|
||||
updateMemo({
|
||||
update: { name: memo.name, content: newContent },
|
||||
updateMask: ["content"],
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
}, [memo.content, memo.name, tableIndex, updateMemo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={tableRef} className="group/table relative w-full overflow-x-auto rounded-lg border border-border my-2">
|
||||
|
|
@ -77,17 +104,46 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps
|
|||
{children}
|
||||
</table>
|
||||
{!readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded bg-accent/80 text-muted-foreground opacity-0 group-hover/table:opacity-100 hover:bg-accent hover:text-foreground transition-all"
|
||||
onClick={handleEditClick}
|
||||
title="Edit table"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover/table:opacity-100 transition-all">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
title="Delete table"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={handleEditClick}
|
||||
title="Edit table"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TableEditorDialog open={dialogOpen} onOpenChange={setDialogOpen} initialData={tableData} onConfirm={handleConfirm} />
|
||||
|
||||
<TableEditorDialog open={dialogOpen} onOpenChange={setDialogOpen} initialData={tableData} onConfirm={handleConfirmEdit} />
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete table</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to delete this table? This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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 {
|
||||
|
|
@ -114,7 +115,10 @@ const Navigation = (props: Props) => {
|
|||
))}
|
||||
</div>
|
||||
{currentUser && (
|
||||
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
|
||||
<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>
|
||||
<UserMenu collapsed={collapsed} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
|
|
@ -8,6 +8,13 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { VisuallyHidden } from "./ui/visually-hidden";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Monospace font stack for the cell inputs. */
|
||||
const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -121,7 +128,6 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
const sorted = [...prev].sort((a, b) => {
|
||||
const va = (a[col] || "").toLowerCase();
|
||||
const vb = (b[col] || "").toLowerCase();
|
||||
// Try numeric comparison first.
|
||||
const na = Number(va);
|
||||
const nb = Number(vb);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
||||
|
|
@ -143,23 +149,18 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
let nextRow = row;
|
||||
|
||||
if (nextCol >= colCount) {
|
||||
// Move to first cell of next row.
|
||||
if (row < rowCount - 1) {
|
||||
nextRow = row + 1;
|
||||
focusCell(nextRow, 0);
|
||||
} else {
|
||||
// At last cell – add a new row and focus it.
|
||||
addRow();
|
||||
// Need to wait for state update; use setTimeout.
|
||||
setTimeout(() => focusCell(rowCount, 0), 0);
|
||||
}
|
||||
} else if (nextCol < 0) {
|
||||
// Move to last cell of previous row.
|
||||
if (row > 0) {
|
||||
nextRow = row - 1;
|
||||
focusCell(nextRow, colCount - 1);
|
||||
} else {
|
||||
// Move to header row.
|
||||
focusCell(-1, colCount - 1);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -189,12 +190,12 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
if (sortState?.col === col) {
|
||||
return sortState.dir === "asc" ? <ArrowUpIcon className="size-3 text-primary" /> : <ArrowDownIcon className="size-3 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDownIcon className="size-3 opacity-0 group-hover/sort:opacity-60" />;
|
||||
return <ArrowUpDownIcon className="size-3 opacity-40" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="2xl" className="p-0!" showCloseButton={false}>
|
||||
<DialogContent size="full" className="p-0! w-[min(56rem,calc(100vw-2rem))] h-[min(44rem,calc(100vh-4rem))]" showCloseButton={false}>
|
||||
<VisuallyHidden>
|
||||
<DialogClose />
|
||||
</VisuallyHidden>
|
||||
|
|
@ -204,54 +205,53 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
<VisuallyHidden>
|
||||
<DialogDescription>Edit table headers, rows, columns and sort data</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Scrollable table area */}
|
||||
<div className="overflow-auto max-h-[60vh] p-4 pb-2">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Scrollable table area — grows to fill */}
|
||||
<div className="flex-1 overflow-auto p-4 pb-2">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
{/* Header row */}
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Row number column */}
|
||||
<th className="w-8 min-w-8" />
|
||||
<th className="w-10 min-w-10" />
|
||||
{headers.map((header, col) => (
|
||||
<th key={col} className="p-0 min-w-[120px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
ref={(el) => setInputRef(`-1:${col}`, el)}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={header}
|
||||
onChange={(e) => updateHeader(col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, -1, col)}
|
||||
placeholder={`Col ${col + 1}`}
|
||||
/>
|
||||
<th key={col} className="p-0 min-w-[140px]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<input
|
||||
ref={(el) => setInputRef(`-1:${col}`, el)}
|
||||
style={{ fontFamily: MONO_FONT }}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-accent/50 border border-border rounded-tl-md focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
value={header}
|
||||
onChange={(e) => updateHeader(col, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, -1, col)}
|
||||
placeholder={`Col ${col + 1}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded hover:bg-accent transition-colors"
|
||||
onClick={() => sortByColumn(col)}
|
||||
>
|
||||
<SortIndicator col={col} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sort column</TooltipContent>
|
||||
</Tooltip>
|
||||
{colCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group/sort flex items-center justify-center size-7 rounded hover:bg-accent transition-colors"
|
||||
onClick={() => sortByColumn(col)}
|
||||
className="flex items-center justify-center size-7 rounded opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
onClick={() => removeColumn(col)}
|
||||
>
|
||||
<SortIndicator col={col} />
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sort column</TooltipContent>
|
||||
<TooltipContent>Remove column</TooltipContent>
|
||||
</Tooltip>
|
||||
{colCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-7 rounded opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-60 hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
onClick={() => removeColumn(col)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove column</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
|
|
@ -277,15 +277,15 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="group">
|
||||
{/* Row number + remove */}
|
||||
<td className="w-8 min-w-8 text-center align-middle">
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground group-hover:hidden">{rowIdx + 1}</span>
|
||||
<td className="w-10 min-w-10 text-center align-middle">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<span className="text-xs text-muted-foreground w-4 text-right">{rowIdx + 1}</span>
|
||||
{rowCount > 1 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden group-hover:flex items-center justify-center size-6 rounded hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
className="flex items-center justify-center size-5 rounded opacity-40 hover:opacity-100 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
|
|
@ -300,6 +300,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
|
|||
<td key={col} className="p-0">
|
||||
<input
|
||||
ref={(el) => setInputRef(`${rowIdx}:${col}`, el)}
|
||||
style={{ fontFamily: MONO_FONT }}
|
||||
className={cn(
|
||||
"w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||
rowIdx === rowCount - 1 && "rounded-bl-md",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { getAccessToken } from "@/auth-state";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
|
|
@ -11,10 +11,49 @@ const INITIAL_RETRY_DELAY_MS = 1000;
|
|||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
const RETRY_BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared connection status store (singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEConnectionStatus = "connected" | "disconnected" | "connecting";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let _status: SSEConnectionStatus = "disconnected";
|
||||
const _listeners = new Set<Listener>();
|
||||
|
||||
function getSSEStatus(): SSEConnectionStatus {
|
||||
return _status;
|
||||
}
|
||||
|
||||
function setSSEStatus(s: SSEConnectionStatus) {
|
||||
if (_status !== s) {
|
||||
_status = s;
|
||||
_listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeSSEStatus(listener: Listener): () => void {
|
||||
_listeners.add(listener);
|
||||
return () => _listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that returns the current SSE connection status.
|
||||
* Re-renders the component whenever the status changes.
|
||||
*/
|
||||
export function useSSEConnectionStatus(): SSEConnectionStatus {
|
||||
return useSyncExternalStore(subscribeSSEStatus, getSSEStatus, getSSEStatus);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* useLiveMemoRefresh connects to the server's SSE endpoint and
|
||||
* invalidates relevant React Query caches when memo change events
|
||||
* (created, updated, deleted) are received.
|
||||
* invalidates relevant React Query caches when change events
|
||||
* (memos, reactions) are received.
|
||||
*
|
||||
* This enables real-time updates across all open instances of the app.
|
||||
*/
|
||||
|
|
@ -23,6 +62,8 @@ export function useLiveMemoRefresh() {
|
|||
const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -32,11 +73,13 @@ export function useLiveMemoRefresh() {
|
|||
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setSSEStatus("disconnected");
|
||||
// Not logged in; retry after a delay in case the user logs in.
|
||||
retryTimeout = setTimeout(connect, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
setSSEStatus("connecting");
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
|
|
@ -55,6 +98,7 @@ export function useLiveMemoRefresh() {
|
|||
|
||||
// Successfully connected - reset retry delay.
|
||||
retryDelayRef.current = INITIAL_RETRY_DELAY_MS;
|
||||
setSSEStatus("connected");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
|
@ -80,8 +124,8 @@ export function useLiveMemoRefresh() {
|
|||
if (line.startsWith("data: ")) {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(jsonStr) as { type: string; name: string };
|
||||
handleSSEEvent(event, queryClient);
|
||||
const event = JSON.parse(jsonStr) as SSEChangeEvent;
|
||||
handleEvent(event);
|
||||
} catch {
|
||||
// Ignore malformed JSON.
|
||||
}
|
||||
|
|
@ -92,11 +136,14 @@ export function useLiveMemoRefresh() {
|
|||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Intentional abort, don't reconnect.
|
||||
setSSEStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
// Connection lost or failed - reconnect with backoff.
|
||||
}
|
||||
|
||||
setSSEStatus("disconnected");
|
||||
|
||||
// Reconnect with exponential backoff.
|
||||
if (mounted) {
|
||||
const delay = retryDelayRef.current;
|
||||
|
|
@ -109,6 +156,7 @@ export function useLiveMemoRefresh() {
|
|||
|
||||
return () => {
|
||||
mounted = false;
|
||||
setSSEStatus("disconnected");
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
|
|
@ -116,9 +164,13 @@ export function useLiveMemoRefresh() {
|
|||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [queryClient]);
|
||||
}, [handleEvent]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SSEChangeEvent {
|
||||
type: string;
|
||||
name: string;
|
||||
|
|
@ -127,32 +179,23 @@ interface SSEChangeEvent {
|
|||
function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType<typeof useQueryClient>) {
|
||||
switch (event.type) {
|
||||
case "memo.created":
|
||||
// Invalidate memo lists so new memos appear.
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
// Invalidate user stats (memo count changed).
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
break;
|
||||
|
||||
case "memo.updated":
|
||||
// Invalidate the specific memo detail cache.
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) });
|
||||
// Invalidate memo lists to reflect updated content/ordering.
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
break;
|
||||
|
||||
case "memo.deleted":
|
||||
// Remove the specific memo from cache.
|
||||
queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) });
|
||||
// Invalidate memo lists.
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
// 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