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
94 changes: 94 additions & 0 deletions apps/web/app/components/pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Link } from "react-router";

/** Beyond this many, the numbers are thinned to the ends and the neighbourhood. */
const SHOWN = 7;

/** A number to offer, or the run of them left out in front of the next one. */
type Cell = { page: number } | { gapBefore: number };

const cells = (page: number, pages: number): Cell[] => {
if (pages <= SHOWN) return Array.from({ length: pages }, (_, index) => ({ page: index + 1 }));

const near = [page - 1, page, page + 1].filter((n) => n > 1 && n < pages);
const shown = [1, ...near, pages];

return shown.flatMap((n, index): Cell[] => {
const previous = shown[index - 1];
if (previous === undefined || n === previous + 1) return [{ page: n }];
// A gap of exactly one page is spelled out instead: an ellipsis standing in
// for a single number is longer than the number and hides the way to it.
return n === previous + 2
? [{ page: previous + 1 }, { page: n }]
: [{ gapBefore: n }, { page: n }];
});
};

const CELL = "rounded-sm px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em]";
const LINK = `${CELL} text-muted hover:text-ink`;

const Step = ({ to, rel, children }: { to: string | null; rel: string; children: string }) =>
to === null ? (
// An end of the list, kept in place so the numbers between the two steps do
// not shift sideways as a reader walks the pages.
<span className={`${CELL} text-rule`} aria-hidden="true">
{children}
</span>
) : (
<Link to={to} rel={rel} className={LINK}>
{children}
</Link>
);

export const Pagination = ({
page,
pages,
href,
label,
}: {
page: number;
pages: number;
href: (page: number) => string;
/** What is being paged through, for a reader who lands on the control itself. */
label: string;
}) => {
if (pages <= 1) return null;

return (
<nav
aria-label={label}
className="mt-10 flex flex-wrap items-center justify-center gap-1 border-t border-rule pt-8"
>
<Step to={page > 1 ? href(page - 1) : null} rel="prev">
← Previous
</Step>

{cells(page, pages).map((cell) => {
if ("gapBefore" in cell) {
return (
<span key={`gap-${cell.gapBefore}`} className={`${CELL} text-muted`} aria-hidden="true">
...
</span>
);
}
return cell.page === page ? (
<span key={cell.page} aria-current="page" className={`${CELL} bg-ink text-paper`}>
{cell.page}
</span>
) : (
<Link
key={cell.page}
to={href(cell.page)}
aria-label={`Page ${cell.page}`}
className={LINK}
>
{cell.page}
</Link>
);
})}

<Step to={page < pages ? href(page + 1) : null} rel="next">
Next →
</Step>
</nav>
);
};
57 changes: 42 additions & 15 deletions apps/web/app/components/search-results.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router";
import { corpusState, serverCorpusState, startCorpus, subscribeCorpus } from "~/lib/corpus";
import { parsePage } from "~/lib/filter";
import { pageOf } from "~/lib/pagination";
import { specsPath } from "~/lib/paths";
import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles";
import { searchDocs, searchTerms } from "~/lib/search";
import { Pagination } from "./pagination";
import { SpecRow } from "./spec-row";

const LIMIT = 60;
const PAGE_SIZE = 20;

const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;

Expand All @@ -29,6 +34,10 @@ export const SearchResults = ({
serverCorpusState,
);

// Read off the URL rather than the loader: the server is not asked again when
// only the page changed, so its idea of which page this is would be stale.
const [params] = useSearchParams();

const scoped = useMemo(
() =>
docs.filter(
Expand All @@ -38,14 +47,21 @@ export const SearchResults = ({
),
[docs, topic, kind],
);
const hits = useMemo(() => searchDocs(scoped, query, LIMIT), [scoped, query]);
const hits = useMemo(() => searchDocs(scoped, query), [scoped, query]);
const terms = useMemo(() => searchTerms(query), [query]);
// The corpus arrives in pages of its own, so the last page grows under the
// reader while the relays are still being read. Clamping is what absorbs it.
const requested = parsePage(params);
const { items, page, pages } = useMemo(
() => pageOf(hits, requested, PAGE_SIZE),
[hits, requested],
);

// Only the authors a reader ended up in front of: the corpus holds far more.
const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState);
useEffect(() => {
wantAuthors(hits.map((hit) => hit.doc.pubkey));
}, [hits]);
wantAuthors(items.map((hit) => hit.doc.pubkey));
}, [items]);

const walking = status === "idle" || status === "loading" || status === "syncing";
const count = walking
Expand All @@ -68,6 +84,7 @@ export const SearchResults = ({
className="mt-10 border-t border-rule pt-6 font-mono text-[0.6875rem] uppercase tracking-[0.18em] text-muted"
>
{count}
{pages > 1 && `, page ${page} of ${pages}`}
{status === "failed" && ", some relays did not answer"}
</p>

Expand All @@ -78,17 +95,27 @@ export const SearchResults = ({
: "No document carries these words. Try fewer of them."}
</p>
) : (
<ul className="mt-6">
{hits.map((hit) => (
<SpecRow
key={hit.doc.path}
spec={hit.doc}
author={authors[hit.doc.pubkey] ?? null}
excerpt={hit.excerpt}
terms={terms}
/>
))}
</ul>
<>
<ul className="mt-6">
{items.map((hit) => (
<SpecRow
key={hit.doc.path}
spec={hit.doc}
author={authors[hit.doc.pubkey] ?? null}
excerpt={hit.excerpt}
terms={terms}
/>
))}
</ul>
<Pagination
page={page}
pages={pages}
href={(n) =>
specsPath({ q: query, topic: topic ?? undefined, kind: kind ?? undefined, page: n })
}
label="Search results"
/>
</>
)}
</>
);
Expand Down
21 changes: 20 additions & 1 deletion apps/web/app/lib/filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseSearchQuery, parseSpecFilter } from "./filter";
import { parsePage, parseSearchQuery, parseSpecFilter } from "./filter";

const filterOf = (query: string) => parseSpecFilter(new URLSearchParams(query));
const searchOf = (query: string) => parseSearchQuery(new URLSearchParams(query));
const pageOf = (query: string) => parsePage(new URLSearchParams(query));

describe("parseSpecFilter", () => {
it("reads a topic and a kind", () => {
Expand Down Expand Up @@ -34,6 +35,24 @@ describe("parseSpecFilter", () => {
});
});

describe("parsePage", () => {
it("reads the page asked for", () => {
expect(pageOf("page=2")).toBe(2);
expect(pageOf("topic=nostr&page=12")).toBe(12);
});

it("is page one when nothing was asked for", () => {
expect(pageOf("")).toBe(1);
expect(pageOf("topic=nostr")).toBe(1);
});

it("is page one for anything that is not a page number", () => {
for (const query of ["page=abc", "page=0", "page=-2", "page=1.5", "page=", "page=9999"]) {
expect(pageOf(query), query).toBe(1);
}
});
});

describe("parseSearchQuery", () => {
it("reads what the visitor typed", () => {
expect(searchOf("q=relay+discovery")).toBe("relay discovery");
Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/lib/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type SpecFilterParams = { topic?: string; kind?: number };

const TOPIC = /^[a-z0-9][a-z0-9\-_.]{0,63}$/;
const KIND = /^\d{1,7}$/;
const PAGE = /^\d{1,3}$/;
/** Long enough for a sentence, short enough that a URL stays a URL. */
const MAX_QUERY = 100;

Expand All @@ -20,6 +21,16 @@ export const parseSpecFilter = (params: URLSearchParams): SpecFilterParams => {
};
};

/**
* Anything that is not a page number is page one, on the same rule as the filter
* above: the caller rebuilds its canonical URL from what survived here, so a
* listing is never indexed under a dozen spellings of its first page.
*/
export const parsePage = (params: URLSearchParams): number => {
const page = params.get("page")?.trim() ?? "";
return PAGE.test(page) ? Math.max(1, Number(page)) : 1;
};

/**
* Kept apart from the filter above, which relays are asked: this one never
* reaches a relay, and never reaches a feed either. It is tidied rather than
Expand Down
37 changes: 37 additions & 0 deletions apps/web/app/lib/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { pageOf } from "./pagination";

const items = Array.from({ length: 94 }, (_, index) => index);

describe("pageOf", () => {
it("cuts a listing into pages of the size asked for", () => {
expect(pageOf(items, 1, 20).items).toEqual([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
]);
expect(pageOf(items, 2, 20).items[0]).toBe(20);
expect(pageOf(items, 5, 20)).toMatchObject({ page: 5, pages: 5, total: 94 });
});

it("leaves the last page short rather than padding it", () => {
expect(pageOf(items, 5, 20).items).toHaveLength(14);
});

it("counts an exact multiple without an empty page after it", () => {
expect(pageOf(items.slice(0, 60), 1, 20).pages).toBe(3);
});

it("is page one of one when there is nothing to show", () => {
expect(pageOf([], 1, 20)).toEqual({ items: [], page: 1, pages: 1, total: 0 });
});

it("clamps a page past the end to the last one", () => {
expect(pageOf(items, 99, 20)).toMatchObject({ page: 5 });
expect(pageOf([], 4, 20)).toMatchObject({ page: 1 });
});

it("clamps a page below the first one", () => {
for (const page of [0, -3, 0.5]) {
expect(pageOf(items, page, 20).page, String(page)).toBe(1);
}
});
});
20 changes: 20 additions & 0 deletions apps/web/app/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export type Paged<T> = { items: T[]; page: number; pages: number; total: number };

/**
* The page asked for is clamped rather than refused: a number past the end is
* usually a listing that shrank since the link was made, and a redirect derived
* from a window that moves every minute would outlive the window itself. An
* empty listing is still page one of one, so a caller never has to special-case
* having nothing.
*/
export const pageOf = <T>(items: T[], page: number, size: number): Paged<T> => {
const pages = Math.max(1, Math.ceil(items.length / size));
const current = Math.min(Math.max(1, Math.floor(page)), pages);
const start = (current - 1) * size;
return {
items: items.slice(start, start + size),
page: current,
pages,
total: items.length,
};
};
26 changes: 21 additions & 5 deletions apps/web/app/lib/paths.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
export type SpecsQuery = { topic?: string; kind?: string | number; q?: string };
export type SpecsQuery = { topic?: string; kind?: string | number; q?: string; page?: number };

/** The first page is the listing itself, so it never carries a number of its own. */
const pageParam = (page: number | undefined): string | null =>
page !== undefined && page > 1 ? String(page) : null;

const withFilter = (base: string, query: SpecsQuery = {}): string => {
const params = new URLSearchParams();
// First, because it is what the reader typed and the rest only narrows it.
if (query.q) params.set("q", query.q);
if (query.topic) params.set("topic", query.topic);
if (query.kind !== undefined && query.kind !== "") params.set("kind", String(query.kind));
// Last, since it narrows nothing: it only says where in the result you are.
const page = pageParam(query.page);
if (page !== null) params.set("page", page);
const search = params.toString();
return search === "" ? base : `${base}?${search}`;
};

export const specsPath = (query: SpecsQuery = {}): string => withFilter("/specs", query);

/** A feed cannot replay a search, so `q` never reaches one. */
export const rssPath = (query: Omit<SpecsQuery, "q"> = {}): string => withFilter("/rss.xml", query);
export const atomPath = (query: Omit<SpecsQuery, "q"> = {}): string =>
withFilter("/atom.xml", query);
/** A feed cannot replay a search and has no pages, so neither reaches one. */
export type FeedQuery = Omit<SpecsQuery, "q" | "page">;
export const rssPath = (query: FeedQuery = {}): string => withFilter("/rss.xml", query);
export const atomPath = (query: FeedQuery = {}): string => withFilter("/atom.xml", query);

export const listingTitle = (query: SpecsQuery = {}): string => {
if (query.q) return `Search results for "${query.q}"`;
Expand All @@ -42,6 +49,15 @@ export const ogImagePath = (npub: string, identifier: string): string =>
/** The same, for an author, drawn from their profile and their shelf. */
export const authorOgImagePath = (npub: string): string => `/og/${npub}`;

/**
* Further down one author's shelf. `authorPath` in the schema names the key, and
* that address is not the place to say which page of it you are reading.
*/
export const authorPagePath = (npub: string, page?: number): string => {
const value = pageParam(page);
return value === null ? `/${npub}` : `/${npub}?page=${value}`;
};

/**
* An author's feeds hang under the author, not under the listing: what a reader
* subscribes to here is a person, and a person is not a query.
Expand Down
16 changes: 11 additions & 5 deletions apps/web/app/lib/specs.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,18 @@ export const loadSpecs = (filter: SpecFilter = {}, limit = 30): Promise<SpecCard
},
);

/** A page of what one author signed. Long, because it is their whole shelf. */
const AUTHOR_LIMIT = 60;
/**
* How far down a listing can be paged. The window is fetched whole and cut into
* pages here rather than walked with a relay cursor: an `until` is a `created_at`,
* and a corpus mirrored from git carries whole commits' worth of documents on the
* same second, which no cursor can step past.
*/
export const LISTING_WINDOW = 500;

/**
* The page, its card and its feeds ask for the same list under the same key, so
* an unfurled link costs one relay query rather than three.
* The whole shelf, unpaged: the page, its card and its feeds ask for the same
* list under the same key, so an unfurled link costs one relay query rather than
* three, and each of them counts what the key actually signed.
*/
export const loadAuthorSpecs = (pubkey: string): Promise<SpecCard[]> =>
loadSpecs({ author: pubkey }, AUTHOR_LIMIT);
loadSpecs({ author: pubkey }, LISTING_WINDOW);
Loading
Loading