Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,73 @@
},
"overrides": [
{
"files": ["src/lib/coverage/metadata.ts", "src/lib/coverage/colors.ts", "src/app/global-error.tsx", "src/lib/format.ts"],
"files": [
"src/lib/coverage/metadata.ts",
"src/lib/coverage/colors.ts",
"src/app/global-error.tsx",
"src/lib/format.ts"
],
"rules": {
"no-restricted-syntax": "off"
}
},
{
"files": [
"src/**/*.{ts,tsx}"
],
"excludedFiles": [
"src/lib/realtime/**",
"**/*.test.ts",
"**/*.test.tsx",
"src/lib/coverage/metadata.ts",
"src/lib/coverage/colors.ts",
"src/app/global-error.tsx",
"src/lib/format.ts"
],
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "Literal[value=/^#(?:[0-9a-fA-F]{3,4}){1,2}$/]",
"message": "Hard-coded hex colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)."
},
{
"selector": "Literal[value=/rgba?\\(/]",
"message": "Hard-coded rgb()/rgba() colours are not allowed in JSX. Use design tokens from src/lib/coverage/metadata.ts or CSS custom properties (see CONTRIBUTING.md)."
},
{
"selector": "Literal[value=1e7]",
"message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of the literal 1e7."
},
{
"selector": "BinaryExpression[operator='**'][right.value=7]",
"message": "Use USDC_BASE_UNIT / fromStroops from src/lib/format.ts instead of 10 ** 7."
},
{
"selector": "NewExpression[callee.name=/^(WebSocket|EventSource)$/]",
"message": "Do not open sockets/streams directly. Subscribe through src/lib/realtime (realtimeClient) so dedup, backoff, auth and teardown apply. To opt out, add `// eslint-disable-next-line no-restricted-syntax -- <justification>`."
},
{
"selector": "CallExpression[callee.property.name='subscribe'][callee.object.name='realtimeClient']:not(:matches(CallExpression[callee.name=/^use(Effect|LayoutEffect)$/] *))",
"message": "realtimeClient.subscribe must be called inside a useEffect/useLayoutEffect callback so the returned unsubscribe runs on unmount."
}
],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"@/lib/realtime/*",
"**/lib/realtime/*",
"./realtime/*"
],
"message": "Import from the public entry point \"@/lib/realtime\" instead of realtime internals."
}
]
}
]
}
}
]
}
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ const wallet = useStoreSelector(walletStore, (s) => s);
- Never read `localStorage` during render. Apply persisted state after mount
to avoid hydration mismatches.
- Review checklist: reject any PR that subscribes to a whole store object.

## Realtime conventions

- Subscribe only through `realtimeClient` from `@/lib/realtime`, inside a `useEffect`/`useLayoutEffect`; never `new WebSocket`/`new EventSource` or deep imports like `@/lib/realtime/client` (enforced by ESLint). Escape hatch: `// eslint-disable-next-line <rule> -- <justification>`.
- Per-topic flags live in `public/flags.json` under `realtime.topics.<topic>` (`{ "enabled": boolean, "kill"?: boolean }`). Precedence: kill > in-memory override > `NEXT_PUBLIC_REALTIME_TOPICS_OVERRIDE` > flags.json > disabled. Missing or malformed entries fail closed.
8 changes: 8 additions & 0 deletions public/flags.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"flags": {
"realtime-oracle": false
},
"realtime": {
"topics": {
"oracle_update": { "enabled": false },
"claims": { "enabled": false },
"pool_stats": { "enabled": false },
"notifications": { "enabled": false }
}
}
}
122 changes: 70 additions & 52 deletions src/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useNotifications } from "@/hooks/useNotifications";
import type { RefractNotification } from "@/lib/notifications";
import { VirtualList } from "@/components/ui/VirtualList";

/** Above this many items the list is windowed; below it the original markup is unchanged. */
const VIRTUALIZE_THRESHOLD = 50;
const ROW_HEIGHT = 120;

const SEVERITY_ICONS: Record<RefractNotification["severity"], string> = {
info: "ℹ",
Expand Down Expand Up @@ -47,6 +52,59 @@ export function NotificationCenter() {
};
}, [isOpen]);

const renderItem = (item: RefractNotification) => {
const colors = SEVERITY_COLORS[item.severity];
const icon = SEVERITY_ICONS[item.severity];

return (
<div
key={item.id}
className={`relative flex flex-col gap-1 rounded-lg border p-3 transition-all ${
item.read
? "border-white/5 bg-white/[0.02] opacity-70"
: `${colors.border} ${colors.bg} shadow-sm`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px] font-bold ${colors.bg} ${colors.text}`}
aria-hidden="true"
>
{icon}
</span>
<h4 className="text-xs font-bold text-pm-text">{item.title}</h4>
</div>
{!item.read && (
<button
type="button"
onClick={() => markAsRead(item.id)}
className="text-[10px] text-pm-text/40 hover:text-pm-text"
title="Mark as read"
>
Mark read
</button>
)}
</div>
<p className="text-[11px] leading-relaxed text-pm-text/70">{item.message}</p>
{item.href && (
<div className="mt-1 flex justify-end">
<Link
href={item.href}
onClick={() => {
markAsRead(item.id);
setIsOpen(false);
}}
className="text-[11px] font-semibold text-pm-violet hover:underline"
>
View Details →
</Link>
</div>
)}
</div>
);
};

return (
<div className="relative" ref={containerRef}>
<button
Expand Down Expand Up @@ -112,58 +170,18 @@ export function NotificationCenter() {
No notifications right now. You&apos;re all caught up!
</div>
) : (
notifications.map((item) => {
const colors = SEVERITY_COLORS[item.severity];
const icon = SEVERITY_ICONS[item.severity];

return (
<div
key={item.id}
className={`relative flex flex-col gap-1 rounded-lg border p-3 transition-all ${
item.read
? "border-white/5 bg-white/[0.02] opacity-70"
: `${colors.border} ${colors.bg} shadow-sm`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px] font-bold ${colors.bg} ${colors.text}`}
aria-hidden="true"
>
{icon}
</span>
<h4 className="text-xs font-bold text-pm-text">{item.title}</h4>
</div>
{!item.read && (
<button
type="button"
onClick={() => markAsRead(item.id)}
className="text-[10px] text-pm-text/40 hover:text-pm-text"
title="Mark as read"
>
Mark read
</button>
)}
</div>
<p className="text-[11px] leading-relaxed text-pm-text/70">{item.message}</p>
{item.href && (
<div className="mt-1 flex justify-end">
<Link
href={item.href}
onClick={() => {
markAsRead(item.id);
setIsOpen(false);
}}
className="text-[11px] font-semibold text-pm-violet hover:underline"
>
View Details →
</Link>
</div>
)}
</div>
);
})
notifications.length > VIRTUALIZE_THRESHOLD ? (
<VirtualList
items={notifications}
rowHeight={ROW_HEIGHT}
height={340}
getKey={(n) => n.id}
renderRow={renderItem}
label="Notifications"
/>
) : (
notifications.map(renderItem)
)
)}
</div>
</div>
Expand Down
77 changes: 77 additions & 0 deletions src/components/ui/VirtualList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { computeWindow } from "@/lib/virtualWindow";

interface VirtualListProps<T> {
items: T[];
rowHeight: number;
height: number;
getKey: (item: T) => string;
renderRow: (item: T, index: number) => ReactNode;
overscan?: number;
className?: string;
/** Fired when the user is scrolled to the bottom, for reveal-on-scroll logic. */
onAtBottomChange?: (atBottom: boolean) => void;
label?: string;
}

/**
* Fixed-row-height windowed list. Renders only the visible slice plus overscan,
* keeps rows in a `list`/`listitem` structure with aria-setsize/posinset so
* screen readers still know the full length, and preserves scroll position when
* items are appended (the container owns scrollTop).
*/
export function VirtualList<T>({
items,
rowHeight,
height,
getKey,
renderRow,
overscan = 4,
className,
onAtBottomChange,
label,
}: VirtualListProps<T>) {
const ref = useRef<HTMLDivElement>(null);
const atBottom = useRef(true);
const [scrollTop, setScrollTop] = useState(0);

const onScroll = useCallback(() => {
const el = ref.current;
if (!el) return;
setScrollTop(el.scrollTop);
const now = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
if (now !== atBottom.current) {
atBottom.current = now;
onAtBottomChange?.(now);
}
}, [onAtBottomChange]);

useEffect(() => {
const el = ref.current;
if (el && el.scrollTop > el.scrollHeight) el.scrollTop = 0;
}, [items.length]);

const w = computeWindow(items.length, rowHeight, scrollTop, height, overscan);

return (
<div ref={ref} onScroll={onScroll} style={{ height, overflowY: "auto" }} className={className} role="list" aria-label={label}>
<div style={{ height: w.totalHeight, position: "relative" }}>
<div style={{ transform: `translateY(${w.offsetTop}px)` }}>
{items.slice(w.start, w.end).map((item, i) => (
<div
key={getKey(item)}
role="listitem"
aria-setsize={items.length}
aria-posinset={w.start + i + 1}
style={{ height: rowHeight }}
>
{renderRow(item, w.start + i)}
</div>
))}
</div>
</div>
</div>
);
}
61 changes: 61 additions & 0 deletions src/lib/notificationThrottle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { ToastLimiter, type ToastItem } from "./notificationThrottle";

const item = (id: string, priority: ToastItem["priority"] = "normal"): ToastItem => ({ id, priority, title: id });

function make(extra = {}) {
let t = new Date(2026, 0, 1, 12).getTime();
const limiter = new ToastLimiter({ maxConcurrent: 3, maxPerWindow: 3, windowMs: 1000, now: () => t, ...extra });
return { limiter, advance: (ms: number) => (t += ms) };
}

describe("ToastLimiter", () => {
it("caps the rate and coalesces overflow into a count", () => {
const { limiter } = make();
for (let i = 0; i < 10; i++) limiter.push(item(`n${i}`));
const s = limiter.getState();
expect(s.visible).toHaveLength(3);
expect(s.overflow).toBe(7);
});

it("never drops critical items and evicts low priority first", () => {
const { limiter } = make();
limiter.push(item("a", "low"));
limiter.push(item("b"));
limiter.push(item("c"));
limiter.push(item("settle", "critical"));
const ids = limiter.getState().visible.map((v) => v.id);
expect(ids).toContain("settle");
expect(ids).not.toContain("a");
expect(limiter.getState().overflow).toBe(1);
});

it("allows critical items past the rate cap", () => {
const { limiter } = make();
for (let i = 0; i < 5; i++) limiter.push(item(`n${i}`));
limiter.push(item("claim", "critical"));
expect(limiter.getState().visible.some((v) => v.id === "claim")).toBe(true);
});

it("dedupes identical ids from realtime and REST", () => {
const { limiter } = make();
expect(limiter.push(item("x"))).toBe(true);
expect(limiter.push(item("x"))).toBe(false);
});

it("queues non-critical items during quiet hours and shows critical ones", () => {
const { limiter } = make({ quietHours: { start: 22, end: 14 } });
limiter.push(item("a"));
limiter.push(item("b", "critical"));
expect(limiter.getState().deferred.map((d) => d.id)).toEqual(["a"]);
expect(limiter.getState().visible.map((d) => d.id)).toEqual(["b"]);
expect(limiter.flushDeferred()).toHaveLength(1);
});

it("resets on account switch", () => {
const { limiter } = make();
limiter.push(item("a"));
limiter.reset();
expect(limiter.push(item("a"))).toBe(true);
});
});
Loading