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,
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>
+3
View File
@@ -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;
}
+58 -15
View File
@@ -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 };
}
-13
View File
@@ -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` };
}
+2 -1
View File
@@ -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,