the freetype client field is gone. the picker is a select-only menu of known clients with multi-select, chips for the active set, and a 32-client cap shared with the server. the api accepts a comma-separated client list and filters any-of with bound parameters. the trigger carets come from phosphor icons, newly adopted. bundle budget rises to 900000 bytes for the picker and the icon dependency.
120 lines
4.8 KiB
TypeScript
120 lines
4.8 KiB
TypeScript
/**
|
|
* The Activity search parameters, validated as pure functions so the route's
|
|
* `validateSearch` stays a one-liner and every rejection is testable without a
|
|
* router.
|
|
*
|
|
* A search value arrives from a URL, from history state, or from a hand-typed
|
|
* link, so nothing about its type is given. Anything that is not exactly the
|
|
* value the API can filter on becomes `undefined`: an unbounded page is honest,
|
|
* a page filtered on a coerced guess is not.
|
|
*/
|
|
|
|
import type { QueriesFilter } from "@/lib/types";
|
|
|
|
export const ACTIVITY_MODES = ["history", "live"] as const;
|
|
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
|
|
|
|
export interface ActivitySearch {
|
|
mode: ActivityMode;
|
|
since: number | undefined;
|
|
until: number | undefined;
|
|
domain: string | undefined;
|
|
client: string | undefined;
|
|
blocked: boolean | undefined;
|
|
}
|
|
|
|
/** History is the surface a bare `/activity` should open on: it answers questions. */
|
|
export function validateMode(value: unknown): ActivityMode {
|
|
return value === "live" ? "live" : "history";
|
|
}
|
|
|
|
/**
|
|
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
|
|
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
|
|
* string never passes, so `?since=now` cannot reach the API as garbage.
|
|
*/
|
|
export function validateTimestamp(value: unknown): number | undefined {
|
|
return Number.isSafeInteger(value) ? (value as number) : undefined;
|
|
}
|
|
|
|
/**
|
|
* The blocked filter. `false` is a real filter — "allowed only" — so it must
|
|
* survive; only a genuine boolean does, because `"false"` out of a URL parser
|
|
* that did not decode JSON would otherwise read as true.
|
|
*/
|
|
export function validateBlocked(value: unknown): boolean | undefined {
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
|
|
* the server treats an empty filter as no filter, and a URL that showed
|
|
* `domain=` as applied state would claim a filter that is not filtering.
|
|
*/
|
|
export function validateText(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed === "" ? undefined : trimmed;
|
|
}
|
|
|
|
/** `queries_repo.max_clients`: past this the API answers 400 rather than filter. */
|
|
export const MAX_CLIENTS = 32;
|
|
|
|
/**
|
|
* The client filter: a comma-separated list of exact addresses, canonicalized
|
|
* here and nowhere else.
|
|
*
|
|
* This is the one place the value is read, so it is the one place it can be made
|
|
* to mean exactly one thing. Everything downstream — the chips that show the
|
|
* filter and the request that applies it — reads what this returns, so the two
|
|
* cannot disagree about a link somebody pasted.
|
|
*
|
|
* The rule: entries are trimmed, blanks are dropped, repeats are dropped, and
|
|
* the list is cut to the cap. A blank entry names no client and a repeat asks
|
|
* for the same client twice, so neither changes which rows come back; dropping
|
|
* them is not a different filter, it is the same filter written once. The cut is
|
|
* a different filter, and it is the honest one available: the API refuses a
|
|
* longer list outright, so keeping the extra addresses would show a filter that
|
|
* cannot be applied at all.
|
|
*/
|
|
export function validateClients(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const seen = new Set<string>();
|
|
for (const entry of value.split(",")) {
|
|
const trimmed = entry.trim();
|
|
if (trimmed !== "") seen.add(trimmed);
|
|
if (seen.size === MAX_CLIENTS) break;
|
|
}
|
|
return seen.size === 0 ? undefined : [...seen].join(",");
|
|
}
|
|
|
|
/**
|
|
* The API filter for a validated search, built field by field.
|
|
*
|
|
* Only the fields that are actually set are written, so an unfiltered request
|
|
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
|
|
* know, and the infinite query's cache key is the filter object, so a key
|
|
* present-but-undefined and a key absent must not be two different windows onto
|
|
* the same rows. `mode` never appears — it selects the surface, not the rows.
|
|
*/
|
|
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
|
|
const filter: QueriesFilter = {};
|
|
if (search.domain !== undefined) filter.domain = search.domain;
|
|
if (search.client !== undefined) filter.client = search.client;
|
|
if (search.blocked !== undefined) filter.blocked = search.blocked;
|
|
if (search.since !== undefined) filter.since = search.since;
|
|
if (search.until !== undefined) filter.until = search.until;
|
|
return filter;
|
|
}
|
|
|
|
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
|
|
return {
|
|
mode: validateMode(search["mode"]),
|
|
since: validateTimestamp(search["since"]),
|
|
until: validateTimestamp(search["until"]),
|
|
domain: validateText(search["domain"]),
|
|
client: validateClients(search["client"]),
|
|
blocked: validateBlocked(search["blocked"]),
|
|
};
|
|
}
|