refactor: enhance message streaming and UI components; add SWR provider
This commit is contained in:
@@ -102,10 +102,14 @@ export function MessagesView({
|
||||
channelId ?? undefined,
|
||||
initialMessages ?? undefined,
|
||||
);
|
||||
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||
// Stream history over WS.
|
||||
// Drives snapshots into the SWR list above buffered by rAF; falls back to the
|
||||
// SSR `initialMessages` seed if WS is unavailable.
|
||||
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||
const { streaming } = useMessagesStream(
|
||||
ws,
|
||||
guildId ?? "",
|
||||
channelId ?? undefined,
|
||||
);
|
||||
// Cursor to the next (older) page + whether more history exists.
|
||||
const { data: pageInfo } = useMessagesHasMore(
|
||||
guildId ?? "",
|
||||
@@ -218,10 +222,11 @@ export function MessagesView({
|
||||
prevLen.current = list.length;
|
||||
}, [list.length, searching]);
|
||||
|
||||
// Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation
|
||||
const streamRef = useStaggerReveal<HTMLDivElement>(".msg-feed-card", {
|
||||
stagger: 0.02,
|
||||
y: 6,
|
||||
dependencies: [display.length, viewMode],
|
||||
dependencies: [viewMode],
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -233,6 +238,12 @@ export function MessagesView({
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Chat Log Stream · Ingestion Stream
|
||||
</h1>
|
||||
{streaming && (
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-signal animate-pulse">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
STREAMING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||
<span>MODE:</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
||||
import { Bricolage_Grotesque, Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/primitives/toast";
|
||||
import { SwrProvider } from "@/components/providers";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -47,6 +48,7 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<SwrProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
@@ -57,6 +59,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
<Toaster position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</SwrProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SWRConfig } from "swr";
|
||||
import { swrConfig } from "@/lib/swr-config";
|
||||
|
||||
/**
|
||||
* Client-side SWR provider. Lives in its own client component so the config's
|
||||
* callbacks (shouldRetryOnError / onErrorRetry) never cross the server→client
|
||||
* boundary from the server-rendered root layout.
|
||||
*/
|
||||
export function SwrProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SWRConfig value={swrConfig}>{children}</SWRConfig>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import gsap from "gsap";
|
||||
import { type RefObject, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(useGSAP);
|
||||
@@ -69,64 +69,3 @@ export function useStaggerReveal<T extends HTMLElement = HTMLDivElement>(
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated number counter using GSAP.
|
||||
*/
|
||||
export function useCounter(
|
||||
targetValue: number,
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
formatter?: (val: number) => string,
|
||||
) {
|
||||
useGSAP(
|
||||
() => {
|
||||
if (!ref.current) return;
|
||||
const obj = { val: 0 };
|
||||
gsap.to(obj, {
|
||||
val: targetValue,
|
||||
duration: 0.75,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatter
|
||||
? formatter(obj.val)
|
||||
: Math.round(obj.val).toLocaleString();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
{ dependencies: [targetValue] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Micro-interaction hook for interactive elements (hover card tilt/glow, pulse)
|
||||
*/
|
||||
export function useLinearHover<T extends HTMLElement = HTMLDivElement>() {
|
||||
const elementRef = useRef<T>(null);
|
||||
|
||||
useGSAP(
|
||||
(_, contextSafe) => {
|
||||
if (!elementRef.current || !contextSafe) return;
|
||||
const el = elementRef.current;
|
||||
|
||||
const onEnter = contextSafe(() => {
|
||||
gsap.to(el, { y: -2, duration: 0.18, ease: "power2.out" });
|
||||
});
|
||||
const onLeave = contextSafe(() => {
|
||||
gsap.to(el, { y: 0, duration: 0.22, ease: "power2.out" });
|
||||
});
|
||||
|
||||
el.addEventListener("mouseenter", onEnter);
|
||||
el.addEventListener("mouseleave", onLeave);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("mouseenter", onEnter);
|
||||
el.removeEventListener("mouseleave", onLeave);
|
||||
};
|
||||
},
|
||||
{ scope: elementRef },
|
||||
);
|
||||
|
||||
return elementRef;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useAction } from "@/hooks/use-action";
|
||||
import { messagesApi, voiceApi } from "@/lib/api";
|
||||
@@ -342,10 +342,10 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a channel/guild history ONE message per WS frame (no 50-row batch).
|
||||
* Calls the backend `stream_messages` handler and accumulates each incoming
|
||||
* `message_snapshot` into the SWR list as it arrives, so the UI renders
|
||||
* progressively. Falls back to the batched `messagesApi.list` if WS is down.
|
||||
* Stream a channel/guild history over WS.
|
||||
* Buffers incoming `message_snapshot` frames with rAF/debounce and flushes in batches,
|
||||
* preventing layout thrashing & SWR cascading re-renders during high frame counts (e.g. 200).
|
||||
* Gates sending `stream_messages` on `ws.status !== "disconnected"` or sends when status is ready.
|
||||
*
|
||||
* Returns: { streaming, error }.
|
||||
*/
|
||||
@@ -358,32 +358,64 @@ export function useMessagesStream(
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const bufferRef = useRef<MessageRecord[]>([]);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guildId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const key = msgKeys.list(guildId, channelId ?? undefined);
|
||||
const unsubSnap = ws.on("message_snapshot", (data) => {
|
||||
if (cancelled) return;
|
||||
const msg = data as MessageRecord;
|
||||
if (channelId && msg.channel_id !== channelId) return;
|
||||
if (!channelId && msg.guild_id && msg.guild_id !== guildId) return;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (bufferRef.current.length === 0) return;
|
||||
const incoming = bufferRef.current;
|
||||
bufferRef.current = [];
|
||||
|
||||
void mutate(
|
||||
key,
|
||||
(old: MessagePage | undefined): MessagePage => {
|
||||
const data2 = old?.data ?? [];
|
||||
if (data2.some((m) => m.id === msg.id))
|
||||
const oldData = old?.data ?? [];
|
||||
const existingIds = new Set(oldData.map((m) => m.id));
|
||||
const newItems = incoming.filter((m) => !existingIds.has(m.id));
|
||||
if (newItems.length === 0)
|
||||
return old ?? { data: [], nextCursor: null };
|
||||
|
||||
return {
|
||||
data: sortMessages([msg, ...data2]),
|
||||
data: sortMessages([...newItems, ...oldData]),
|
||||
nextCursor: old?.nextCursor ?? null,
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (rafIdRef.current !== null) return;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
flushBuffer();
|
||||
});
|
||||
};
|
||||
|
||||
const unsubSnap = ws.on("message_snapshot", (data) => {
|
||||
if (cancelled) return;
|
||||
const msg = data as MessageRecord;
|
||||
if (channelId && msg.channel_id !== channelId) return;
|
||||
if (!channelId && msg.guild_id && msg.guild_id !== guildId) return;
|
||||
|
||||
bufferRef.current.push(msg);
|
||||
scheduleFlush();
|
||||
});
|
||||
|
||||
const unsubEnd = ws.on("message_snapshot_end", (data) => {
|
||||
if (cancelled) return;
|
||||
// Flush any remaining buffered snapshots immediately
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
flushBuffer();
|
||||
|
||||
const end = data as {
|
||||
sent: number;
|
||||
nextCursor: string | null;
|
||||
@@ -391,6 +423,7 @@ export function useMessagesStream(
|
||||
};
|
||||
setStreaming(false);
|
||||
setError(Boolean(end.error));
|
||||
|
||||
// Persist the next-page cursor so "load older" still works after streaming.
|
||||
if (end.nextCursor) {
|
||||
void mutate(
|
||||
@@ -404,6 +437,8 @@ export function useMessagesStream(
|
||||
}
|
||||
});
|
||||
|
||||
// Send stream request if ws.status is connected (or not provided/undefined)
|
||||
if (ws.status === undefined || ws.status === "connected") {
|
||||
setStreaming(true);
|
||||
setError(false);
|
||||
ws.sendText(
|
||||
@@ -412,13 +447,21 @@ export function useMessagesStream(
|
||||
payload: { guildId, channelId: channelId ?? undefined, limit: 200 },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setStreaming(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
bufferRef.current = [];
|
||||
unsubSnap();
|
||||
unsubEnd();
|
||||
};
|
||||
}, [ws, guildId, channelId, mutate]);
|
||||
}, [ws.status, ws.sendText, ws.on, guildId, channelId, mutate]);
|
||||
|
||||
return { streaming, error };
|
||||
}
|
||||
|
||||
@@ -4,16 +4,3 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an inline style that staggers a list item's entrance animation.
|
||||
* Pair with the `animate-stagger` class. Caps the delay so long lists still
|
||||
* appear promptly.
|
||||
*/
|
||||
export function staggerDelay(
|
||||
index: number,
|
||||
step = 45,
|
||||
max = 600,
|
||||
): React.CSSProperties {
|
||||
return { animationDelay: `${Math.min(index * step, max)}ms` };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WsEventType } from "./ws/types";
|
||||
import type { WsEventType, WsStatus } from "./ws/types";
|
||||
|
||||
export type WsHook = {
|
||||
status?: WsStatus;
|
||||
on: <E extends WsEventType>(
|
||||
eventType: E,
|
||||
handler: (data: unknown) => void,
|
||||
|
||||
Reference in New Issue
Block a user