"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import {
ActivityFeed,
type ActivityFeedItem,
type ActivityFeedProps,
} from "@/components/ui/activity-feed"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
export type LiveFeedTone = "success" | "warning" | "danger" | "info" | "neutral"
/** One thing that just happened: what it was, how it went, and when. */
export type LiveFeedEvent = {
id: string
text: string
tone: LiveFeedTone
at: Date
}
/** The pill each tone wears. A tone is a meaning, so it resolves to a token, never a literal. */
const TONE_VARIANT: Record<LiveFeedTone, StatusVariant> = {
success: "success",
warning: "warning",
danger: "danger",
info: "info",
neutral: "neutral",
}
const TONE_LABEL: Record<LiveFeedTone, string> = {
success: "OK",
warning: "Warn",
danger: "Error",
info: "Info",
neutral: "Note",
}
/** How many rows the feed holds before the oldest falls off the bottom. */
const DEFAULT_LIMIT = 8
export type LiveFeedProps = Omit<ActivityFeedProps, "items"> & {
/** Called for each new row. Seed it, so the server's first frame and the client's agree. */
source: () => LiveFeedEvent
/** Milliseconds between rows. */
every: number
limit?: number
/** False parks the feed on the rows it already has. */
live?: boolean
}
/** The rows a feed opens on: `limit` of them, newest first, drawn before anything renders. */
function firstFrame(source: () => LiveFeedEvent, limit: number): LiveFeedEvent[] {
const rows: LiveFeedEvent[] = []
for (let i = 0; i < limit; i++) rows.unshift(source())
return rows
}
/** Whether the document is currently on screen. False on the server, where it is not. */
function useHidden(): boolean {
const [hidden, setHidden] = React.useState(false)
React.useEffect(() => {
const read = () => setHidden(document.hidden)
read()
document.addEventListener("visibilitychange", read)
return () => document.removeEventListener("visibilitychange", read)
}, [])
return hidden
}
/**
* An activity feed whose rows arrive on their own.
*
* The first `limit` rows are drawn before anything renders, so the server sends
* a real frame rather than an empty box waiting for a tick — which is why the
* source has to be seeded rather than random. After that a row arrives every
* `every` milliseconds, newest first, and the oldest falls off at `limit`.
*
* It stops when nobody is watching: a hidden tab, a reader who has asked for
* less motion, or `live={false}` all park it on the rows it already has, and
* `data-live` says which it is. The list is a polite live region, so a new row
* is announced once, after whatever the reader is doing.
*/
function LiveFeed({
className,
source,
every,
limit = DEFAULT_LIMIT,
live = true,
...props
}: LiveFeedProps) {
const root = React.useRef<HTMLDivElement | null>(null)
const [rows, setRows] = React.useState(() => firstFrame(source, limit))
const hidden = useHidden()
const reduced = useReducedMotion(root)
const running = live && !hidden && !reduced && every > 0
useInterval(() => {
setRows((current) => [source(), ...current].slice(0, limit))
}, running ? every : null)
const items: ActivityFeedItem[] = rows.map((row) => ({
id: row.id,
time: row.at,
action: (
<span className="flex min-w-0 items-baseline gap-2">
<StatusBadge
status={row.tone}
variant={TONE_VARIANT[row.tone]}
size="sm"
label={TONE_LABEL[row.tone]}
className="shrink-0 self-center"
/>
<span className="min-w-0">{row.text}</span>
</span>
),
}))
return (
<div
ref={root}
data-slot="live-feed"
data-live={running || undefined}
className={cn("w-full", className)}
>
{/* The composite-input exception: the root takes className and the state
attributes, and everything else — groupByDay, emptyMessage, now, an
id, an aria-* — goes to the feed, which is where it belongs. */}
<ActivityFeed
items={items}
// Polite, and only the rows: a reader is told what arrived, not handed
// the whole feed again every tick.
aria-live="polite"
aria-relevant="additions"
{...props}
/>
</div>
)
}
export { LiveFeed }