"use client";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@openstatus/ui/components/ui/tooltip";
import { cn } from "@openstatus/ui/lib/utils";
import { formatDistanceToNowStrict } from "date-fns";
import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp";
import { UserAvatar } from "@/components/common/user-avatar";
import { useHydrated } from "@/hooks/use-hydrated";
/**
* Two kinds of item share the list: a compact row (`TimelineIndicator` +
* `TimelineHeader`) for state changes, and a `TimelineCard` for authored
* content. The indicator and the card's avatar sit on the same column.
*/
export function Timeline({
children,
className,
...props
}: React.ComponentProps<"ol">) {
return (
{children}
);
}
// The rail runs up through the gap to the item above, on the column shared
// by row icons and card avatars; the composer at the head is part of it.
export function TimelineItem({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
{children}
);
}
// ml-4 matches the card's horizontal padding so the icon lines up with the
// card avatar.
export function TimelineIndicator({
children,
className,
...props
}: React.ComponentProps<"span">) {
return (
svg]:size-4",
className,
)}
{...props}
>
{children}
);
}
export function TimelineCard({
children,
className,
...props
}: React.ComponentProps<"div">) {
return (
{children}
);
}
// One sentence, muted throughout in a compact row; inside a card it doubles
// as the card header and `TimelineActor`/`TimelineHighlight` lift to the
// foreground.
export function TimelineHeader({
children,
className,
...props
}: React.ComponentProps<"div">) {
return (
[data-slot=timeline-actions]]:pr-16",
// compact rows separate the time with a middle dot, once it has rendered
"[li>&>time:not(:empty)]:before:mr-1.5 [li>&>time:not(:empty)]:before:content-['ยท']",
className,
)}
{...props}
>
{children}
);
}
export function TimelineHighlight({
children,
className,
...props
}: React.ComponentProps<"span">) {
return (
{children}
);
}
type Actor = {
name: string | null;
email: string | null;
photoUrl?: string | null;
};
/** Where the actor acted from, when not the dashboard itself. */
export type ActorSource = {
label: string;
icon: React.ComponentType>;
/** Deep link to the original, e.g. a Slack permalink. */
href?: string;
};
// Renders nothing for a missing actor; the caller phrases the sentence
// without one. Email on hover when the name is shown instead. A source
// overlays the avatar's corner so the sentence keeps its own verb.
export function TimelineActor({
actor,
avatar = false,
source,
className,
...props
}: Omit, "children"> & {
actor: Actor | null;
avatar?: boolean;
source?: ActorSource;
}) {
if (!actor) return null;
const label = actor.name ?? actor.email ?? "Unknown";
const name = {label};
return (
{avatar ? (
{source ? : null}
) : null}
{!actor.email || actor.email === label ? (
name
) : (
{name}
{actor.email}
)}
);
}
function TimelineActorSource({ source }: { source: ActorSource }) {
const className = cn(
"text-foreground absolute -right-1 -bottom-1 flex size-3 items-center justify-center rounded-full ring-1",
// fill and ring match the surface behind so the badge reads as a cut-out
"bg-background ring-background in-data-[slot=timeline-card]:bg-card in-data-[slot=timeline-card]:ring-card",
"focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]",
);
// brand icons carry an svg ; no pointer events keeps the native tooltip away
const icon = ;
return (
{source.href ? (
{icon}
) : (
{icon}
)}
{source.href ? `Open in ${source.label}` : `via ${source.label}`}
);
}
export function TimelineTime({
date,
children,
className,
...props
}: Omit, "dateTime"> & { date: Date }) {
// local-time text differs between server and browser
const hydrated = useHydrated();
return (
);
}
// Pinned to the card corner so a wrapping sentence never pushes it down.
export function TimelineActions({
children,
className,
...props
}: React.ComponentProps<"div">) {
return (