diff --git a/surfsense_web/app/(home)/announcements/page.tsx b/surfsense_web/app/(home)/announcements/page.tsx index 5707a6182..7be00a533 100644 --- a/surfsense_web/app/(home)/announcements/page.tsx +++ b/surfsense_web/app/(home)/announcements/page.tsx @@ -3,18 +3,15 @@ import { Bell, BellOff, - CheckCheck, ExternalLink, - Filter, Info, type Megaphone, Rocket, Wrench, - X, Zap, } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -25,16 +22,6 @@ import { CardHeader, CardTitle, } 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 AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements"; import { formatRelativeDate } from "@/lib/format-date"; @@ -84,22 +71,14 @@ const categoryConfig: Record< function AnnouncementCard({ announcement, - onMarkRead, - onDismiss, }: { 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; return ( - +
@@ -120,47 +99,12 @@ function AnnouncementCard({ Important )} - {!announcement.isRead && ( - - )}
{formatRelativeDate(announcement.date)}
- - {/* Actions */} -
- {!announcement.isRead && ( - - - - - Mark as read - - )} - - - - - Dismiss - -
@@ -174,7 +118,6 @@ function AnnouncementCard({ onMarkRead(announcement.id)} > {announcement.link.label} @@ -190,23 +133,15 @@ function AnnouncementCard({ // Empty state // --------------------------------------------------------------------------- -function EmptyState({ hasFilters }: { hasFilters: boolean }) { +function EmptyState() { return (
- {hasFilters ? ( - - ) : ( - - )} +
-

- {hasFilters ? "No matching announcements" : "No announcements"} -

+

No announcements

- {hasFilters - ? "Try adjusting your filters to see more announcements." - : "You're all caught up! New announcements will appear here."} + You're all caught up! New announcements will appear here.

); @@ -217,134 +152,41 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) { // --------------------------------------------------------------------------- export default function AnnouncementsPage() { - const [activeCategories, setActiveCategories] = useState([]); - const [showOnlyUnread, setShowOnlyUnread] = useState(false); + const { announcements, markAllRead } = useAnnouncements(); - const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({ - includeDismissed: false, - }); - - // 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] - ); - }; + // Auto-mark all visible announcements as read when the page is opened + useEffect(() => { + markAllRead(); + }, [markAllRead]); return ( - -
- {/* Header */} -
-
-
-

- Announcements -

-
+
+ {/* Header */} +
+
+
+

+ Announcements +

- - {/* Content */} -
- {/* Toolbar */} -
-
- {/* Category filter dropdown */} - - - - - - Categories - - {(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => { - const cfg = categoryConfig[cat]; - const CatIcon = cfg.icon; - return ( - toggleCategory(cat)} - > - - {cfg.label} - - ); - })} - - setShowOnlyUnread((v) => !v)} - > - - Unread only - - - - - {hasActiveFilters && ( - - )} -
- - {/* Mark all read */} - {unreadCount > 0 && ( - - )} -
- - - - {/* Announcement list */} - {filteredAnnouncements.length === 0 ? ( - - ) : ( -
- {filteredAnnouncements.map((announcement) => ( - - ))} -
- )} -
- + + {/* Content */} +
+ {announcements.length === 0 ? ( + + ) : ( +
+ {announcements.map((announcement) => ( + + ))} +
+ )} +
+
); } diff --git a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx index cf955fe0f..591c3b875 100644 --- a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx +++ b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx @@ -10,6 +10,8 @@ import { markAnnouncementRead, markAnnouncementToasted, } 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 */ const categoryToVariant: Record = { @@ -52,34 +54,33 @@ function showAnnouncementToast(announcement: Announcement) { * Global provider that shows important announcements as toast notifications. * * Place this component once at the root layout level (alongside ). - * On mount, it checks for unread important announcements that haven't been - * shown as toasts yet, and displays them with a short stagger delay. + * On mount, it checks for active, audience-matched, unread important + * announcements that haven't been shown as toasts yet, and displays them + * with a short stagger delay. */ export function AnnouncementToastProvider() { const hasChecked = useRef(false); useEffect(() => { - // Only run once per page load if (hasChecked.current) return; hasChecked.current = true; - // Small delay to let the page settle before showing toasts const timer = setTimeout(() => { - const importantUntoasted = announcements.filter( - (a) => a.isImportant && !isAnnouncementToasted(a.id) + const authed = isAuthenticated(); + 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++) { const announcement = importantUntoasted[i]; setTimeout(() => showAnnouncementToast(announcement), i * 800); } - }, 1500); // Initial delay for page to settle + }, 1500); return () => clearTimeout(timer); }, []); - // This component renders nothing — it only triggers side effects return null; } diff --git a/surfsense_web/contracts/types/announcement.types.ts b/surfsense_web/contracts/types/announcement.types.ts index 3711dae6b..6c5206d9d 100644 --- a/surfsense_web/contracts/types/announcement.types.ts +++ b/surfsense_web/contracts/types/announcement.types.ts @@ -4,12 +4,17 @@ * Frontend-only announcement system that supports: * - Multiple announcement categories (update, feature, maintenance, info) * - 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 */ export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info"; +/** Who should see the announcement */ +export type AnnouncementAudience = "all" | "users" | "web_visitors"; + /** Single announcement entry */ export interface Announcement { /** Unique identifier */ @@ -22,6 +27,12 @@ export interface Announcement { category: AnnouncementCategory; /** ISO date string of when the announcement was published */ 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 */ isImportant: boolean; /** Optional CTA link */ @@ -37,6 +48,4 @@ export interface AnnouncementUserState { readIds: string[]; /** IDs of important announcements already shown as toasts */ toastedIds: string[]; - /** IDs of announcements the user has explicitly dismissed */ - dismissedIds: string[]; } diff --git a/surfsense_web/hooks/use-announcements.ts b/surfsense_web/hooks/use-announcements.ts index ad1e9b6fe..8a715b9e1 100644 --- a/surfsense_web/hooks/use-announcements.ts +++ b/surfsense_web/hooks/use-announcements.ts @@ -1,16 +1,19 @@ "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 { announcements } from "@/lib/announcements/announcements-data"; import { - dismissAnnouncement, getAnnouncementState, - isAnnouncementDismissed, isAnnouncementRead, markAllAnnouncementsRead, markAnnouncementRead, } 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 @@ -39,12 +42,11 @@ function notify() { } // --------------------------------------------------------------------------- -// Enriched announcement with read/dismissed state +// Enriched announcement with read state // --------------------------------------------------------------------------- export interface AnnouncementWithState extends Announcement { isRead: boolean; - isDismissed: boolean; } // --------------------------------------------------------------------------- @@ -54,42 +56,54 @@ export interface AnnouncementWithState extends Announcement { interface UseAnnouncementsOptions { /** Filter by category */ category?: AnnouncementCategory; - /** If true, include dismissed announcements (default: false) */ - includeDismissed?: boolean; } export function useAnnouncements(options: UseAnnouncementsOptions = {}) { - const { category, includeDismissed = false } = options; + const { category } = options; // Subscribe to state changes (re-renders when localStorage state is bumped) 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(() => { - let items = announcements.map((a) => ({ - ...a, - isRead: isAnnouncementRead(a.id), - isDismissed: isAnnouncementDismissed(a.id), - })); + const authed = isAuthenticated(); + const now = new Date(); + let items: AnnouncementWithState[] = getActiveAnnouncements(announcements, authed, now).map( + (a) => ({ + ...a, + isRead: isAnnouncementRead(a.id), + }), + ); if (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()); return items; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [category, includeDismissed, stateVersion]); + }, [category, stateVersion, tick]); - const unreadCount = useMemo( - () => enriched.filter((a) => !a.isRead && !a.isDismissed).length, - [enriched] - ); + // Schedule a re-render when the next announcement starts or expires + useEffect(() => { + 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) => { markAnnouncementRead(id); @@ -98,22 +112,17 @@ export function useAnnouncements(options: UseAnnouncementsOptions = {}) { const handleMarkAllRead = useCallback(() => { 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); notify(); }, []); - const handleDismiss = useCallback((id: string) => { - dismissAnnouncement(id); - markAnnouncementRead(id); - notify(); - }, []); - return { announcements: enriched, unreadCount, markRead: handleMarkRead, markAllRead: handleMarkAllRead, - dismiss: handleDismiss, }; } diff --git a/surfsense_web/lib/announcements/announcements-data.ts b/surfsense_web/lib/announcements/announcements-data.ts index f9a22581a..bde8b3f43 100644 --- a/surfsense_web/lib/announcements/announcements-data.ts +++ b/surfsense_web/lib/announcements/announcements-data.ts @@ -4,6 +4,10 @@ import type { Announcement } from "@/contracts/types/announcement.types"; * Static announcements data. * * 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. * * 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.", category: "feature", 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, - link: { - label: "Check Here", - url: "/announcements", - }, }, // { // 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.", // category: "update", // date: "2026-02-10T00:00:00Z", + // startTime: "2026-02-10T00:00:00Z", + // endTime: "2026-03-10T00:00:00Z", + // audience: "all", // 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.", // category: "maintenance", // date: "2026-02-08T00:00:00Z", + // startTime: "2026-02-08T00:00:00Z", + // endTime: "2026-02-16T00:00:00Z", + // audience: "all", // 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.", // category: "feature", // date: "2026-02-05T00:00:00Z", + // startTime: "2026-02-05T00:00:00Z", + // endTime: "2026-03-05T00:00:00Z", + // audience: "users", // isImportant: false, // link: { // 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.", // category: "feature", // date: "2026-01-28T00:00:00Z", + // startTime: "2026-01-28T00:00:00Z", + // endTime: "2026-02-28T00:00:00Z", + // audience: "users", // isImportant: false, // }, ]; diff --git a/surfsense_web/lib/announcements/announcements-storage.ts b/surfsense_web/lib/announcements/announcements-storage.ts index 9b55df6be..853e10963 100644 --- a/surfsense_web/lib/announcements/announcements-storage.ts +++ b/surfsense_web/lib/announcements/announcements-storage.ts @@ -5,11 +5,11 @@ const STORAGE_KEY = "surfsense_announcements_state"; const defaultState: AnnouncementUserState = { readIds: [], 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 { if (typeof window === "undefined") return defaultState; @@ -21,7 +21,6 @@ export function getAnnouncementState(): AnnouncementUserState { return { readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [], toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [], - dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [], }; } catch { 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) */ @@ -98,10 +86,3 @@ export function isAnnouncementRead(id: string): boolean { export function isAnnouncementToasted(id: string): boolean { return getAnnouncementState().toastedIds.includes(id); } - -/** - * Check if an announcement has been dismissed - */ -export function isAnnouncementDismissed(id: string): boolean { - return getAnnouncementState().dismissedIds.includes(id); -} diff --git a/surfsense_web/lib/announcements/announcements-utils.ts b/surfsense_web/lib/announcements/announcements-utils.ts new file mode 100644 index 000000000..7da833334 --- /dev/null +++ b/surfsense_web/lib/announcements/announcements-utils.ts @@ -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; +}