import { DatePipe } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
Injector,
runInInjectionContext,
signal,
untracked,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isObservable, type Observable, take } from 'rxjs';
import { resolveNotificationLinkForMode, ShippingModeService } from '@africanies/africanies-core';
import { AfricaniesIconComponent } from '@africanies/africanies-icons';
import type { PaginationMetaModel } from '@africanies/africanies-models';
import { ButtonComponent } from '../button/button.component';
import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n';
import { AfricaniesOverlayRef } from '../overlay/africanies-overlay-ref';
import { OVERLAY_DATA } from '../overlay/overlay-data.token';
import type {
AfricaniesNotification,
NotificationDrawerData,
NotificationDrawerResult,
NotificationPageResult,
} from './notification.types';
/** Pixels before the list end to prefetch the next page. */
const SCROLL_PREFETCH_PX = 120;
/** How long the “click again to confirm” arm stays active. */
const MARK_ALL_CONFIRM_MS = 4000;
/**
* Right-edge drawer listing in-app notifications.
*
* Opened via {@link NotificationDrawerService}.
*/
@Component({
selector: 'africanies-notification-drawer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'block h-full w-full min-w-0 overflow-hidden',
},
imports: [AfricaniesIconComponent, ButtonComponent],
template: `
{{ inboxLabel() }}
{{ title() }}
@if (hasUnread()) {
{{ unreadLabel() }}
}
@if (hasUnread() && canMarkAllRead()) {
}
`,
})
/**
* Right-edge drawer listing in-app notifications.
*/
export class NotificationDrawerPanel {
protected readonly ref =
inject>(AfricaniesOverlayRef);
private readonly data = inject(OVERLAY_DATA);
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private readonly shipping = inject(ShippingModeService);
private readonly i18n = inject(AfricaniesUiI18n);
private readonly scrollHost = viewChild>('scrollHost');
private readonly loadSentinel =
viewChild>('loadSentinel');
private observer: IntersectionObserver | null = null;
protected readonly viewLinkClass = computed(() => {
const base =
'inline-flex cursor-pointer items-center gap-1 self-start text-caption font-semibold underline underline-offset-[3px] transition-colors';
if (this.shipping.mode() === 'stn') {
return `${base} text-import-strong decoration-import-strong/35 hover:decoration-import-strong dark:text-import-light dark:decoration-import-light/40 dark:hover:decoration-import-light`;
}
return `${base} text-export-strong decoration-export-strong/35 hover:decoration-export-strong dark:text-export-light dark:decoration-export-light/40 dark:hover:decoration-export-light`;
});
protected readonly title = computed(
() =>
this.data.title ??
this.i18n.t('africaniesUi.notifications.title', 'Notifications'),
);
private readonly seededItems = this.data.notifications ?? [];
protected readonly items = signal(this.seededItems);
protected readonly markingId = signal(null);
protected readonly markingAll = signal(false);
protected readonly confirmingMarkAll = signal(false);
protected readonly loadingInitial = signal(
!!this.data.onLoadPage && this.seededItems.length === 0,
);
protected readonly loadingMore = signal(false);
protected readonly loadError = signal(null);
protected readonly hasMore = signal(false);
protected readonly currentPage = signal(0);
protected readonly unreadCount = computed(
() => (this.items() ?? []).filter((item) => !item.read).length,
);
protected readonly hasUnread = computed(() => this.unreadCount() > 0);
protected readonly markAllReadLabel = computed(() => {
if (this.markingAll()) {
return this.i18n.t('africaniesUi.notifications.marking', 'Marking…');
}
if (this.confirmingMarkAll()) {
return this.i18n.t(
'africaniesUi.notifications.confirmMarkAll',
'Click again to confirm',
);
}
return this.i18n.t(
'africaniesUi.notifications.markAllRead',
'Mark all as read',
);
});
protected readonly markAllReadAria = computed(() => {
if (this.confirmingMarkAll()) {
return this.i18n.t(
'africaniesUi.notifications.confirmMarkAllAria',
'Click again to confirm mark all as read',
);
}
return this.i18n.t(
'africaniesUi.notifications.markAllRead',
'Mark all as read',
);
});
protected readonly inboxLabel = computed(() =>
this.i18n.t('africaniesUi.notifications.inbox', 'Inbox'),
);
protected readonly unreadLabel = computed(() =>
this.i18n.tParams(
'africaniesUi.notifications.unread',
'{{count}} unread',
{ count: this.unreadCount() },
),
);
protected readonly closeLabel = computed(() =>
this.i18n.t('africaniesUi.notifications.close', 'Close notifications'),
);
protected readonly loadingLabel = computed(() =>
this.i18n.t(
'africaniesUi.notifications.loading',
'Loading notifications…',
),
);
protected readonly loadingMoreLabel = computed(() =>
this.i18n.t('africaniesUi.notifications.loadingMore', 'Loading more…'),
);
protected readonly tryAgainLabel = computed(() =>
this.i18n.t('africaniesUi.notifications.tryAgain', 'Try again'),
);
protected readonly emptyLabel = computed(() =>
this.i18n.t(
'africaniesUi.notifications.empty',
"You're all caught up.",
),
);
protected viewLabel(item: AfricaniesNotification): string {
return this.markingId() === item.id
? this.i18n.t('africaniesUi.notifications.opening', 'Opening…')
: this.i18n.t('africaniesUi.notifications.view', 'View');
}
protected markReadLabel(item: AfricaniesNotification): string {
return this.markingId() === item.id
? this.i18n.t('africaniesUi.notifications.marking', 'Marking…')
: this.i18n.t('africaniesUi.notifications.markRead', 'Mark read');
}
private markAllConfirmTimer: ReturnType | null = null;
constructor() {
if (this.data.onLoadPage) {
if (this.seededItems.length > 0) {
this.currentPage.set(1);
this.fetchPage(1, 'refresh');
} else {
this.fetchPage(1, 'replace');
}
}
effect(() => {
if (
this.loadingInitial() ||
!this.data.onLoadPage ||
!this.hasMore() ||
this.items().length === 0 ||
!this.loadSentinel()
) {
return;
}
untracked(() => this.scheduleInfiniteScrollSetup());
});
this.destroyRef.onDestroy(() => {
this.observer?.disconnect();
this.observer = null;
this.clearMarkAllConfirm();
});
}
protected usesPagination(): boolean {
return typeof this.data.onLoadPage === 'function';
}
protected canMarkAllRead(): boolean {
return typeof this.data.onMarkAllRead === 'function';
}
protected canMarkRead(): boolean {
return typeof this.data.onMarkRead === 'function';
}
protected retryInitialLoad(): void {
if (!this.data.onLoadPage) {
return;
}
this.loadError.set(null);
this.fetchPage(1, 'replace');
}
protected itemCardClass(item: AfricaniesNotification): string {
if (item.read) {
return 'border-border bg-transparent dark:border-white/10';
}
return 'border-ink-blue/20 bg-background-welcome dark:border-export-light/20 dark:bg-white/[0.04]';
}
protected viewHref(item: AfricaniesNotification): string {
const link = item.link?.trim();
if (!link) {
return '';
}
return resolveNotificationLinkForMode(link, this.shipping.mode());
}
protected onViewClick(event: MouseEvent, item: AfricaniesNotification): void {
if (this.markingId() || this.markingAll()) {
event.preventDefault();
return;
}
const closeDrawer = (): void => {
this.ref.close({
selectedId: item.id,
markedReadId: item.read ? undefined : item.id,
});
};
const navigate = (): void => {
const link = this.viewHref(item);
if (link) {
if (item.externalLink) {
window.open(link, '_blank', 'noopener,noreferrer');
} else {
window.location.assign(link);
}
}
closeDrawer();
};
if (item.read || !this.data.onMarkRead) {
closeDrawer();
return;
}
event.preventDefault();
this.markingId.set(item.id);
this.runHook(
() => this.data.onMarkRead?.(item.id),
() => {
this.patchRead(item.id);
this.markingId.set(null);
navigate();
},
() => this.markingId.set(null),
);
}
protected markRead(item: AfricaniesNotification): void {
if (item.read || this.markingId() || this.markingAll() || !this.data.onMarkRead) {
return;
}
this.markingId.set(item.id);
this.runHook(
() => this.data.onMarkRead?.(item.id),
() => {
this.patchRead(item.id);
this.markingId.set(null);
},
() => this.markingId.set(null),
);
}
/**
* First click arms a short confirm (Cursor Undo-style). Second click
* marks every notification read.
*/
protected markAllRead(): void {
if (this.markingAll() || !this.data.onMarkAllRead) {
return;
}
if (!this.confirmingMarkAll()) {
this.armMarkAllConfirm();
return;
}
this.clearMarkAllConfirm();
this.markingAll.set(true);
this.runHook(
() => this.data.onMarkAllRead?.(),
() => {
this.items.update((rows) =>
(rows ?? []).map((row) => ({ ...row, read: true })),
);
this.markingAll.set(false);
this.ref.close({ markedAllRead: true });
},
() => this.markingAll.set(false),
);
}
private armMarkAllConfirm(): void {
this.confirmingMarkAll.set(true);
this.clearMarkAllConfirmTimer();
this.markAllConfirmTimer = setTimeout(() => {
this.confirmingMarkAll.set(false);
this.markAllConfirmTimer = null;
}, MARK_ALL_CONFIRM_MS);
}
private clearMarkAllConfirm(): void {
this.confirmingMarkAll.set(false);
this.clearMarkAllConfirmTimer();
}
private clearMarkAllConfirmTimer(): void {
if (this.markAllConfirmTimer != null) {
clearTimeout(this.markAllConfirmTimer);
this.markAllConfirmTimer = null;
}
}
protected formatTimestamp(value: string): string {
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
return value;
}
return new DatePipe('en-US').transform(parsed, 'MMM d, h:mm a') ?? value;
}
private scheduleInfiniteScrollSetup(): void {
runInInjectionContext(this.injector, () => {
afterNextRender(() => this.setupInfiniteScroll());
});
}
private setupInfiniteScroll(): void {
this.observer?.disconnect();
const root = this.scrollHost()?.nativeElement;
const sentinel = this.loadSentinel()?.nativeElement;
if (!root || !sentinel || !this.data.onLoadPage) {
return;
}
this.observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
this.loadNextPage();
},
{
root,
rootMargin: `0px 0px ${SCROLL_PREFETCH_PX}px 0px`,
threshold: 0,
},
);
this.observer.observe(sentinel);
}
private resolveHasMore(
pagination: PaginationMetaModel | null,
itemCount: number,
): boolean {
if (itemCount === 0) {
return false;
}
if (pagination == null) {
return false;
}
if (pagination.has_next_page) {
return true;
}
if (pagination.total_pages > 0) {
return pagination.current_page < pagination.total_pages;
}
return false;
}
private loadNextPage(): void {
if (
!this.data.onLoadPage ||
!this.hasMore() ||
this.loadingMore() ||
this.loadingInitial()
) {
return;
}
this.fetchPage(this.currentPage() + 1, 'append');
}
private fetchPage(
page: number,
mode: 'replace' | 'append' | 'refresh',
): void {
const loadPage = this.data.onLoadPage;
if (!loadPage) {
return;
}
if (mode === 'replace') {
this.loadingInitial.set(true);
this.loadError.set(null);
} else if (mode === 'refresh') {
this.loadError.set(null);
} else {
this.loadingMore.set(true);
}
this.runPageLoad(
() => loadPage(page),
(result) => {
if (mode === 'replace' || mode === 'refresh') {
this.items.set(result.items ?? []);
} else {
this.appendItems(result.items ?? []);
}
this.currentPage.set(page);
this.hasMore.set(
this.resolveHasMore(
result.pagination,
(result.items ?? []).length,
),
);
this.loadingInitial.set(false);
this.loadingMore.set(false);
},
() => {
if (mode === 'replace') {
this.loadError.set(this.i18n.t('africaniesUi.notifications.loadError', 'Could not load notifications. Try again.'));
this.loadingInitial.set(false);
} else if (mode === 'refresh' && this.items().length === 0) {
this.loadError.set(this.i18n.t('africaniesUi.notifications.loadError', 'Could not load notifications. Try again.'));
}
this.loadingMore.set(false);
},
);
}
private appendItems(incoming: AfricaniesNotification[] | null | undefined): void {
const rows = incoming ?? [];
if (!rows.length) {
return;
}
this.items.update((existing) => {
const seen = new Set((existing ?? []).map((item) => item.id));
const next = rows.filter((item) => !seen.has(item.id));
return next.length ? [...existing, ...next] : existing;
});
}
private patchRead(id: string): void {
this.items.update((rows) =>
(rows ?? []).map((row) => (row.id === id ? { ...row, read: true } : row)),
);
}
private runPageLoad(
invoke: () => Observable | Promise,
succeed: (result: NotificationPageResult) => void,
fail: () => void,
): void {
let pending: Observable | Promise;
try {
pending = invoke();
} catch {
fail();
return;
}
if (isObservable(pending)) {
pending.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe({
next: (result) => succeed(result),
error: () => fail(),
});
return;
}
void Promise.resolve(pending).then(succeed, fail);
}
private runHook(
invoke: () => Observable | Promise | void | undefined,
succeed: () => void,
fail: () => void,
): void {
let pending: Observable | Promise | void;
try {
pending = invoke();
} catch {
fail();
return;
}
if (pending == null) {
succeed();
return;
}
if (isObservable(pending)) {
pending.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe({
next: () => succeed(),
error: () => fail(),
});
return;
}
void Promise.resolve(pending).then(succeed, fail);
}
}