refactor: enhance message streaming and UI components; add SWR provider

This commit is contained in:
asepharyana
2026-08-26 16:51:46 +07:00
parent 37d15456dc
commit b640079cb6
7 changed files with 110 additions and 113 deletions
@@ -102,10 +102,14 @@ export function MessagesView({
channelId ?? undefined, channelId ?? undefined,
initialMessages ?? undefined, initialMessages ?? undefined,
); );
// Stream history one message per WS frame (replaces the 50-row batched fetch). // Stream history over WS.
// Drives snapshots into the SWR list above as they arrive; falls back to the // Drives snapshots into the SWR list above buffered by rAF; falls back to the
// SSR `initialMessages` seed if WS is unavailable. // 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. // Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore( const { data: pageInfo } = useMessagesHasMore(
guildId ?? "", guildId ?? "",
@@ -218,10 +222,11 @@ export function MessagesView({
prevLen.current = list.length; prevLen.current = list.length;
}, [list.length, searching]); }, [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", { const streamRef = useStaggerReveal<HTMLDivElement>(".msg-feed-card", {
stagger: 0.02, stagger: 0.02,
y: 6, y: 6,
dependencies: [display.length, viewMode], dependencies: [viewMode],
}); });
return ( return (
@@ -233,6 +238,12 @@ export function MessagesView({
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase"> <h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Chat Log Stream · Ingestion Stream Chat Log Stream · Ingestion Stream
</h1> </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>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted"> <div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>MODE:</span> <span>MODE:</span>
+13 -10
View File
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Bricolage_Grotesque, Inter, JetBrains_Mono } from "next/font/google"; import { Bricolage_Grotesque, Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/primitives/toast"; import { Toaster } from "@/components/primitives/toast";
import { SwrProvider } from "@/components/providers";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = Inter({
@@ -47,16 +48,18 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<ThemeProvider <SwrProvider>
attribute="class" <ThemeProvider
defaultTheme="dark" attribute="class"
enableSystem={false} defaultTheme="dark"
enableColorScheme={false} enableSystem={false}
disableTransitionOnChange enableColorScheme={false}
> disableTransitionOnChange
{children} >
<Toaster position="bottom-right" /> {children}
</ThemeProvider> <Toaster position="bottom-right" />
</ThemeProvider>
</SwrProvider>
</body> </body>
</html> </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 { useGSAP } from "@gsap/react";
import gsap from "gsap"; import gsap from "gsap";
import { type RefObject, useRef } from "react"; import { useRef } from "react";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
gsap.registerPlugin(useGSAP); gsap.registerPlugin(useGSAP);
@@ -69,64 +69,3 @@ export function useStaggerReveal<T extends HTMLElement = HTMLDivElement>(
return containerRef; 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;
}
+66 -23
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action"; import { useAction } from "@/hooks/use-action";
import { messagesApi, voiceApi } from "@/lib/api"; 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). * Stream a channel/guild history over WS.
* Calls the backend `stream_messages` handler and accumulates each incoming * Buffers incoming `message_snapshot` frames with rAF/debounce and flushes in batches,
* `message_snapshot` into the SWR list as it arrives, so the UI renders * preventing layout thrashing & SWR cascading re-renders during high frame counts (e.g. 200).
* progressively. Falls back to the batched `messagesApi.list` if WS is down. * Gates sending `stream_messages` on `ws.status !== "disconnected"` or sends when status is ready.
* *
* Returns: { streaming, error }. * Returns: { streaming, error }.
*/ */
@@ -358,32 +358,64 @@ export function useMessagesStream(
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const bufferRef = useRef<MessageRecord[]>([]);
const rafIdRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!guildId) return; if (!guildId) return;
let cancelled = false; let cancelled = false;
const key = msgKeys.list(guildId, channelId ?? undefined); const key = msgKeys.list(guildId, channelId ?? undefined);
const unsubSnap = ws.on("message_snapshot", (data) => {
if (cancelled) return; const flushBuffer = () => {
const msg = data as MessageRecord; if (bufferRef.current.length === 0) return;
if (channelId && msg.channel_id !== channelId) return; const incoming = bufferRef.current;
if (!channelId && msg.guild_id && msg.guild_id !== guildId) return; bufferRef.current = [];
void mutate( void mutate(
key, key,
(old: MessagePage | undefined): MessagePage => { (old: MessagePage | undefined): MessagePage => {
const data2 = old?.data ?? []; const oldData = old?.data ?? [];
if (data2.some((m) => m.id === msg.id)) 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 old ?? { data: [], nextCursor: null };
return { return {
data: sortMessages([msg, ...data2]), data: sortMessages([...newItems, ...oldData]),
nextCursor: old?.nextCursor ?? null, nextCursor: old?.nextCursor ?? null,
}; };
}, },
{ revalidate: false }, { 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) => { const unsubEnd = ws.on("message_snapshot_end", (data) => {
if (cancelled) return; if (cancelled) return;
// Flush any remaining buffered snapshots immediately
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
flushBuffer();
const end = data as { const end = data as {
sent: number; sent: number;
nextCursor: string | null; nextCursor: string | null;
@@ -391,6 +423,7 @@ export function useMessagesStream(
}; };
setStreaming(false); setStreaming(false);
setError(Boolean(end.error)); setError(Boolean(end.error));
// Persist the next-page cursor so "load older" still works after streaming. // Persist the next-page cursor so "load older" still works after streaming.
if (end.nextCursor) { if (end.nextCursor) {
void mutate( void mutate(
@@ -404,21 +437,31 @@ export function useMessagesStream(
} }
}); });
setStreaming(true); // Send stream request if ws.status is connected (or not provided/undefined)
setError(false); if (ws.status === undefined || ws.status === "connected") {
ws.sendText( setStreaming(true);
JSON.stringify({ setError(false);
type: "stream_messages", ws.sendText(
payload: { guildId, channelId: channelId ?? undefined, limit: 200 }, JSON.stringify({
}), type: "stream_messages",
); payload: { guildId, channelId: channelId ?? undefined, limit: 200 },
}),
);
} else {
setStreaming(false);
}
return () => { return () => {
cancelled = true; cancelled = true;
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
bufferRef.current = [];
unsubSnap(); unsubSnap();
unsubEnd(); unsubEnd();
}; };
}, [ws, guildId, channelId, mutate]); }, [ws.status, ws.sendText, ws.on, guildId, channelId, mutate]);
return { streaming, error }; return { streaming, error };
} }
-13
View File
@@ -4,16 +4,3 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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` };
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WsEventType } from "./ws/types"; import type { WsEventType, WsStatus } from "./ws/types";
export type WsHook = { export type WsHook = {
status?: WsStatus;
on: <E extends WsEventType>( on: <E extends WsEventType>(
eventType: E, eventType: E,
handler: (data: unknown) => void, handler: (data: unknown) => void,