Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/api/sponsorship-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Sponsorship config requests (#546)

## Current behaviour

- The dashboard home fetches sponsorship configs for the visible wallets in batches of five
(`SPONSORSHIP_CONCURRENCY`).
- The global sponsorship page already batches its requests in groups of five
(`CONFIG_CONCURRENCY`).

Both still send one request per wallet, so the count grows with the number of wallets. Batching
only limits how many run at once.

## Fix that needs the backend

Return `sponsorship` with each wallet in the wallet list response, or add
`GET /v1/sponsorship?wallet_ids=...`. The client then makes a constant number of requests per
page.
3 changes: 2 additions & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { AuthProvider } from "@/lib/useAuth";

export const dynamic = "force-dynamic";

Expand All @@ -14,5 +15,5 @@ export const metadata: Metadata = {
};

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return children;
return <AuthProvider>{children}</AuthProvider>;
}
15 changes: 12 additions & 3 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { ErrorState } from "@/components/dashboard/WalletUI";

export const dynamic = "force-dynamic";

// How many sponsorship configs to request at once for a page of wallets.
const SPONSORSHIP_CONCURRENCY = 5;

export default function DashboardHome() {
const { user, token, loading, logout } = useAuth();
const [wallets, setWallets] = useState<WalletView[] | null>(null);
Expand Down Expand Up @@ -49,9 +52,15 @@ export default function DashboardHome() {
setWallets(page.data);
setNextCursor(page.next_cursor);
// Fetch sponsorship configs for the visible page in parallel.
const results = await Promise.allSettled(
page.data.map((w) => getSponsorshipConfig(asAuthToken(token), asWalletId(w.id))),
);
const results: PromiseSettledResult<SponsorshipConfig>[] = [];
for (let i = 0; i < page.data.length; i += SPONSORSHIP_CONCURRENCY) {
const batch = page.data.slice(i, i + SPONSORSHIP_CONCURRENCY);
results.push(
...(await Promise.allSettled(
batch.map((w) => getSponsorshipConfig(asAuthToken(token), asWalletId(w.id))),
)),
);
}
if (aborted) return;
const map = new Map<string, SponsorshipConfig | null>();
page.data.forEach((w, i) => {
Expand Down
41 changes: 37 additions & 4 deletions src/components/dashboard/DashboardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { DashboardBackground } from "./DashboardBackground";
import { IdleLock } from "./IdleLock";
import { network } from "@/lib/network";
import type { User } from "@/lib/auth";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";

/** Dashboard chrome: network banner, idle-warning banner, sidebar, topbar, content. */
export function DashboardShell({
Expand All @@ -21,6 +23,14 @@ export function DashboardShell({
blockNavigation?: (href: string) => boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);

// Close the mobile menu after navigating.
useEffect(() => {
setMenuOpen(false);
}, [pathname]);

return (
<div className="relative flex min-h-screen flex-col bg-background">
<DashboardBackground />
Expand All @@ -37,12 +47,35 @@ export function DashboardShell({
)}

<div className="relative z-10 flex flex-1">
<Sidebar user={user} blockNavigation={blockNavigation} />
<Sidebar user={user} blockNavigation={blockNavigation} className="hidden lg:flex" />
{menuOpen && (
<>
<div
aria-hidden="true"
className="fixed inset-0 z-20 bg-black/40 lg:hidden"
onClick={() => setMenuOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-30 lg:hidden">
<Sidebar user={user} blockNavigation={blockNavigation} className="h-full" />
</div>
</>
)}

<div className="flex flex-1 flex-col">
{/* topbar */}
<header className="flex items-center justify-between border-b border-border px-8 py-4">
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-4 sm:px-8">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Open menu"
aria-expanded={menuOpen}
className="rounded-md p-2 text-muted hover:bg-hover hover:text-foreground lg:hidden"
>
<span aria-hidden="true">☰</span>
</button>
<h1 className="truncate text-lg font-semibold text-foreground">{title}</h1>
</div>
<div className="flex items-center gap-4">
<button
onClick={onLogout}
Expand All @@ -54,7 +87,7 @@ export function DashboardShell({
</div>
</header>

<main className="flex-1 px-8 py-8">
<main className="flex-1 px-4 py-8 sm:px-8">
<div className="mx-auto w-full max-w-6xl">
<Parallax speed={28}>{children}</Parallax>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ const NAV: {
export function Sidebar({
user,
blockNavigation,
className,
}: {
user?: User | null;
/** Returns true to cancel a nav click (e.g. while unsaved one-time secrets are shown). */
blockNavigation?: (href: string) => boolean;
/** Extra classes, e.g. to hide the sidebar on small screens. */
className?: string;
}) {
const pathname = usePathname();

return (
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-surface-sunken px-3 py-5">
<aside className={`flex w-64 shrink-0 flex-col border-r border-border bg-surface-sunken px-3 py-5 ${className ?? ""}`}>
<div className="px-3">
<Logo />
<p className="mt-5 text-sm font-medium text-foreground">
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function apiFetch<T>(
...rest,
signal,
headers: {
"content-type": "application/json",
...(rest.body !== undefined ? { "content-type": "application/json" } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
...headers,
},
Expand Down
24 changes: 22 additions & 2 deletions src/lib/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { createContext, createElement, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { toast } from "sonner";
import { ApiError } from "./api";
Expand All @@ -25,7 +25,7 @@ const MIN_LOADING_MS = 700;
* 2. 401 from any apiFetch → "session-expired" CustomEvent.
* 3. Another tab removing octo_token → storage event.
*/
export function useAuth() {
function useAuthSession() {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<User | null>(null);
Expand Down Expand Up @@ -139,6 +139,26 @@ export function useAuth() {
return { user, token, loading, authError, retrySessionCheck, logout };
}

type AuthSession = ReturnType<typeof useAuthSession>;

const AuthContext = createContext<AuthSession | null>(null);

/**
* Runs the session check once for the dashboard. Mounted in the dashboard layout, so
* navigating between pages reuses the result instead of calling `me()` again.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const session = useAuthSession();
return createElement(AuthContext.Provider, { value: session }, children);
}

/** The shared session from AuthProvider. Dashboard pages always render inside it. */
export function useAuth(): AuthSession {
const session = useContext(AuthContext);
if (!session) throw new Error("useAuth must be used inside AuthProvider");
return session;
}

export function isAuthenticationFailure(err: unknown): boolean {
return err instanceof ApiError && (err.status === 401 || err.status === 403);
}