import * as i0 from "@angular/core"; import { OnDestroy } from "@angular/core"; import { SvDataError } from "@styloviz/core"; /** * Notification Center — type definitions * * Six variants cover the most common global-market notification UI patterns: * 'dropdown' – Bell trigger → floating panel with tabs & search (default) * 'drawer' – Bell trigger → full-height right-side slide-in panel * 'inline' – Panel embedded directly in the page (dedicated route) * 'feed' – Activity-feed: actor + verb + target (GitHub/Jira style) * 'compact' – Bell trigger → minimal slim list, no tabs/search * 'grouped' – Bell trigger → dropdown with sticky date-group headers */ export type NotificationCenterVariant = 'dropdown' | 'inline'; /** Semantic category — drives the icon and colour of the type badge. */ export type NotificationType = 'comment' | 'mention' | 'assignment' | 'status' | 'invite' | 'approval' | 'deadline' | 'payment' | 'security' | 'update' | 'reaction' | 'system'; export type NotificationPriority = 'low' | 'medium' | 'high' | 'urgent'; export interface NotificationAction { readonly id: string; readonly label: string; /** @default 'secondary' */ readonly variant?: 'primary' | 'secondary' | 'danger'; } interface NotificationActor { readonly id: string; readonly name: string; readonly avatar?: string; readonly color?: string; } interface NotificationTarget { readonly label: string; readonly href?: string; } export interface NotificationItem { readonly id: string; readonly type: NotificationType; readonly title: string; readonly body?: string; readonly priority?: NotificationPriority; readonly timestamp: string; read: boolean; readonly actor?: NotificationActor; readonly target?: NotificationTarget; readonly actions?: NotificationAction[]; readonly category?: string; readonly pinned?: boolean; /** * The user can no longer see the record this notification is about * (`OF-10-I8`). * * ★★★ THE TITLE IS THE LEAK. "Dana commented on Q3 layoffs" is a sentence * about a document, and a notification inbox keeps it long after the * document's permissions change — so the row outlives the access it was * created under. Set this when the host learns the user lost access: * the row keeps its type, time and read state, and renders * `redactedLabel` in place of its title, body, actor, target and * actions. * * ★★ THE ROW IS NOT REMOVED, deliberately. Its EXISTENCE is not the secret * — the user was told about it already, and an inbox that silently loses * rows leaves them hunting for something they remember reading. What has * to go is the content. * * ⚠️ A CLIENT-SIDE REDACTION IS NOT AUTHORIZATION: the title must not be * SENT to a user who may not see it. This is the second line of defence * for a feed the host cannot re-fetch in time, not the first. */ readonly redacted?: boolean; /** * Monotonic revision of THIS notification (`X-12`, `OF-10-M1`). * * ★★★ WITHOUT IT A STALE UPDATE CANNOT BE DETECTED AT ALL. Two sockets, a * retry, or a replay after a reconnect deliver an older copy of a row * after a newer one, and the row silently goes backwards in time — * "assigned to Dana" reverts to "unassigned" and nothing is wrong on * screen. Merge incoming notifications with `svMergeRecords` from * `@styloviz/core` and a lower revision is refused and reported. */ readonly version?: number; /** * Monotonic position in the notification STREAM (`OF-10-I5`). * * ★★ A DIFFERENT AXIS FROM `version`, and both are needed: `version` orders * a row against its own past, `seq` orders rows against each other when * timestamps tie or the clocks disagree — which they do, because * `timestamp` is a string somebody's server rendered. * * ★ Used for ordering only when EVERY notification has one. A half-sequenced * list has no sequence, and mixing the two rules per pair makes an * intransitive comparator whose sort result depends on the array's * starting arrangement. */ readonly seq?: number; /** * Two notifications with the same key are one EVENT and collapse into one * row (`OF-10-I3`, `OF-10-M4`). * * ★ Different from `id`, which is the same RECORD. Three comments on one * ticket are three records and, in a notification list, one line. */ readonly dedupeKey?: string; } export interface NotificationClickEvent { readonly notification: NotificationItem; /** * Undo the optimistic mark-as-read that opening the notification performed. Undoes this write only — later writes stand — and does nothing once the host has confirmed it. */ readonly rollback: () => void; /** * Other rows this write applied to: the unread rows collapsed into this one * by a shared `dedupeKey` (`OF-10-M4`), now marked read with it. Absent * when there were none. The server has to update these as well, or they * come back on the next fetch. */ readonly collapsedIds?: readonly string[]; } export interface NotificationActionClickEvent { readonly notification: NotificationItem; readonly actionId: string; } export interface NotificationDismissEvent { readonly notification: NotificationItem; /** * Undo the optimistic dismissal — the notification returns to the list in its original position. Undoes this write only — later writes stand — and does nothing once the host has confirmed it. */ readonly rollback: () => void; /** * Other rows this dismissal removed: every row collapsed into this one by a * shared `dedupeKey` (`OF-10-M4`), read or not — dismissing the survivor * alone would promote the next one into its place. Absent when there were * none. The server has to dismiss these as well, or they come back on the * next fetch. */ readonly collapsedIds?: readonly string[]; } /** * What the user is asking to see. Emitted debounced, so a host has one handler * to write whether they typed, switched tab, or both. */ export interface NotificationQuery { /** The selected tab label, exactly as supplied in `tabs`. */ readonly tab: string; /** The search box contents, trimmed. `''` when not searching. */ readonly search: string; } /** Asking for the next page of an unbounded notification list. */ export interface NotificationLoadMoreEvent { /** Notifications already rendered — the offset to fetch from. */ readonly loaded: number; /** The query the next page should honour. */ readonly query: NotificationQuery; } /** Marking everything read, with a way to put it back. */ export interface NotificationMarkAllReadEvent { /** Ids that were unread before the click — what the server must update. */ readonly unreadIds: readonly string[]; /** * Undo the optimistic mark-all-read. Call when the request fails. * Undoes this write only — later writes stand — and does nothing once the host has confirmed it. */ readonly rollback: () => void; } /** * SvNotificationCenterComponent — Premium notification inbox. * * Selector: `sv-notification-center` * * Six variants: * - `dropdown` Bell + floating panel with tabs, search & actions (default) * - `drawer` Bell + full-height right-side slide panel (Slack/Figma style) * - `inline` Embedded panel, no trigger — use on dedicated /notifications routes * - `feed` Activity-feed style list (GitHub/Jira style) * - `compact` Bell + slim dropdown, no tabs/search * - `grouped` Bell + dropdown with sticky date-group section headers * * Floating panels (dropdown, compact, grouped, drawer) are portaled to * document.body so they escape every overflow / transform / backdrop-filter * ancestor that would otherwise clip or misposition a fixed-positioned panel. * * @example * ```html * * ``` */ export declare class SvNotificationCenterComponent implements OnDestroy { private readonly platformId; private readonly vcr; private readonly _floatingPanelTpl; private _portalContainer; private readonly _overlayStack; /** Registration while the floating panel is mounted (`X-4`). */ private _stack; private _portalView; /** Trigger element kept for scroll-tracking repositioning. */ private _triggerEl; private _scrollHandler; private _scrollRafId; /** Presentation: dropdown, drawer, inline, feed, compact or grouped. @default 'dropdown' */ readonly variant: import("@angular/core").InputSignal; /** The notifications to display. */ readonly notifications: import("@angular/core").InputSignal; /** Max height of the scrollable list (CSS length). @default '400px' */ readonly maxHeight: import("@angular/core").InputSignal; /** Filter tab labels. @default ['All','Unread','Mentions','System'] */ readonly tabs: import("@angular/core").InputSignal; /** Panel heading text. @default 'Notifications' */ readonly panelTitle: import("@angular/core").InputSignal; /** Show the search box. @default true */ readonly showSearch: import("@angular/core").InputSignal; /** Animate the bell icon on new notifications. @default true */ readonly animateBell: import("@angular/core").InputSignal; /** * Render the panel's "View all" control. * * ★ Off by default, and that default is the fix. The control used to be * unconditional markup with no handler on it — a link to the notifications * page that went nowhere, which is worse than no link because a user who * presses it concludes the page is broken rather than absent. A host that has * somewhere to send them opts in and handles `viewAll`; a host that does not * never shows the control. * * @default false */ readonly showViewAll: import("@angular/core").InputSignal; /** * True while notifications are being fetched. Shows a placeholder in place of * the list when there is nothing to show yet. * @default false */ readonly loading: import("@angular/core").InputSignal; /** * Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an * `HttpErrorResponse`. * @default null */ readonly error: import("@angular/core").InputSignal; /** * The server's unread total for the WHOLE feed (`OF-10-I2`). * `null` counts the rows in hand instead. @default null * * ★★★ THE BADGE WAS GUARANTEED WRONG FOR ANY PAGED FEED, not merely * "unreconciled" as the audit put it. `unreadCount` counted the rows * that had been DOWNLOADED, and this component ships `hasMore` — so a * user with 240 unread notifications and a first page of 20 saw a badge * reading 20, and it went UP as they scrolled. Pass the number the * server knows. * * ★★ IT IS RECONCILED, NOT TRUSTED BLINDLY. A total that the component * simply rendered would sit still while the user marked rows read in * front of it, which is the same class of bug from the other end. The * component subtracts the reads it is holding OPTIMISTICALLY — rows whose * local `read` is true while the `notifications` array you passed still * says false — so the badge drops on the click and the subtraction * disappears by itself when your updated rows arrive. Nothing is tracked * across changes, so a `rollback()` needs no bookkeeping either. * * ★ Send the updated rows and the new total together. Between the two the * delta is measured against rows you have not updated yet, which is a * transient off-by-one in the safe direction (too low, never too high). */ readonly unreadTotal: import("@angular/core").InputSignal; /** * What a redacted row shows instead of its title (`OF-10-I8`). * @default 'This notification is no longer available' */ readonly redactedLabel: import("@angular/core").InputSignal; /** * More notifications exist on the server. Renders the "Load more" control. * A notification feed is unbounded — without this a host has to send the * user's entire history to render a dropdown. * @default false */ readonly hasMore: import("@angular/core").InputSignal; /** True while the next page is being fetched. @default false */ readonly loadingMore: import("@angular/core").InputSignal; /** * Ids with a write in flight. Those rows dim and stop responding, so a second * click cannot race the first and leave `rollback()` ambiguous. * @default [] */ readonly pendingIds: import("@angular/core").InputSignal>; /** * Ids whose last write FAILED, mapped to the message to show on that row. * * ★★★ `pendingIds` HAD NO COUNTERPART, AND THAT IS `OF-10-M2`. Optimistic * read/dismiss rolls back correctly (`OF-10-I1`, fixed in T0) — the row * reverts, and the user is told nothing. A notification that silently * un-dismissed itself looks like the click never landed, so they click * again, and again, against an endpoint that is refusing. * * ★★ PER ROW, not one banner. `error` above is for the FEED — "could not * load notifications" — and putting a single row's failure there says the * whole list is broken. The two are different facts and the audit's own * reasoning for `role="alert"` vs `role="status"` applies: scope the * message to what actually failed. * * ```ts * this.itemErrors.set({ 'n-14': 'Could not mark as read.' }); * ``` * * @default {} */ readonly itemErrors: import("@angular/core").InputSignal> | ReadonlyMap>; /** * Delay before `queryChange` fires, in milliseconds. `0` emits immediately. * @default 300 */ readonly debounceMs: import("@angular/core").InputSignal; /** * Filter and search on the server instead of in the component. Leaves the * supplied `notifications` untouched — the host is responsible for returning * only matching rows. Required for a real feed: filtering client-side can * only ever search the page you already downloaded. * @default false */ readonly serverSide: import("@angular/core").InputSignal; /** * Whether the panel is open. Two-way bindable, so a host can open the centre * from a route or a keyboard shortcut. Ignored by the always-visible * `inline` and `feed` variants. */ readonly open: import("@angular/core").ModelSignal; /** * The selected tab label. Two-way bindable, so the tab survives a reload. * @default 'All' */ readonly activeTab: import("@angular/core").ModelSignal; /** The search box contents. Two-way bindable. @default '' */ readonly search: import("@angular/core").ModelSignal; /** Emitted when a notification row is clicked. */ readonly notificationClick: import("@angular/core").OutputEmitterRef; /** Emitted when an inline action on a notification is clicked. */ readonly actionClick: import("@angular/core").OutputEmitterRef; /** Emitted when "mark all as read" is triggered. */ readonly markAllRead: import("@angular/core").OutputEmitterRef; /** Emitted when a notification is dismissed. */ readonly dismiss: import("@angular/core").OutputEmitterRef; /** * The tab or search term changed — fetch the matching notifications. * Debounced by `debounceMs`, and never emitted twice for the same query. */ readonly queryChange: import("@angular/core").OutputEmitterRef; /** The user asked for the next page. */ readonly loadMore: import("@angular/core").OutputEmitterRef; /** The host should re-run the failed load. */ readonly retry: import("@angular/core").OutputEmitterRef; /** * The user asked for the full notifications surface — navigate there. * Only reachable when `showViewAll` is set; the panel closes first, because * leaving a floating panel open over the page it just navigated to is how a * dropdown ends up covering its own destination. */ readonly viewAll: import("@angular/core").OutputEmitterRef; readonly _open: import("@angular/core").ModelSignal; readonly _activeTab: import("@angular/core").ModelSignal; readonly _search: import("@angular/core").ModelSignal; readonly _items: import("@angular/core").WritableSignal; /** * Optimistic writes the host has not confirmed yet, keyed by write * (`OF-10-I1`). Each holds the ids it still applies to. * * ★★★ AN OVERLAY, NOT A SNAPSHOT, BECAUSE THE HOST'S LIST KEEPS MOVING. * The local list used to be a copy of `notifications` with the change * written into it, so any host push — a realtime arrival about a * different row — replaced the copy and threw the change away: a row the * user had just opened flicked back to unread until the server answered. * And `rollback()` restored a snapshot, which after a push either erased * the pushed rows or had to give up. Now the local list is always * `notifications` with the pending writes laid over it, so a push keeps * them and a rollback removes ONE write and nothing else. * * ★★ AN ENTRY ENDS IN ONE OF THREE WAYS: the host's list confirms it (the * row arrives read; a dismissed row stops arriving), the row disappears * from the host's list (nothing left to overlay), or its `rollback()` is * called. Confirmation is per id, so a mark-all-read the host confirms in * pages drains a page at a time. */ private readonly _optimistic; private _writeSeq; /** Debounced search term — what `queryChange` is built from. */ private readonly _debouncedSearch; /** `pendingIds` normalized to a Set. */ private readonly _pending; /** Whether this notification has a write in flight. */ isPending(item: NotificationItem): boolean; /** `itemErrors` normalized to a Map, so either shape can be bound. */ private readonly _itemErrors; /** The message for this row's failed write, or `null`. */ errorFor(item: NotificationItem): string | null; /** * Classes that make a row's async state VISIBLE. * * ★★★ `pendingIds` WAS BEHAVIOUR-ONLY, AND ITS OWN DOCSTRING SAID * OTHERWISE: "those rows dim and stop responding". The handlers did * refuse — `isPending` guarded every one of them — but `isPending` was * never referenced in the template, so nothing dimmed and nothing said * the click had landed. A row that refuses input and looks unchanged is * indistinguishable from a dead row, which is the same defect as * `OF-06-I7` one component over. */ rowStateClass(item: NotificationItem): string; /** The row's "Try again" was pressed. */ readonly retryItem: import("@angular/core").OutputEmitterRef; /** * Re-run the failed write for this row. * * ★ No `stopPropagation` here: the block this button sits in carries * `svStopPropagation`, which the row's action buttons already use for the * same reason. Doing it in both places looked prudent and was not — a * mutation removing the directive stayed green, because the handler was * covering for it while leaving the MESSAGE TEXT beside the button still * falling through to the row's overlay button. */ onRetryItem(item: NotificationItem): void; /** * Whether a search term or a non-default tab is narrowing the list — which * decides whether an empty list means "nothing here" or "nothing matching". */ readonly isFiltered: import("@angular/core").Signal; /** Reset the search and return to the first tab. */ clearFilters(): void; readonly _panelStyle: import("@angular/core").WritableSignal<{ top?: string; bottom?: string; right: string; maxH?: string; }>; private _clickOutsideHandler; /** Last query handed to the host — guards against re-emitting the same one. */ private _lastQuery; constructor(); /** * Pending open-transition timer. * * ★ It has to be cleared on destroy. Without the handle the callback below * still ran after teardown, mounting a `[data-nc-portal]` panel into * `document.body` that nothing owned any more and attaching a document click * listener and a window scroll listener that `ngOnDestroy` had already * finished removing — so a route change made while the bell was being opened * left an orphan panel on screen and two listeners running for the life of * the tab. Matches the Pro twin, which already held the handle. */ private _openTimer?; ngOnDestroy(): void; /** * The rows as the panel shows them: one per `id`, one per `dedupeKey`, * ordered (`OF-10-I3`, `OF-10-M4`, `OF-10-M5`). * * ★★★ EVERY COUNT IS TAKEN OVER THIS, NOT OVER `_items` (`OF-10-I2`). The * raw list still holds the duplicates and the collapsed rows the panel * does not render, so the badge counted a repeated id twice and counted * every row folded into a `dedupeKey` group — and clicking the one row * the user could see marked only that row read, so the badge stayed up * over a list with nothing unread in it, permanently. */ private readonly _merged; /** Unread among the rows actually in hand — as rendered, see `_merged`. */ readonly loadedUnreadCount: import("@angular/core").Signal; /** * Rows this component has marked read that the host has not confirmed yet. * * ★ DERIVED, NOT COUNTED. An incremented counter would have to be unwound * by every `rollback()`, by a re-send of the same list, and by a dismissal * of an already-counted row — three places to forget. Comparing the local * list against the host's makes all three self-correcting. */ private readonly _optimisticReads; /** * What the badge shows: the server's total when there is one, minus the * reads not yet confirmed (`OF-10-I2`). See {@link unreadTotal}. */ readonly unreadCount: import("@angular/core").Signal; readonly badgeLabel: import("@angular/core").Signal; /** * Classes for the trigger bell. * * `cssClass` on `` is a single string, so the conditional * `[class.animate-bounce]` the hand-drawn `` carried has to fold into * the value. Kept identical to the Pro twin so the two cannot drift. */ readonly bellCssClass: import("@angular/core").Signal<"w-5 h-5 animate-bounce" | "w-5 h-5">; readonly filteredItems: import("@angular/core").Signal; readonly groupedItems: import("@angular/core").Signal<{ label: string; items: NotificationItem[]; }[]>; readonly tabUnread: import("@angular/core").Signal>; /** * Toggles the floating panel. On open, portals the panel to document.body * so it is never clipped by overflow:hidden, transform, or backdrop-filter * on any ancestor element. */ togglePanel(event: MouseEvent): void; closePanel(): void; /** Close the floating panel when Escape is pressed while it is open. */ onEscape(): void; /** * Computes a viewport-safe position for the floating panel. * * Horizontal: right-aligned with the trigger's right edge, clamped so the * panel never overflows the left viewport edge. * * Vertical (flip logic): * - Prefer BELOW the trigger when there is at least 280 px of clearance. * - Flip ABOVE when there is more space above than below. * - `maxH` caps the panel so it never clips the opposite viewport edge. */ private _computePosition; /** * Appends a portal host
to document.body and renders the * #floatingPanel template into it via Angular's ViewContainerRef. * * The host div has no transform / filter / overflow, so position:fixed * children are viewport-relative in all browsers. */ private _createPortal; private _destroyPortal; /** * Recomputes panel position on every scroll / resize so the panel * tracks the trigger button as the page scrolls. * Uses requestAnimationFrame to throttle updates. */ private _attachScrollListener; private _removeScrollListener; private _attachClickOutside; private _removeClickOutside; /** The host's rows with every pending write laid over them. */ private _overlay; /** Drop what the host's list has confirmed, or no longer contains. */ private _prune; /** * Record an optimistic write and return its `rollback` handle. * * ★ The handle undoes THIS write only, whenever it is called. It used to * restore a whole-list snapshot, which is why it had to refuse once * anything else had changed — and a refused rollback left the failed write * on screen for good. Removing one entry from the overlay cannot undo * anything else, so there is nothing to refuse. Once the host has * confirmed (or dropped) the rows, the entry is gone and the call is a * no-op. */ private _write; /** * The rows one press on `item` acts on: itself, and every row collapsed into * it by a shared `dedupeKey` (`OF-10-M4`). * * ★★ THE ROW IS ONE EVENT, SO THE WRITE IS TOO. Acting on the survivor alone * left its siblings behind: opening it kept them unread (a badge nothing * visible explains), and dismissing it promoted the next sibling into its * place — the notification the user just dismissed came straight back. */ private _groupOf; onNotificationClick(item: NotificationItem): void; /** * A redacted row with its content removed rather than merely hidden. * * ★ `body`, `actor`, `target` and `actions` go with the title: an actor's * name and a target's label are sentences about the same record, and an * action button would post to a resource the user may not touch. */ private _redact; /** * The host's own row for this id. * * ★ The template holds the REDACTED copy, so an event emitted straight from * it would hand the host back a row whose title this component invented. * The host owns the real one — including `redacted: true`, which is what * lets it decide what a click on such a row means. */ private _original; onActionClick(item: NotificationItem, actionId: string, event: Event): void; onDismiss(item: NotificationItem, event: Event): void; onMarkAllRead(): void; /** * Hand the user to the full notifications surface. * * The panel closes on the way out: it is portalled to `document.body` at a * fixed position, so one left open would hang over the page it just navigated * to with nothing on that page able to dismiss it. */ onViewAll(): void; /** Ask the host for the next page of notifications. */ onLoadMore(): void; setTab(tab: string): void; typeIcon(type: NotificationType): string; typeColor(type: NotificationType): string; typeAccent(type: NotificationType): string; priorityColor(p: NotificationPriority): string; initials(name: string): string; timeAgo(iso: string): string; dateLabel(iso: string): string; static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; }