feat: broadcast
This commit is contained in:
@@ -8,6 +8,7 @@ import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
|||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
||||||
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
||||||
|
import { BroadcastModal } from "@/components/notifications/broadcast-modal";
|
||||||
import { SharePostProvider } from "@/components/social/share-post-provider";
|
import { SharePostProvider } from "@/components/social/share-post-provider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
||||||
@@ -115,6 +116,9 @@ export default function RootLayout({
|
|||||||
{/* Toaster and FloatingPlayer are global — toasts and
|
{/* Toaster and FloatingPlayer are global — toasts and
|
||||||
currently-playing audio survive navigation between
|
currently-playing audio survive navigation between
|
||||||
route groups. */}
|
route groups. */}
|
||||||
|
{/* Announcements: at most one modal, only when signed
|
||||||
|
in, dismissed server-side so it never repeats. */}
|
||||||
|
<BroadcastModal />
|
||||||
<FloatingPlayer />
|
<FloatingPlayer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ServiceWorkerRegistrar />
|
<ServiceWorkerRegistrar />
|
||||||
|
|||||||
@@ -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`.
|
* event name and is surfaced here rather than bumping `unread`.
|
||||||
*/
|
*/
|
||||||
lastChatActivity: ChatActivity | null;
|
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
|
/** Threads holding unread messages — the Chats nav badge. Muted threads are
|
||||||
* excluded by the BFF, so muting a busy group clears the badge. */
|
* excluded by the BFF, so muting a busy group clears the badge. */
|
||||||
chatUnread: number;
|
chatUnread: number;
|
||||||
@@ -115,6 +118,7 @@ export function NotificationsProvider({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
const [chatUnread, setChatUnread] = useState(0);
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const [lastBroadcastAt, setLastBroadcastAt] = useState(0);
|
||||||
const [lastEventAt, setLastEventAt] = useState(0);
|
const [lastEventAt, setLastEventAt] = useState(0);
|
||||||
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
|
const [lastChatActivity, setLastChatActivity] = useState<ChatActivity | null>(
|
||||||
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 = () => {
|
cleanup = () => {
|
||||||
|
announcements.unbind_all();
|
||||||
|
pusher.unsubscribe("broadcasts");
|
||||||
channel.unbind_all();
|
channel.unbind_all();
|
||||||
pusher.unsubscribe(channelName);
|
pusher.unsubscribe(channelName);
|
||||||
pusher.disconnect();
|
pusher.disconnect();
|
||||||
@@ -272,6 +287,7 @@ export function NotificationsProvider({
|
|||||||
lastChatActivity,
|
lastChatActivity,
|
||||||
chatUnread,
|
chatUnread,
|
||||||
refreshChatUnread,
|
refreshChatUnread,
|
||||||
|
lastBroadcastAt,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<Broadcast | null>("/broadcasts/pending"),
|
||||||
|
|
||||||
|
/** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */
|
||||||
|
markSeen: (id: string) =>
|
||||||
|
call<void>(`/broadcasts/${id}/seen`, { method: "POST" }),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user