119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The announcement surface: a modal on desktop, a drawer on mobile.
|
|
*
|
|
* Shown at most once per user, and that guarantee lives on the server — the
|
|
* dismissal is a row, not local storage — so it survives a new device, a
|
|
* cleared browser, and two tabs racing.
|
|
*
|
|
* It appears on load and on the realtime nudge, but never over a signed-out
|
|
* page: an announcement is addressed to a person, and there is nobody to
|
|
* address until they're signed in.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import Image from "next/image";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
|
import { MessageText } from "@/components/chat/message-text";
|
|
import { Button } from "@/components/ui/button";
|
|
import { broadcasts, type Broadcast } from "@/lib/broadcasts";
|
|
|
|
export function BroadcastModal() {
|
|
const { user } = useAuth();
|
|
const { lastBroadcastAt } = useNotifications();
|
|
const [item, setItem] = useState<Broadcast | null>(null);
|
|
const [dismissing, setDismissing] = useState(false);
|
|
|
|
const load = useCallback(() => {
|
|
if (!user?.id) return;
|
|
broadcasts
|
|
.pending()
|
|
.then(setItem)
|
|
.catch(() => {
|
|
/* announcements are never worth an error surface */
|
|
});
|
|
}, [user?.id]);
|
|
|
|
// On sign-in, and again whenever the realtime nudge fires. lastBroadcastAt
|
|
// changes on each publish, so this refetches without the event carrying any
|
|
// content of its own.
|
|
useEffect(() => {
|
|
load();
|
|
}, [load, lastBroadcastAt]);
|
|
|
|
const dismiss = useCallback(async () => {
|
|
if (!item || dismissing) return;
|
|
setDismissing(true);
|
|
// Close first: the dismissal is idempotent and the server already knows
|
|
// what this user has seen, so a failed write costs at most one repeat
|
|
// rather than a modal that won't go away.
|
|
const id = item.id;
|
|
setItem(null);
|
|
setDismissing(false);
|
|
try {
|
|
await broadcasts.markSeen(id);
|
|
} catch {
|
|
/* it will simply be offered again */
|
|
}
|
|
}, [item, dismissing]);
|
|
|
|
// Guard on the shape, not just presence: a malformed payload must not put
|
|
// an empty modal over every page in the app.
|
|
if (!item?.id || !item.title) return null;
|
|
|
|
return (
|
|
<ResponsiveDialog
|
|
title={item.title}
|
|
description=""
|
|
onClose={dismiss}
|
|
footer={
|
|
<div className="flex gap-2">
|
|
{item.cta_url && item.cta_label ? (
|
|
<>
|
|
<Button variant="outline" className="flex-1" onClick={dismiss}>
|
|
Not now
|
|
</Button>
|
|
<a
|
|
href={item.cta_url}
|
|
// Dismiss on the way out, so following the link also counts as
|
|
// having seen it.
|
|
onClick={dismiss}
|
|
{...(item.cta_url.startsWith("/")
|
|
? {}
|
|
: { target: "_blank", rel: "noopener noreferrer" })}
|
|
className="inline-flex flex-1 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 dark:text-white"
|
|
>
|
|
{item.cta_label}
|
|
</a>
|
|
</>
|
|
) : (
|
|
<Button className="w-full" onClick={dismiss}>
|
|
Got it
|
|
</Button>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-3 p-4">
|
|
{item.banner_url && (
|
|
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-lg bg-muted">
|
|
<Image
|
|
src={item.banner_url}
|
|
alt=""
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, 28rem"
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="text-sm leading-relaxed text-foreground">
|
|
<MessageText body={item.body} entities={item.entities ?? []} />
|
|
</div>
|
|
</div>
|
|
</ResponsiveDialog>
|
|
);
|
|
}
|