refactor: enhance message streaming and UI components; add SWR provider
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +48,7 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
<SwrProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="dark"
|
||||||
@@ -57,6 +59,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
</ThemeProvider>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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);
|
setStreaming(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
ws.sendText(
|
ws.sendText(
|
||||||
@@ -412,13 +447,21 @@ export function useMessagesStream(
|
|||||||
payload: { guildId, channelId: channelId ?? undefined, limit: 200 },
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user