"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AlertTriangle,
CheckCircle2,
Info,
Mic,
MicOff,
Pause,
Phone,
PhoneOff,
Play,
SkipBack,
SkipForward,
Timer,
Upload,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type IslandActivity =
| { kind: "call"; id: string; name: string; subtitle?: string }
| { kind: "timer"; id: string; label: string; /** seconds */ duration: number }
| { kind: "music"; id: string; title: string; artist: string; /** seconds */ duration: number; colors?: [string, string] }
| { kind: "progress"; id: string; label: string; /** 0–1 */ progress: number; detail?: string }
| { kind: "toast"; id: string; message: string; tone?: "success" | "error" | "info"; /** ms */ duration?: number };
export type IslandAction = "accept" | "decline" | "end" | "mute" | "unmute" | "pause" | "resume" | "cancel" | "done" | "prev" | "next";
export interface NotificationIslandProps {
activity: IslandActivity | null;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onAction?: (action: IslandAction, activity: IslandActivity) => void;
/** Called when a toast times out, a call ends or a timer is cancelled. */
onDismiss?: (activity: IslandActivity) => void;
/** "absolute" pins to the top of the nearest positioned parent; "fixed" to the viewport. */
position?: "absolute" | "fixed" | "static";
className?: string;
}
const SPRING = { type: "spring", stiffness: 380, damping: 30, mass: 0.9 } as const;
function fmt(s: number) {
const v = Math.max(0, Math.round(s));
return `${Math.floor(v / 60)}:${String(v % 60).padStart(2, "0")}`;
}
const initials = (name: string) =>
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase();
function dims(a: IslandActivity | null, expanded: boolean) {
if (!a) return { w: 120, h: 34, r: 17 };
if (a.kind === "toast") return { w: 300, h: 42, r: 21 };
if (!expanded) return { w: 240, h: 36, r: 18 };
const h = a.kind === "music" ? 168 : 96;
return { w: 360, h, r: a.kind === "music" ? 36 : 32 };
}
export function NotificationIsland({
activity,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
onAction,
onDismiss,
position = "absolute",
className,
}: NotificationIslandProps) {
const reduce = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLDivElement>(null);
const [innerExp, setInnerExp] = React.useState(defaultExpanded);
const expanded = (expandedProp ?? innerExp) && !!activity && activity.kind !== "toast";
const setExpanded = React.useCallback(
(v: boolean) => {
if (expandedProp === undefined) setInnerExp(v);
onExpandedChange?.(v);
},
[expandedProp, onExpandedChange],
);
const [maxW, setMaxW] = React.useState(360);
const [now, setNow] = React.useState(0);
const [visible, setVisible] = React.useState(true);
// Per-activity runtime state.
const [call, setCall] = React.useState<{ id: string; since: number | null; muted: boolean } | null>(null);
const [timer, setTimer] = React.useState<{ id: string; endsAt: number; pausedLeft: number | null } | null>(null);
const [music, setMusic] = React.useState<{ id: string; pos: number; playing: boolean; at: number } | null>(null);
React.useEffect(() => {
const el = rootRef.current?.parentElement;
if (!el) return;
const ro = new ResizeObserver(([e]) => setMaxW(Math.max(200, e.contentRect.width - 16)));
ro.observe(el);
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
if (rootRef.current) io.observe(rootRef.current);
return () => {
ro.disconnect();
io.disconnect();
};
}, []);
// Initialise runtime state when a new activity arrives.
React.useEffect(() => {
const t = performance.now();
/* eslint-disable react-hooks/set-state-in-effect -- runtime state is derived from the incoming activity */
setNow(t);
if (activity?.kind === "call") setCall((c) => (c?.id === activity.id ? c : { id: activity.id, since: null, muted: false }));
if (activity?.kind === "timer") setTimer((c) => (c?.id === activity.id ? c : { id: activity.id, endsAt: t + activity.duration * 1000, pausedLeft: null }));
if (activity?.kind === "music") setMusic((c) => (c?.id === activity.id ? c : { id: activity.id, pos: 0, playing: true, at: t }));
/* eslint-enable react-hooks/set-state-in-effect */
}, [activity]);
// Clock.
const needsClock =
visible &&
((activity?.kind === "call" && call?.since != null) ||
(activity?.kind === "timer" && timer?.pausedLeft == null) ||
(activity?.kind === "music" && music?.playing));
React.useEffect(() => {
if (!needsClock) return;
const id = setInterval(() => setNow(performance.now()), 250);
return () => clearInterval(id);
}, [needsClock]);
// Toast auto-dismiss.
const dismissRef = React.useRef(onDismiss);
React.useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
const toastId = activity?.kind === "toast" ? activity.id : null;
const toastMs = activity?.kind === "toast" ? (activity.duration ?? 3200) : 0;
React.useEffect(() => {
if (!toastId || !activity) return;
const a = activity;
const id = setTimeout(() => dismissRef.current?.(a), toastMs);
return () => clearTimeout(id);
// Restart only when a different toast arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toastId, toastMs]);
// Timer completion.
const timerLeft = activity?.kind === "timer" && timer?.id === activity.id ? (timer.pausedLeft ?? Math.max(0, (timer.endsAt - now) / 1000)) : 0;
const timerDone = activity?.kind === "timer" && timer?.id === activity.id && timer.pausedLeft == null && now > 0 && timerLeft <= 0;
const doneFired = React.useRef<string | null>(null);
React.useEffect(() => {
if (timerDone && activity && doneFired.current !== activity.id) {
doneFired.current = activity.id;
onAction?.("done", activity);
}
}, [timerDone, activity, onAction]);
// Collapse on outside click / Escape.
React.useEffect(() => {
if (!expanded) return;
const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setExpanded(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setExpanded(false);
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [expanded, setExpanded]);
const act = (a: IslandAction) => {
if (!activity) return;
const t = performance.now();
if (activity.kind === "call") {
if (a === "accept") setCall((c) => c && { ...c, since: t });
if (a === "mute" || a === "unmute") setCall((c) => c && { ...c, muted: a === "mute" });
if (a === "decline" || a === "end") {
setExpanded(false);
onDismiss?.(activity);
}
}
if (activity.kind === "timer") {
if (a === "pause") setTimer((c) => c && { ...c, pausedLeft: Math.max(0, (c.endsAt - t) / 1000) });
if (a === "resume") setTimer((c) => c && { ...c, endsAt: t + (c.pausedLeft ?? 0) * 1000, pausedLeft: null });
if (a === "cancel") {
setExpanded(false);
onDismiss?.(activity);
}
}
if (activity.kind === "music") {
setMusic((m) => {
if (!m) return m;
const pos = m.playing ? m.pos + (t - m.at) / 1000 : m.pos;
if (a === "pause") return { ...m, pos, playing: false, at: t };
if (a === "resume") return { ...m, pos, playing: true, at: t };
if (a === "prev") return { ...m, pos: 0, at: t };
if (a === "next") return { ...m, pos: 0, at: t };
return m;
});
}
if (activity.kind === "progress" && a === "cancel") {
setExpanded(false);
onDismiss?.(activity);
}
onAction?.(a, activity);
setNow(t);
};
const d = dims(activity, expanded);
const w = Math.min(d.w, maxW);
const contentKey = activity ? `${activity.kind}-${activity.id}-${expanded ? "x" : "c"}` : "idle";
const summary = describe(activity, { timerLeft, call, music, now });
return (
<div
ref={rootRef}
className={cn(
"z-40 flex justify-center",
position === "absolute" && "absolute inset-x-0 top-3",
position === "fixed" && "fixed inset-x-0 top-3",
className,
)}
>
<motion.div
key={activity?.id ?? "idle"}
initial={reduce ? false : { scale: activity ? 0.92 : 1 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 18 }}
>
<motion.div
className="relative overflow-hidden bg-black text-white shadow-[0_10px_30px_-10px_rgba(0,0,0,0.6)] ring-1 ring-white/10"
initial={false}
animate={{ width: w, height: d.h, borderRadius: d.r }}
transition={reduce ? { duration: 0 } : SPRING}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={contentKey}
className="absolute inset-0"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(6px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: "blur(6px)" }}
transition={{ duration: 0.28, ease: [0.2, 0.8, 0.2, 1] }}
>
{activity && !expanded && activity.kind !== "toast" && (
<button
type="button"
aria-expanded={false}
aria-label={`${summary}. Expand`}
onClick={() => setExpanded(true)}
className="absolute inset-0 z-10 rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
/>
)}
{activity && (
<Content
activity={activity}
expanded={expanded}
timerLeft={timerLeft}
timerDone={timerDone}
timerPaused={timer?.pausedLeft != null}
call={call}
music={music}
now={now}
act={act}
reduce={reduce}
onCollapse={() => setExpanded(false)}
/>
)}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
<p className="sr-only" aria-live="polite">
{summary}
</p>
</div>
);
}
function describe(
a: IslandActivity | null,
s: { timerLeft: number; call: { since: number | null } | null; music: unknown; now: number },
): string {
if (!a) return "";
switch (a.kind) {
case "call":
return s.call?.since != null ? `On call with ${a.name}` : `Incoming call from ${a.name}`;
case "timer":
return `${a.label}: ${fmt(s.timerLeft)} left`;
case "music":
return `Now playing ${a.title} by ${a.artist}`;
case "progress":
return `${a.label} ${Math.round(a.progress * 100)}%`;
case "toast":
return a.message;
}
}
interface ContentProps {
activity: IslandActivity;
expanded: boolean;
timerLeft: number;
timerDone: boolean;
timerPaused: boolean;
call: { id: string; since: number | null; muted: boolean } | null;
music: { id: string; pos: number; playing: boolean; at: number } | null;
now: number;
act: (a: IslandAction) => void;
reduce: boolean;
onCollapse: () => void;
}
function Content({ activity: a, expanded, timerLeft, timerDone, timerPaused, call, music, now, act, reduce, onCollapse }: ContentProps) {
const compact = "flex h-full items-center justify-between gap-3 pr-3 pl-2";
if (a.kind === "toast") {
const tone = a.tone ?? "success";
const Icon = tone === "success" ? CheckCircle2 : tone === "error" ? AlertTriangle : Info;
return (
<div className="flex h-full items-center gap-2.5 px-4 text-sm font-medium" role="status">
<Icon className={cn("size-4 shrink-0", tone === "success" ? "text-emerald-400" : tone === "error" ? "text-rose-400" : "text-sky-400")} />
<span className="truncate">{a.message}</span>
</div>
);
}
if (a.kind === "call") {
const live = call?.id === a.id && call.since != null;
const secs = live ? (now - (call.since ?? now)) / 1000 : 0;
if (!expanded)
return (
<div className={compact}>
<Avatar name={a.name} size={24} />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{live ? fmt(secs) : a.name}</span>
{live ? <Bars color="bg-emerald-400" reduce={reduce} /> : <RingingPhone reduce={reduce} />}
</div>
);
return (
<div className="flex h-full items-center gap-3 px-4">
<Avatar name={a.name} size={48} />
<div className="min-w-0 flex-1">
<p className="text-[11px] text-white/55">{live ? `On call · ${fmt(secs)}` : (a.subtitle ?? "Incoming call")}</p>
<p className="truncate text-base font-semibold">{a.name}</p>
</div>
{live ? (
<>
<RoundButton label={call?.muted ? "Unmute" : "Mute"} onClick={() => act(call?.muted ? "unmute" : "mute")} className="bg-white/15 hover:bg-white/25">
{call?.muted ? <MicOff className="size-4" /> : <Mic className="size-4" />}
</RoundButton>
<RoundButton label="End call" onClick={() => act("end")} className="bg-rose-500 hover:bg-rose-400">
<PhoneOff className="size-4" />
</RoundButton>
</>
) : (
<>
<RoundButton label="Decline" onClick={() => act("decline")} className="bg-rose-500 hover:bg-rose-400">
<PhoneOff className="size-4" />
</RoundButton>
<RoundButton label="Accept" onClick={() => act("accept")} className="bg-emerald-500 hover:bg-emerald-400">
<Phone className="size-4" />
</RoundButton>
</>
)}
</div>
);
}
if (a.kind === "timer") {
const pct = a.duration ? 1 - timerLeft / a.duration : 1;
if (!expanded)
return (
<div className={compact}>
<span className="flex items-center gap-1.5 pl-1 text-xs font-medium text-amber-400">
<Timer className="size-4" /> {timerDone ? "Done" : a.label}
</span>
<span className="text-sm font-semibold text-amber-400 tabular-nums">{fmt(timerLeft)}</span>
</div>
);
return (
<div className="flex h-full items-center gap-3 px-4">
<Ring pct={pct} size={48} color="#fbbf24">
<Timer className="size-4 text-amber-400" />
</Ring>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] text-white/55">{timerDone ? "Time’s up" : a.label}</p>
<p className="text-2xl leading-none font-semibold tracking-tight text-amber-400 tabular-nums">{fmt(timerLeft)}</p>
</div>
{!timerDone && (
<RoundButton label={timerPaused ? "Resume timer" : "Pause timer"} onClick={() => act(timerPaused ? "resume" : "pause")} className="bg-amber-400/20 text-amber-300 hover:bg-amber-400/30">
{timerPaused ? <Play className="size-4 fill-current" /> : <Pause className="size-4 fill-current" />}
</RoundButton>
)}
<RoundButton label={timerDone ? "Dismiss" : "Cancel timer"} onClick={() => act("cancel")} className="bg-white/15 hover:bg-white/25">
<X className="size-4" />
</RoundButton>
</div>
);
}
if (a.kind === "music") {
const m = music?.id === a.id ? music : null;
const pos = m ? Math.min(a.duration, m.playing ? m.pos + (now - m.at) / 1000 : m.pos) : 0;
const playing = !!m?.playing && pos < a.duration;
const [c1, c2] = a.colors ?? ["#f472b6", "#7c3aed"];
if (!expanded)
return (
<div className={compact}>
<span className="size-6 rounded-md" style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }} />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{a.title}</span>
<Bars color="bg-pink-400" reduce={reduce || !playing} />
</div>
);
return (
<div className="flex h-full flex-col justify-center gap-3 px-5">
<div className="flex items-center gap-3">
<span className="relative size-14 shrink-0 overflow-hidden rounded-xl" style={{ background: `linear-gradient(135deg, ${c1}, ${c2})` }}>
<span className="absolute inset-3 rounded-full border border-white/40" />
<span className="absolute inset-[22px] rounded-full bg-white/70" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{a.title}</p>
<p className="truncate text-sm text-white/60">{a.artist}</p>
</div>
<Bars color="bg-pink-400" reduce={reduce || !playing} />
</div>
<div className="flex items-center gap-2 font-mono text-[10px] text-white/55 tabular-nums">
<span>{fmt(pos)}</span>
<span className="h-1 flex-1 overflow-hidden rounded-full bg-white/20">
<span className="block h-full rounded-full bg-white transition-[width] duration-300 ease-linear" style={{ width: `${(pos / a.duration) * 100}%` }} />
</span>
<span>-{fmt(a.duration - pos)}</span>
</div>
<div className="flex items-center justify-center gap-5">
<RoundButton label="Previous track" onClick={() => act("prev")} className="bg-transparent hover:bg-white/10">
<SkipBack className="size-5 fill-current" />
</RoundButton>
<RoundButton label={playing ? "Pause" : "Play"} onClick={() => act(playing ? "pause" : "resume")} className="size-11 bg-white text-black hover:bg-white/90">
{playing ? <Pause className="size-5 fill-current" /> : <Play className="size-5 translate-x-px fill-current" />}
</RoundButton>
<RoundButton label="Next track" onClick={() => act("next")} className="bg-transparent hover:bg-white/10">
<SkipForward className="size-5 fill-current" />
</RoundButton>
</div>
</div>
);
}
// progress
const pct = Math.max(0, Math.min(1, a.progress));
const done = pct >= 1;
if (!expanded)
return (
<div className={compact}>
<span className="flex min-w-0 items-center gap-1.5 pl-1 text-xs font-medium">
{done ? <CheckCircle2 className="size-4 shrink-0 text-emerald-400" /> : <Upload className="size-4 shrink-0 text-sky-400" />}
<span className="truncate">{done ? "Uploaded" : a.label}</span>
</span>
<Ring pct={pct} size={22} color={done ? "#34d399" : "#38bdf8"} />
</div>
);
return (
<div className="flex h-full items-center gap-3 px-4">
<Ring pct={pct} size={48} color={done ? "#34d399" : "#38bdf8"}>
<span className="font-mono text-[11px] font-semibold tabular-nums">{Math.round(pct * 100)}%</span>
</Ring>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{done ? "Upload complete" : a.label}</p>
<p className="truncate text-[11px] text-white/55">{a.detail ?? (done ? "All files synced" : "Uploading…")}</p>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/15">
<span className={cn("block h-full rounded-full transition-[width] duration-300", done ? "bg-emerald-400" : "bg-sky-400")} style={{ width: `${pct * 100}%` }} />
</span>
</div>
<RoundButton label={done ? "Close" : "Cancel upload"} onClick={() => (done ? onCollapse() : act("cancel"))} className="bg-white/15 hover:bg-white/25">
<X className="size-4" />
</RoundButton>
</div>
);
}
function Avatar({ name, size }: { name: string; size: number }) {
const hue = [...name].reduce((s, c) => s + c.charCodeAt(0), 0) % 360;
return (
<span
aria-hidden
className="grid shrink-0 place-items-center rounded-full font-semibold text-white"
style={{ width: size, height: size, fontSize: size * 0.38, background: `linear-gradient(135deg, oklch(0.7 0.15 ${hue}), oklch(0.5 0.18 ${hue + 50}))` }}
>
{initials(name)}
</span>
);
}
function RoundButton({ children, label, onClick, className }: { children: React.ReactNode; label: string; onClick: () => void; className?: string }) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={cn("relative z-20 grid size-10 shrink-0 place-items-center rounded-full text-white transition outline-none focus-visible:ring-2 focus-visible:ring-white/80", className)}
>
{children}
</button>
);
}
function Bars({ color, reduce }: { color: string; reduce: boolean }) {
return (
<span className="flex h-4 items-center gap-[2px]" aria-hidden>
{[0, 1, 2, 3].map((i) => (
<motion.span
key={i}
className={cn("w-[3px] rounded-full", color)}
animate={reduce ? { height: 5 } : { height: [5, 15, 7, 12, 5] }}
transition={reduce ? { duration: 0.2 } : { duration: 0.8 + i * 0.1, repeat: Infinity, delay: i * 0.1, ease: "easeInOut" }}
/>
))}
</span>
);
}
function RingingPhone({ reduce }: { reduce: boolean }) {
return (
<motion.span
className="grid size-6 place-items-center rounded-full bg-emerald-500"
animate={reduce ? undefined : { rotate: [0, -14, 14, -10, 10, 0] }}
transition={{ duration: 0.9, repeat: Infinity, repeatDelay: 0.6 }}
aria-hidden
>
<Phone className="size-3.5 fill-current" />
</motion.span>
);
}
function Ring({ pct, size, color, children }: { pct: number; size: number; color: string; children?: React.ReactNode }) {
const stroke = size > 30 ? 4 : 3;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
return (
<span className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="absolute inset-0 -rotate-90" aria-hidden>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - Math.max(0, Math.min(1, pct)))}
style={{ transition: "stroke-dashoffset 0.3s linear" }}
/>
</svg>
{children}
</span>
);
}