From 03c8799f227588451957f46b96b51c2862bbbcbe Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 29 Aug 2026 18:34:21 +0530 Subject: [PATCH] feat: broadcast --- app/layout.tsx | 4 + components/notifications/broadcast-modal.tsx | 116 ++++++++++++++++++ .../notifications/notifications-provider.tsx | 16 +++ lib/broadcasts.ts | 45 +++++++ 4 files changed, 181 insertions(+) create mode 100644 components/notifications/broadcast-modal.tsx create mode 100644 lib/broadcasts.ts diff --git a/app/layout.tsx b/app/layout.tsx index 9530eb3a..184ab8f9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player"; import { AuthProvider } from "@/components/auth/auth-provider"; import { AuthGateProvider } from "@/components/auth/auth-gate"; import { NotificationsProvider } from "@/components/notifications/notifications-provider"; +import { BroadcastModal } from "@/components/notifications/broadcast-modal"; import { SharePostProvider } from "@/components/social/share-post-provider"; import { Toaster } from "@/components/ui/toaster"; import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register"; @@ -115,6 +116,9 @@ export default function RootLayout({ {/* Toaster and FloatingPlayer are global — toasts and currently-playing audio survive navigation between route groups. */} + {/* Announcements: at most one modal, only when signed + in, dismissed server-side so it never repeats. */} + diff --git a/components/notifications/broadcast-modal.tsx b/components/notifications/broadcast-modal.tsx new file mode 100644 index 00000000..b1662a25 --- /dev/null +++ b/components/notifications/broadcast-modal.tsx @@ -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(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 ( + + {item.cta_url && item.cta_label ? ( + <> + + + {item.cta_label} + + + ) : ( + + )} + + } + > +
+ {item.banner_url && ( +
+ +
+ )} +
+ +
+
+
+ ); +} diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index bb2b290d..61f16611 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -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( 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} diff --git a/lib/broadcasts.ts b/lib/broadcasts.ts new file mode 100644 index 00000000..7f5883a1 --- /dev/null +++ b/lib/broadcasts.ts @@ -0,0 +1,45 @@ +/** + * Browser-side client for admin announcements. + * + * Deliberately thin: an announcement is fetched, shown once, and dismissed. + * Nothing is cached client-side — "have I seen this?" is the server's answer, + * so it survives a new device, a cleared browser, and a second tab. + */ + +import type { MessageEntity } from "@/lib/chat"; + +const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; + +async function call(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BFF_BASE}/api/v1${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!res.ok) throw new Error(`broadcast request failed: ${res.status}`); + if (res.status === 204) return undefined as T; + const json = await res.json(); + return (json?.data ?? json) as T; +} + +export type Broadcast = { + id: string; + title: string; + body: string; + /** Formatting spans over `body`, same shape chat messages use. */ + entities: MessageEntity[]; + banner_url?: string | null; + cta_label?: string | null; + cta_url?: string | null; + published_at?: string | null; + expires_at?: string | null; +}; + +export const broadcasts = { + /** The announcement waiting for this user, or null. */ + pending: () => call("/broadcasts/pending"), + + /** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */ + markSeen: (id: string) => + call(`/broadcasts/${id}/seen`, { method: "POST" }), +};