feat: broadcast

This commit is contained in:
gosangam
2026-08-29 18:34:21 +05:30
parent 011b5bba91
commit 03c8799f22
4 changed files with 181 additions and 0 deletions
@@ -0,0 +1,116 @@
"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]);
if (!item) 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>
);
}
@@ -52,6 +52,9 @@ type NotificationsContextValue = {
* event name and is surfaced here rather than bumping `unread`.
*/
lastChatActivity: ChatActivity | null;
/** Bumps when an announcement is published. Carries no content — consumers
* refetch /broadcasts/pending, which answers per user. */
lastBroadcastAt: number;
/** Threads holding unread messages — the Chats nav badge. Muted threads are
* excluded by the BFF, so muting a busy group clears the badge. */
chatUnread: number;
@@ -115,6 +118,7 @@ export function NotificationsProvider({
const { user } = useAuth();
const [unread, setUnread] = useState(0);
const [chatUnread, setChatUnread] = useState(0);
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0);
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
null,
@@ -249,7 +253,18 @@ export function NotificationsProvider({
});
},
);
// Announcements ride a PUBLIC channel: one publish reaches everyone in
// a single Soketi call rather than one per user, and the event is safe
// to broadcast because it carries nothing — the content comes from the
// authenticated fetch it triggers.
const announcements = pusher.subscribe("broadcasts");
announcements.bind("broadcast_published", () =>
setLastBroadcastAt(Date.now()),
);
cleanup = () => {
announcements.unbind_all();
pusher.unsubscribe("broadcasts");
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
@@ -272,6 +287,7 @@ export function NotificationsProvider({
lastChatActivity,
chatUnread,
refreshChatUnread,
lastBroadcastAt,
}}
>
{children}