diff --git a/surfsense_web/app/(home)/announcements/page.tsx b/surfsense_web/app/(home)/announcements/page.tsx new file mode 100644 index 000000000..5707a6182 --- /dev/null +++ b/surfsense_web/app/(home)/announcements/page.tsx @@ -0,0 +1,350 @@ +"use client"; + +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 { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + 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"; + +// --------------------------------------------------------------------------- +// Category configuration +// --------------------------------------------------------------------------- + +const categoryConfig: Record< + AnnouncementCategory, + { + label: string; + icon: typeof Megaphone; + color: string; + badgeVariant: "default" | "secondary" | "destructive" | "outline"; + } +> = { + feature: { + label: "Feature", + icon: Rocket, + color: "text-emerald-500", + badgeVariant: "default", + }, + update: { + label: "Update", + icon: Zap, + color: "text-blue-500", + badgeVariant: "secondary", + }, + maintenance: { + label: "Maintenance", + icon: Wrench, + color: "text-amber-500", + badgeVariant: "outline", + }, + info: { + label: "Info", + icon: Info, + color: "text-muted-foreground", + badgeVariant: "secondary", + }, +}; + +// --------------------------------------------------------------------------- +// Announcement card +// --------------------------------------------------------------------------- + +function AnnouncementCard({ + announcement, + onMarkRead, + onDismiss, +}: { + announcement: AnnouncementWithState; + onMarkRead: (id: string) => void; + onDismiss: (id: string) => void; +}) { + const config = categoryConfig[announcement.category]; + const Icon = config.icon; + + return ( + + +
+
+
+ +
+
+
+ {announcement.title} + + {config.label} + + {announcement.isImportant && ( + + + Important + + )} + {!announcement.isRead && ( + + )} +
+ + {formatRelativeDate(announcement.date)} + +
+
+ + {/* Actions */} +
+ {!announcement.isRead && ( + + + + + Mark as read + + )} + + + + + Dismiss + +
+
+
+ + +

{announcement.description}

+
+ + {announcement.link && ( + + + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function EmptyState({ hasFilters }: { hasFilters: boolean }) { + return ( +
+
+ {hasFilters ? ( + + ) : ( + + )} +
+

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

+

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

+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function AnnouncementsPage() { + const [activeCategories, setActiveCategories] = useState([]); + const [showOnlyUnread, setShowOnlyUnread] = useState(false); + + 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] + ); + }; + + return ( + +
+ {/* 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) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 047baa27b..5e8fa394f 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { RootProvider } from "fumadocs-ui/provider/next"; import { Roboto } from "next/font/google"; +import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider"; import { ElectricProvider } from "@/components/providers/ElectricProvider"; import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider"; import { I18nProvider } from "@/components/providers/I18nProvider"; @@ -124,6 +125,7 @@ export default function RootLayout({ + diff --git a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx new file mode 100644 index 000000000..cf955fe0f --- /dev/null +++ b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Megaphone } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import type { Announcement } from "@/contracts/types/announcement.types"; +import { announcements } from "@/lib/announcements/announcements-data"; +import { + isAnnouncementToasted, + markAnnouncementRead, + markAnnouncementToasted, +} from "@/lib/announcements/announcements-storage"; + +/** Map announcement category to the Sonner toast method */ +const categoryToVariant: Record = { + update: "info", + feature: "success", + maintenance: "warning", + info: "info", +}; + +/** Show a single announcement as a toast */ +function showAnnouncementToast(announcement: Announcement) { + const variant = categoryToVariant[announcement.category] ?? "info"; + + const options = { + description: truncateText(announcement.description, 120), + duration: 12000, + icon: , + action: announcement.link + ? { + label: announcement.link.label, + onClick: () => { + if (announcement.link?.url.startsWith("http")) { + window.open(announcement.link.url, "_blank"); + } else if (announcement.link?.url) { + window.location.href = announcement.link.url; + } + }, + } + : undefined, + onDismiss: () => { + markAnnouncementRead(announcement.id); + }, + }; + + toast[variant](announcement.title, options); + markAnnouncementToasted(announcement.id); +} + +/** + * 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. + */ +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) + ); + + // 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 + + return () => clearTimeout(timer); + }, []); + + // This component renders nothing — it only triggers side effects + return null; +} + +/** Truncate text to a maximum length, adding ellipsis if needed */ +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}...`; +} diff --git a/surfsense_web/components/homepage/footer-new.tsx b/surfsense_web/components/homepage/footer-new.tsx index e52ab09b9..4bbff0cbd 100644 --- a/surfsense_web/components/homepage/footer-new.tsx +++ b/surfsense_web/components/homepage/footer-new.tsx @@ -33,6 +33,10 @@ export function FooterNew() { title: "Contact Us", href: "/contact", }, + { + title: "Announcements", + href: "/announcements", + }, ]; const socials = [ diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index 670e3c810..fd118b50a 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -4,6 +4,7 @@ import { IconBrandGithub, IconBrandReddit, IconMenu2, + IconSpeakerphone, IconX, } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; @@ -12,6 +13,7 @@ import { useEffect, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; +import { useAnnouncements } from "@/hooks/use-announcements"; import { useGithubStars } from "@/hooks/use-github-stars"; import { cn } from "@/lib/utils"; @@ -47,7 +49,11 @@ export const Navbar = () => { const DesktopNav = ({ navItems, isScrolled }: any) => { const [hovered, setHovered] = useState(null); + const [mounted, setMounted] = useState(false); const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); + const { unreadCount } = useAnnouncements(); + + useEffect(() => setMounted(true), []); return ( { @@ -118,6 +124,17 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { )} + + + {mounted && unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + @@ -127,7 +144,11 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => { const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); + const { unreadCount } = useAnnouncements(); + + useEffect(() => setMounted(true), []); return ( { )} + + + {mounted && unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ed96b84ca..aa6d2984d 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react"; +import { + AlertTriangle, + Inbox, + LogOut, + Megaphone, + PencilIcon, + SquareLibrary, + Trash2, +} from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -23,6 +31,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; +import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { logout } from "@/lib/auth-utils"; @@ -65,6 +74,9 @@ export function LayoutDataProvider({ const queryClient = useQueryClient(); const { theme, setTheme } = useTheme(); + // Announcements + const { unreadCount: announcementUnreadCount } = useAnnouncements(); + // Atoms const { data: user } = useAtomValue(currentUserAtom); const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); @@ -293,8 +305,15 @@ export function LayoutDataProvider({ icon: SquareLibrary, isActive: pathname?.includes("/documents"), }, + { + title: "Announcements", + url: "/announcements", + icon: Megaphone, + isActive: pathname?.includes("/announcements"), + badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, + }, ], - [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount] + [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount] ); // Handlers diff --git a/surfsense_web/contexts/LocaleContext.tsx b/surfsense_web/contexts/LocaleContext.tsx index 5b41385e0..405fa4a50 100644 --- a/surfsense_web/contexts/LocaleContext.tsx +++ b/surfsense_web/contexts/LocaleContext.tsx @@ -4,8 +4,8 @@ import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; import enMessages from "../messages/en.json"; import esMessages from "../messages/es.json"; -import ptMessages from "../messages/pt.json"; import hiMessages from "../messages/hi.json"; +import ptMessages from "../messages/pt.json"; import zhMessages from "../messages/zh.json"; type Locale = "en" | "es" | "pt" | "hi" | "zh"; diff --git a/surfsense_web/contracts/types/announcement.types.ts b/surfsense_web/contracts/types/announcement.types.ts new file mode 100644 index 000000000..3711dae6b --- /dev/null +++ b/surfsense_web/contracts/types/announcement.types.ts @@ -0,0 +1,42 @@ +/** + * Announcement system types + * + * Frontend-only announcement system that supports: + * - Multiple announcement categories (update, feature, maintenance, info) + * - Important flag for toast notifications + * - Read/dismissed state tracking via localStorage + */ + +/** Announcement category */ +export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info"; + +/** Single announcement entry */ +export interface Announcement { + /** Unique identifier */ + id: string; + /** Short title */ + title: string; + /** Full description (supports basic text) */ + description: string; + /** Category for visual styling and filtering */ + category: AnnouncementCategory; + /** ISO date string of when the announcement was published */ + date: string; + /** If true, the user will see a toast notification for this announcement */ + isImportant: boolean; + /** Optional CTA link */ + link?: { + label: string; + url: string; + }; +} + +/** State stored in localStorage for tracking user interactions */ +export interface AnnouncementUserState { + /** IDs of announcements the user has read (clicked/viewed) */ + 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 new file mode 100644 index 000000000..ad1e9b6fe --- /dev/null +++ b/surfsense_web/hooks/use-announcements.ts @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useMemo, 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"; + +// --------------------------------------------------------------------------- +// External-store plumbing so React re-renders when localStorage changes +// --------------------------------------------------------------------------- + +let stateVersion = 0; +const listeners = new Set<() => void>(); + +function subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); +} + +function getSnapshot() { + return stateVersion; +} + +function getServerSnapshot() { + return 0; +} + +/** Bump the version so useSyncExternalStore triggers a re-render */ +function notify() { + stateVersion++; + for (const listener of listeners) listener(); +} + +// --------------------------------------------------------------------------- +// Enriched announcement with read/dismissed state +// --------------------------------------------------------------------------- + +export interface AnnouncementWithState extends Announcement { + isRead: boolean; + isDismissed: boolean; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +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; + + // Subscribe to state changes (re-renders when localStorage state is bumped) + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const enriched: AnnouncementWithState[] = useMemo(() => { + let items = announcements.map((a) => ({ + ...a, + isRead: isAnnouncementRead(a.id), + isDismissed: isAnnouncementDismissed(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]); + + const unreadCount = useMemo( + () => enriched.filter((a) => !a.isRead && !a.isDismissed).length, + [enriched] + ); + + const handleMarkRead = useCallback((id: string) => { + markAnnouncementRead(id); + notify(); + }, []); + + const handleMarkAllRead = useCallback(() => { + const state = getAnnouncementState(); + const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id); + 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 new file mode 100644 index 000000000..00f199bd4 --- /dev/null +++ b/surfsense_web/lib/announcements/announcements-data.ts @@ -0,0 +1,65 @@ +import type { Announcement } from "@/contracts/types/announcement.types"; + +/** + * Static announcements data. + * + * To add a new announcement, append an entry to this array. + * Set `isImportant: true` to trigger a toast notification for the user. + * + * This file can be replaced with an API call in the future. + */ +export const announcements: Announcement[] = [ + { + id: "2026-02-12-announcement-syste", + title: "Introducing Announcements", + description: + "Stay up to date with the latest SurfSense news! Important announcements will appear as toast notifications so you never miss critical updates. Visit the Announcements page from the sidebar to browse all past announcements.", + category: "feature", + date: "2026-02-12T00:00:00Z", + isImportant: true, + link: { + label: "Learn more", + url: "/changelog", + }, + }, + // { + // id: "2026-02-10-podcast-improvements", + // title: "Podcast Generation Improvements", + // description: + // "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", + // isImportant: false, + // }, + // { + // id: "2026-02-08-scheduled-maintenance", + // title: "Scheduled Maintenance — Feb 15", + // description: + // "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", + // isImportant: true, + // }, + // { + // id: "2026-02-05-new-connectors", + // title: "New Connectors Available", + // description: + // "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", + // isImportant: false, + // link: { + // label: "View connectors", + // url: "#connectors", + // }, + // }, + // { + // id: "2026-01-28-team-collaboration", + // title: "Enhanced Team Collaboration", + // description: + // "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", + // isImportant: false, + // }, +]; diff --git a/surfsense_web/lib/announcements/announcements-storage.ts b/surfsense_web/lib/announcements/announcements-storage.ts new file mode 100644 index 000000000..9b55df6be --- /dev/null +++ b/surfsense_web/lib/announcements/announcements-storage.ts @@ -0,0 +1,107 @@ +import type { AnnouncementUserState } from "@/contracts/types/announcement.types"; + +const STORAGE_KEY = "surfsense_announcements_state"; + +const defaultState: AnnouncementUserState = { + readIds: [], + toastedIds: [], + dismissedIds: [], +}; + +/** + * Get the current announcement user state from localStorage + */ +export function getAnnouncementState(): AnnouncementUserState { + if (typeof window === "undefined") return defaultState; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultState; + const parsed = JSON.parse(raw) as Partial; + 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; + } +} + +/** + * Save announcement user state to localStorage + */ +function saveAnnouncementState(state: AnnouncementUserState): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Silently fail if localStorage is full or unavailable + } +} + +/** + * Mark an announcement as read + */ +export function markAnnouncementRead(id: string): void { + const state = getAnnouncementState(); + if (!state.readIds.includes(id)) { + state.readIds.push(id); + saveAnnouncementState(state); + } +} + +/** + * Mark all announcements as read + */ +export function markAllAnnouncementsRead(ids: string[]): void { + const state = getAnnouncementState(); + const newIds = ids.filter((id) => !state.readIds.includes(id)); + if (newIds.length > 0) { + state.readIds.push(...newIds); + saveAnnouncementState(state); + } +} + +/** + * 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) + */ +export function markAnnouncementToasted(id: string): void { + const state = getAnnouncementState(); + if (!state.toastedIds.includes(id)) { + state.toastedIds.push(id); + saveAnnouncementState(state); + } +} + +/** + * Check if an announcement has been read + */ +export function isAnnouncementRead(id: string): boolean { + return getAnnouncementState().readIds.includes(id); +} + +/** + * Check if an announcement has been toasted + */ +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); +}