mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
refactor: made announcements time-bound and added audiences
- Added startTime and endTime properties to announcements for time-bound visibility. - Introduced audience targeting to control who sees announcements (all, users, web_visitors). - Updated related components and hooks to support new announcement features. - Removed unused state tracking for dismissed announcements to streamline functionality.
This commit is contained in:
parent
2c68e4ad69
commit
f777142017
7 changed files with 211 additions and 263 deletions
|
|
@ -3,18 +3,15 @@
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
CheckCheck,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Filter,
|
|
||||||
Info,
|
Info,
|
||||||
type Megaphone,
|
type Megaphone,
|
||||||
Rocket,
|
Rocket,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,16 +22,6 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
|
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||||
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
|
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
import { formatRelativeDate } from "@/lib/format-date";
|
||||||
|
|
@ -84,22 +71,14 @@ const categoryConfig: Record<
|
||||||
|
|
||||||
function AnnouncementCard({
|
function AnnouncementCard({
|
||||||
announcement,
|
announcement,
|
||||||
onMarkRead,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
}: {
|
||||||
announcement: AnnouncementWithState;
|
announcement: AnnouncementWithState;
|
||||||
onMarkRead: (id: string) => void;
|
|
||||||
onDismiss: (id: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const config = categoryConfig[announcement.category];
|
const config = categoryConfig[announcement.category] ?? categoryConfig.info;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card className="group relative transition-all duration-200 hover:shadow-md">
|
||||||
className={`group relative transition-all duration-200 hover:shadow-md ${
|
|
||||||
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
|
@ -120,47 +99,12 @@ function AnnouncementCard({
|
||||||
Important
|
Important
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!announcement.isRead && (
|
|
||||||
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="mt-1 text-xs">
|
<CardDescription className="mt-1 text-xs">
|
||||||
{formatRelativeDate(announcement.date)}
|
{formatRelativeDate(announcement.date)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{!announcement.isRead && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => onMarkRead(announcement.id)}
|
|
||||||
>
|
|
||||||
<CheckCheck className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Mark as read</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => onDismiss(announcement.id)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Dismiss</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
@ -174,7 +118,6 @@ function AnnouncementCard({
|
||||||
<Link
|
<Link
|
||||||
href={announcement.link.url}
|
href={announcement.link.url}
|
||||||
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
|
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
|
||||||
onClick={() => onMarkRead(announcement.id)}
|
|
||||||
>
|
>
|
||||||
{announcement.link.label}
|
{announcement.link.label}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
|
@ -190,23 +133,15 @@ function AnnouncementCard({
|
||||||
// Empty state
|
// Empty state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||||
{hasFilters ? (
|
<BellOff className="h-7 w-7 text-muted-foreground" />
|
||||||
<Filter className="h-7 w-7 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="h-7 w-7 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">No announcements</h3>
|
||||||
{hasFilters ? "No matching announcements" : "No announcements"}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||||
{hasFilters
|
You're all caught up! New announcements will appear here.
|
||||||
? "Try adjusting your filters to see more announcements."
|
|
||||||
: "You're all caught up! New announcements will appear here."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -217,134 +152,41 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function AnnouncementsPage() {
|
export default function AnnouncementsPage() {
|
||||||
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
|
const { announcements, markAllRead } = useAnnouncements();
|
||||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
|
||||||
|
|
||||||
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
|
// Auto-mark all visible announcements as read when the page is opened
|
||||||
includeDismissed: false,
|
useEffect(() => {
|
||||||
});
|
markAllRead();
|
||||||
|
}, [markAllRead]);
|
||||||
// Apply local filters
|
|
||||||
const filteredAnnouncements = announcements.filter((a) => {
|
|
||||||
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
|
|
||||||
if (showOnlyUnread && a.isRead) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
|
|
||||||
|
|
||||||
const toggleCategory = (cat: AnnouncementCategory) => {
|
|
||||||
setActiveCategories((prev) =>
|
|
||||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<div className="min-h-screen relative pt-20">
|
||||||
<div className="min-h-screen relative pt-20">
|
{/* Header */}
|
||||||
{/* Header */}
|
<div className="border-b border-border/50">
|
||||||
<div className="border-b border-border/50">
|
<div className="max-w-5xl mx-auto relative">
|
||||||
<div className="max-w-5xl mx-auto relative">
|
<div className="p-6">
|
||||||
<div className="p-6">
|
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||||
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
Announcements
|
||||||
Announcements
|
</h1>
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Category filter dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-1.5">
|
|
||||||
<Filter className="h-3.5 w-3.5" />
|
|
||||||
Filter
|
|
||||||
{activeCategories.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
|
||||||
{activeCategories.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
|
||||||
<DropdownMenuLabel>Categories</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
|
|
||||||
const cfg = categoryConfig[cat];
|
|
||||||
const CatIcon = cfg.icon;
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={cat}
|
|
||||||
checked={activeCategories.includes(cat)}
|
|
||||||
onCheckedChange={() => toggleCategory(cat)}
|
|
||||||
>
|
|
||||||
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
|
|
||||||
{cfg.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={showOnlyUnread}
|
|
||||||
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
|
|
||||||
>
|
|
||||||
<Bell className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Unread only
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveCategories([]);
|
|
||||||
setShowOnlyUnread(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mark all read */}
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
|
|
||||||
<CheckCheck className="h-3.5 w-3.5" />
|
|
||||||
Mark all as read
|
|
||||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
|
||||||
{unreadCount}
|
|
||||||
</Badge>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mb-6" />
|
|
||||||
|
|
||||||
{/* Announcement list */}
|
|
||||||
{filteredAnnouncements.length === 0 ? (
|
|
||||||
<EmptyState hasFilters={hasActiveFilters} />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{filteredAnnouncements.map((announcement) => (
|
|
||||||
<AnnouncementCard
|
|
||||||
key={announcement.id}
|
|
||||||
announcement={announcement}
|
|
||||||
onMarkRead={markRead}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
|
||||||
|
{announcements.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<AnnouncementCard
|
||||||
|
key={announcement.id}
|
||||||
|
announcement={announcement}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
markAnnouncementRead,
|
markAnnouncementRead,
|
||||||
markAnnouncementToasted,
|
markAnnouncementToasted,
|
||||||
} from "@/lib/announcements/announcements-storage";
|
} from "@/lib/announcements/announcements-storage";
|
||||||
|
import { getActiveAnnouncements } from "@/lib/announcements/announcements-utils";
|
||||||
|
import { isAuthenticated } from "@/lib/auth-utils";
|
||||||
|
|
||||||
/** Map announcement category to the Sonner toast method */
|
/** Map announcement category to the Sonner toast method */
|
||||||
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
|
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
|
||||||
|
|
@ -52,34 +54,33 @@ function showAnnouncementToast(announcement: Announcement) {
|
||||||
* Global provider that shows important announcements as toast notifications.
|
* Global provider that shows important announcements as toast notifications.
|
||||||
*
|
*
|
||||||
* Place this component once at the root layout level (alongside <Toaster />).
|
* Place this component once at the root layout level (alongside <Toaster />).
|
||||||
* On mount, it checks for unread important announcements that haven't been
|
* On mount, it checks for active, audience-matched, unread important
|
||||||
* shown as toasts yet, and displays them with a short stagger delay.
|
* announcements that haven't been shown as toasts yet, and displays them
|
||||||
|
* with a short stagger delay.
|
||||||
*/
|
*/
|
||||||
export function AnnouncementToastProvider() {
|
export function AnnouncementToastProvider() {
|
||||||
const hasChecked = useRef(false);
|
const hasChecked = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run once per page load
|
|
||||||
if (hasChecked.current) return;
|
if (hasChecked.current) return;
|
||||||
hasChecked.current = true;
|
hasChecked.current = true;
|
||||||
|
|
||||||
// Small delay to let the page settle before showing toasts
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const importantUntoasted = announcements.filter(
|
const authed = isAuthenticated();
|
||||||
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
const active = getActiveAnnouncements(announcements, authed);
|
||||||
|
const importantUntoasted = active.filter(
|
||||||
|
(a) => a.isImportant && !isAnnouncementToasted(a.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show each important announcement as a toast with stagger
|
|
||||||
for (let i = 0; i < importantUntoasted.length; i++) {
|
for (let i = 0; i < importantUntoasted.length; i++) {
|
||||||
const announcement = importantUntoasted[i];
|
const announcement = importantUntoasted[i];
|
||||||
setTimeout(() => showAnnouncementToast(announcement), i * 800);
|
setTimeout(() => showAnnouncementToast(announcement), i * 800);
|
||||||
}
|
}
|
||||||
}, 1500); // Initial delay for page to settle
|
}, 1500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// This component renders nothing — it only triggers side effects
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,17 @@
|
||||||
* Frontend-only announcement system that supports:
|
* Frontend-only announcement system that supports:
|
||||||
* - Multiple announcement categories (update, feature, maintenance, info)
|
* - Multiple announcement categories (update, feature, maintenance, info)
|
||||||
* - Important flag for toast notifications
|
* - Important flag for toast notifications
|
||||||
* - Read/dismissed state tracking via localStorage
|
* - Time-bound visibility (start/end times)
|
||||||
|
* - Audience targeting (all, users, web_visitors)
|
||||||
|
* - Read state tracking via localStorage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Announcement category */
|
/** Announcement category */
|
||||||
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
|
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
|
||||||
|
|
||||||
|
/** Who should see the announcement */
|
||||||
|
export type AnnouncementAudience = "all" | "users" | "web_visitors";
|
||||||
|
|
||||||
/** Single announcement entry */
|
/** Single announcement entry */
|
||||||
export interface Announcement {
|
export interface Announcement {
|
||||||
/** Unique identifier */
|
/** Unique identifier */
|
||||||
|
|
@ -22,6 +27,12 @@ export interface Announcement {
|
||||||
category: AnnouncementCategory;
|
category: AnnouncementCategory;
|
||||||
/** ISO date string of when the announcement was published */
|
/** ISO date string of when the announcement was published */
|
||||||
date: string;
|
date: string;
|
||||||
|
/** ISO datetime — announcement becomes visible at this time */
|
||||||
|
startTime: string;
|
||||||
|
/** ISO datetime — announcement expires and is hidden after this time */
|
||||||
|
endTime: string;
|
||||||
|
/** Who should see this announcement */
|
||||||
|
audience: AnnouncementAudience;
|
||||||
/** If true, the user will see a toast notification for this announcement */
|
/** If true, the user will see a toast notification for this announcement */
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
/** Optional CTA link */
|
/** Optional CTA link */
|
||||||
|
|
@ -37,6 +48,4 @@ export interface AnnouncementUserState {
|
||||||
readIds: string[];
|
readIds: string[];
|
||||||
/** IDs of important announcements already shown as toasts */
|
/** IDs of important announcements already shown as toasts */
|
||||||
toastedIds: string[];
|
toastedIds: string[];
|
||||||
/** IDs of announcements the user has explicitly dismissed */
|
|
||||||
dismissedIds: string[];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
|
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||||
import { announcements } from "@/lib/announcements/announcements-data";
|
import { announcements } from "@/lib/announcements/announcements-data";
|
||||||
import {
|
import {
|
||||||
dismissAnnouncement,
|
|
||||||
getAnnouncementState,
|
getAnnouncementState,
|
||||||
isAnnouncementDismissed,
|
|
||||||
isAnnouncementRead,
|
isAnnouncementRead,
|
||||||
markAllAnnouncementsRead,
|
markAllAnnouncementsRead,
|
||||||
markAnnouncementRead,
|
markAnnouncementRead,
|
||||||
} from "@/lib/announcements/announcements-storage";
|
} from "@/lib/announcements/announcements-storage";
|
||||||
|
import {
|
||||||
|
getActiveAnnouncements,
|
||||||
|
msUntilNextTransition,
|
||||||
|
} from "@/lib/announcements/announcements-utils";
|
||||||
|
import { isAuthenticated } from "@/lib/auth-utils";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// External-store plumbing so React re-renders when localStorage changes
|
// External-store plumbing so React re-renders when localStorage changes
|
||||||
|
|
@ -39,12 +42,11 @@ function notify() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Enriched announcement with read/dismissed state
|
// Enriched announcement with read state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface AnnouncementWithState extends Announcement {
|
export interface AnnouncementWithState extends Announcement {
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
isDismissed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -54,42 +56,54 @@ export interface AnnouncementWithState extends Announcement {
|
||||||
interface UseAnnouncementsOptions {
|
interface UseAnnouncementsOptions {
|
||||||
/** Filter by category */
|
/** Filter by category */
|
||||||
category?: AnnouncementCategory;
|
category?: AnnouncementCategory;
|
||||||
/** If true, include dismissed announcements (default: false) */
|
|
||||||
includeDismissed?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
|
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
|
||||||
const { category, includeDismissed = false } = options;
|
const { category } = options;
|
||||||
|
|
||||||
// Subscribe to state changes (re-renders when localStorage state is bumped)
|
// Subscribe to state changes (re-renders when localStorage state is bumped)
|
||||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
|
||||||
|
// Tick counter that increments when a start/end boundary is crossed,
|
||||||
|
// so useMemo re-evaluates and expired announcements disappear.
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
const enriched: AnnouncementWithState[] = useMemo(() => {
|
const enriched: AnnouncementWithState[] = useMemo(() => {
|
||||||
let items = announcements.map((a) => ({
|
const authed = isAuthenticated();
|
||||||
...a,
|
const now = new Date();
|
||||||
isRead: isAnnouncementRead(a.id),
|
let items: AnnouncementWithState[] = getActiveAnnouncements(announcements, authed, now).map(
|
||||||
isDismissed: isAnnouncementDismissed(a.id),
|
(a) => ({
|
||||||
}));
|
...a,
|
||||||
|
isRead: isAnnouncementRead(a.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
items = items.filter((a) => a.category === category);
|
items = items.filter((a) => a.category === category);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeDismissed) {
|
|
||||||
items = items.filter((a) => !a.isDismissed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date descending (newest first)
|
|
||||||
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [category, includeDismissed, stateVersion]);
|
}, [category, stateVersion, tick]);
|
||||||
|
|
||||||
const unreadCount = useMemo(
|
// Schedule a re-render when the next announcement starts or expires
|
||||||
() => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
|
useEffect(() => {
|
||||||
[enriched]
|
const ms = msUntilNextTransition(announcements);
|
||||||
);
|
if (ms === null) return;
|
||||||
|
|
||||||
|
// Cap at 60 s so we don't hold a very long timer; we'll re-check then.
|
||||||
|
const delay = Math.min(ms + 500, 60_000);
|
||||||
|
const id = setTimeout(() => setTick((t) => t + 1), delay);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => enriched.filter((a) => !a.isRead).length, [enriched]);
|
||||||
|
|
||||||
|
// Keep a ref so callbacks are stable and don't cause re-render loops
|
||||||
|
const enrichedRef = useRef(enriched);
|
||||||
|
enrichedRef.current = enriched;
|
||||||
|
|
||||||
const handleMarkRead = useCallback((id: string) => {
|
const handleMarkRead = useCallback((id: string) => {
|
||||||
markAnnouncementRead(id);
|
markAnnouncementRead(id);
|
||||||
|
|
@ -98,22 +112,17 @@ export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
|
||||||
|
|
||||||
const handleMarkAllRead = useCallback(() => {
|
const handleMarkAllRead = useCallback(() => {
|
||||||
const state = getAnnouncementState();
|
const state = getAnnouncementState();
|
||||||
const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
|
const activeIds = enrichedRef.current.map((a) => a.id);
|
||||||
|
const unreadIds = activeIds.filter((id) => !state.readIds.includes(id));
|
||||||
|
if (unreadIds.length === 0) return;
|
||||||
markAllAnnouncementsRead(unreadIds);
|
markAllAnnouncementsRead(unreadIds);
|
||||||
notify();
|
notify();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDismiss = useCallback((id: string) => {
|
|
||||||
dismissAnnouncement(id);
|
|
||||||
markAnnouncementRead(id);
|
|
||||||
notify();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
announcements: enriched,
|
announcements: enriched,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
markRead: handleMarkRead,
|
markRead: handleMarkRead,
|
||||||
markAllRead: handleMarkAllRead,
|
markAllRead: handleMarkAllRead,
|
||||||
dismiss: handleDismiss,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import type { Announcement } from "@/contracts/types/announcement.types";
|
||||||
* Static announcements data.
|
* Static announcements data.
|
||||||
*
|
*
|
||||||
* To add a new announcement, append an entry to this array.
|
* To add a new announcement, append an entry to this array.
|
||||||
|
* Each announcement requires `startTime` and `endTime` (ISO datetime strings)
|
||||||
|
* to define its visibility window, and `audience` to control who sees it.
|
||||||
|
* Current possible audiences are "all", "users", and "web_visitors".
|
||||||
|
* Current possible categories are "feature", "update", "maintenance", and "info".
|
||||||
* Set `isImportant: true` to trigger a toast notification for the user.
|
* Set `isImportant: true` to trigger a toast notification for the user.
|
||||||
*
|
*
|
||||||
* This file can be replaced with an API call in the future.
|
* This file can be replaced with an API call in the future.
|
||||||
|
|
@ -15,11 +19,21 @@ export const announcements: Announcement[] = [
|
||||||
description: "All major announcements will be posted here.",
|
description: "All major announcements will be posted here.",
|
||||||
category: "feature",
|
category: "feature",
|
||||||
date: "2026-02-17T00:00:00Z",
|
date: "2026-02-17T00:00:00Z",
|
||||||
|
startTime: "2026-02-17T00:00:00Z",
|
||||||
|
endTime: "2026-02-20T00:00:00Z",
|
||||||
|
audience: "all",
|
||||||
|
isImportant: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "announcement-6",
|
||||||
|
title: "Past Test Announcement",
|
||||||
|
description: "This should be seen by nobody, because it's in the past.",
|
||||||
|
category: "maintenance",
|
||||||
|
date: "2026-02-17T00:00:00Z",
|
||||||
|
startTime: "2026-02-15T23:23:00Z",
|
||||||
|
endTime: "2026-02-16T00:00:00Z",
|
||||||
|
audience: "users",
|
||||||
isImportant: true,
|
isImportant: true,
|
||||||
link: {
|
|
||||||
label: "Check Here",
|
|
||||||
url: "/announcements",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// id: "2026-02-10-podcast-improvements",
|
// id: "2026-02-10-podcast-improvements",
|
||||||
|
|
@ -28,6 +42,9 @@ export const announcements: Announcement[] = [
|
||||||
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
|
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
|
||||||
// category: "update",
|
// category: "update",
|
||||||
// date: "2026-02-10T00:00:00Z",
|
// date: "2026-02-10T00:00:00Z",
|
||||||
|
// startTime: "2026-02-10T00:00:00Z",
|
||||||
|
// endTime: "2026-03-10T00:00:00Z",
|
||||||
|
// audience: "all",
|
||||||
// isImportant: false,
|
// isImportant: false,
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
|
|
@ -37,6 +54,9 @@ export const announcements: Announcement[] = [
|
||||||
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
|
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
|
||||||
// category: "maintenance",
|
// category: "maintenance",
|
||||||
// date: "2026-02-08T00:00:00Z",
|
// date: "2026-02-08T00:00:00Z",
|
||||||
|
// startTime: "2026-02-08T00:00:00Z",
|
||||||
|
// endTime: "2026-02-16T00:00:00Z",
|
||||||
|
// audience: "all",
|
||||||
// isImportant: true,
|
// isImportant: true,
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
|
|
@ -46,6 +66,9 @@ export const announcements: Announcement[] = [
|
||||||
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
|
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
|
||||||
// category: "feature",
|
// category: "feature",
|
||||||
// date: "2026-02-05T00:00:00Z",
|
// date: "2026-02-05T00:00:00Z",
|
||||||
|
// startTime: "2026-02-05T00:00:00Z",
|
||||||
|
// endTime: "2026-03-05T00:00:00Z",
|
||||||
|
// audience: "users",
|
||||||
// isImportant: false,
|
// isImportant: false,
|
||||||
// link: {
|
// link: {
|
||||||
// label: "View connectors",
|
// label: "View connectors",
|
||||||
|
|
@ -59,6 +82,9 @@ export const announcements: Announcement[] = [
|
||||||
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
|
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
|
||||||
// category: "feature",
|
// category: "feature",
|
||||||
// date: "2026-01-28T00:00:00Z",
|
// date: "2026-01-28T00:00:00Z",
|
||||||
|
// startTime: "2026-01-28T00:00:00Z",
|
||||||
|
// endTime: "2026-02-28T00:00:00Z",
|
||||||
|
// audience: "users",
|
||||||
// isImportant: false,
|
// isImportant: false,
|
||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ const STORAGE_KEY = "surfsense_announcements_state";
|
||||||
const defaultState: AnnouncementUserState = {
|
const defaultState: AnnouncementUserState = {
|
||||||
readIds: [],
|
readIds: [],
|
||||||
toastedIds: [],
|
toastedIds: [],
|
||||||
dismissedIds: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current announcement user state from localStorage
|
* Get the current announcement user state from localStorage.
|
||||||
|
* Gracefully ignores legacy `dismissedIds` from older versions.
|
||||||
*/
|
*/
|
||||||
export function getAnnouncementState(): AnnouncementUserState {
|
export function getAnnouncementState(): AnnouncementUserState {
|
||||||
if (typeof window === "undefined") return defaultState;
|
if (typeof window === "undefined") return defaultState;
|
||||||
|
|
@ -21,7 +21,6 @@ export function getAnnouncementState(): AnnouncementUserState {
|
||||||
return {
|
return {
|
||||||
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
|
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
|
||||||
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
|
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
|
||||||
dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [],
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaultState;
|
return defaultState;
|
||||||
|
|
@ -63,17 +62,6 @@ export function markAllAnnouncementsRead(ids: string[]): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss an announcement (hide it from the list)
|
|
||||||
*/
|
|
||||||
export function dismissAnnouncement(id: string): void {
|
|
||||||
const state = getAnnouncementState();
|
|
||||||
if (!state.dismissedIds.includes(id)) {
|
|
||||||
state.dismissedIds.push(id);
|
|
||||||
saveAnnouncementState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an important announcement as already toasted (shown as toast)
|
* Mark an important announcement as already toasted (shown as toast)
|
||||||
*/
|
*/
|
||||||
|
|
@ -98,10 +86,3 @@ export function isAnnouncementRead(id: string): boolean {
|
||||||
export function isAnnouncementToasted(id: string): boolean {
|
export function isAnnouncementToasted(id: string): boolean {
|
||||||
return getAnnouncementState().toastedIds.includes(id);
|
return getAnnouncementState().toastedIds.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an announcement has been dismissed
|
|
||||||
*/
|
|
||||||
export function isAnnouncementDismissed(id: string): boolean {
|
|
||||||
return getAnnouncementState().dismissedIds.includes(id);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
80
surfsense_web/lib/announcements/announcements-utils.ts
Normal file
80
surfsense_web/lib/announcements/announcements-utils.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { Announcement } from "@/contracts/types/announcement.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the current time falls within the announcement's
|
||||||
|
* [startTime, endTime] window. Returns false for invalid windows
|
||||||
|
* (endTime before startTime) or when now is outside the range.
|
||||||
|
*/
|
||||||
|
export function isAnnouncementActive(announcement: Announcement, now = new Date()): boolean {
|
||||||
|
const start = new Date(announcement.startTime).getTime();
|
||||||
|
const end = new Date(announcement.endTime).getTime();
|
||||||
|
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return false;
|
||||||
|
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
return nowMs >= start && nowMs <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the announcement's audience matches the viewer context.
|
||||||
|
* - `"all"` — visible to everyone
|
||||||
|
* - `"users"` — visible only to authenticated users
|
||||||
|
* - `"web_visitors"` — visible only to unauthenticated visitors
|
||||||
|
*/
|
||||||
|
export function announcementMatchesAudience(
|
||||||
|
announcement: Announcement,
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
): boolean {
|
||||||
|
switch (announcement.audience) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
case "users":
|
||||||
|
return isAuthenticated;
|
||||||
|
case "web_visitors":
|
||||||
|
return !isAuthenticated;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter announcements to only those that are currently active and
|
||||||
|
* targeted at the given audience.
|
||||||
|
*/
|
||||||
|
export function getActiveAnnouncements(
|
||||||
|
announcements: Announcement[],
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
now = new Date(),
|
||||||
|
): Announcement[] {
|
||||||
|
return announcements.filter(
|
||||||
|
(a) => isAnnouncementActive(a, now) && announcementMatchesAudience(a, isAuthenticated),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of milliseconds until the next announcement either
|
||||||
|
* starts or expires. Returns `null` when there are no upcoming transitions.
|
||||||
|
* Useful for scheduling re-renders so the UI updates automatically.
|
||||||
|
*/
|
||||||
|
export function msUntilNextTransition(
|
||||||
|
announcements: Announcement[],
|
||||||
|
now = new Date(),
|
||||||
|
): number | null {
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
let nearest: number | null = null;
|
||||||
|
|
||||||
|
for (const a of announcements) {
|
||||||
|
const start = new Date(a.startTime).getTime();
|
||||||
|
const end = new Date(a.endTime).getTime();
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || end < start) continue;
|
||||||
|
|
||||||
|
for (const edge of [start, end]) {
|
||||||
|
if (edge > nowMs) {
|
||||||
|
const diff = edge - nowMs;
|
||||||
|
if (nearest === null || diff < nearest) nearest = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue