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" }),
+};