Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,53 @@
import { databaseTimestampToDate } from "~/utils/dates";
import { useUserIntlPreference } from "./useUserIntlPreference";
/**
* Zero-width space rendered (hidden via `invisible`) during SSR so the element
* reserves exactly one normal text line, avoiding layout shift on hydration
* without an empty box's baseline quirks.
*/
const SSR_PLACEHOLDER = "\u200b";
const SSR_FORMATTER = {
format: (_date: Date | number) => SSR_PLACEHOLDER,
formatRange: (_from: Date | number, _to: Date | number) => SSR_PLACEHOLDER,
};
/**
* SSR-safe wrapper around `Intl.DateTimeFormat`.
*
* Uses the user's locale and hour cycle preferences via `useUserIntlPreference`.
* Before hydration the returned formatter's methods return a zero-width space
* placeholder so that server output matches the initial client render while
* reserving one text line.
*
* Inputs accept either a `Date` or a database timestamp (`number`); numbers
* are converted via `databaseTimestampToDate`.
*/
export function useDateTimeFormat(options: Intl.DateTimeFormatOptions) {
const { language, hourCycle, isLoaded } = useUserIntlPreference();
const formatter = new Intl.DateTimeFormat(language, {
...options,
...(options.hour && hourCycle ? { hourCycle } : {}),
});
const realFormatter = {
format: (date: Date | number) => {
return formatter.format(
typeof date === "number" ? databaseTimestampToDate(date) : date,
);
},
formatRange: (from: Date | number, to: Date | number) => {
return formatter.formatRange(
typeof from === "number" ? databaseTimestampToDate(from) : from,
typeof to === "number" ? databaseTimestampToDate(to) : to,
);
},
};
return {
formatter: isLoaded ? realFormatter : SSR_FORMATTER,
isLoaded,
};
}

View File

@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import type { LanguageCode } from "~/modules/i18n/config";
import {
databaseTimestampToDate,
formatDistanceToNow as formatDistanceToNowUtil,
} from "~/utils/dates";
/**
* Hook that returns a `formatDistanceToNow` function (date-fns) bound to the
* current site language, for locale-aware "x ago" / "in x" output.
*
* Accepts either a `Date` or a database timestamp (`number`); numbers are
* converted via `databaseTimestampToDate`.
*
* Note: this intentionally does NOT honor the user's "always use browser
* language" preference (unlike `useDateTimeFormat`). The browser may be set to
* a language we have not loaded a date-fns locale for, so we use the
* site language to guarantee a translated result.
*/
export function useFormatDistanceToNow() {
const { i18n } = useTranslation();
return (
date: Date | number,
options?: Omit<Parameters<typeof formatDistanceToNowUtil>[1], "language">,
) => {
return formatDistanceToNowUtil(
typeof date === "number" ? databaseTimestampToDate(date) : date,
{
...options,
language: i18n.language as LanguageCode,
},
);
};
}

View File

@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import type { UserPreferences } from "~/db/tables-json";
import { useUser } from "~/features/auth/core/user";
import { useHydrated } from "../useHydrated";
/**
* Resolves the language and hour cycle to use when formatting dates and times
* for the current user. Prefers a browser language sharing a base tag with the
* active i18n language (e.g. `en-GB` over site `en`) for regional formatting.
* `isLoaded` is `false` until hydration; gate locale-dependent output on it to
* avoid hydration mismatches.
*/
export function useUserIntlPreference() {
const { i18n } = useTranslation();
const user = useUser();
const hydrated = useHydrated();
const browserLanguages = hydrated ? navigator.languages : [];
// does the user want to use their browser language even if the site is in another language?
const language =
browserLanguages.find((lang) => compareLanguages(lang, i18n.language)) ??
i18n.language;
return {
language,
hourCycle: resolveHourCycle(user?.preferences?.clockFormat),
isLoaded: hydrated,
};
}
function resolveHourCycle(
clockFormat: UserPreferences["clockFormat"],
): "h12" | "h23" | undefined {
if (clockFormat === "12h") return "h12";
if (clockFormat === "24h") return "h23";
return undefined;
}
function compareLanguages(a: string, b: string) {
const baseA = a.split("-")[0];
const baseB = b.split("-")[0];
return baseA.toUpperCase() === baseB.toUpperCase();
}

View File

@@ -0,0 +1,64 @@
import useSWRImmutable from "swr/immutable";
import type { PatronsListLoaderData } from "~/features/front-page/routes/patrons-list";
import { getWeaponUsage } from "~/features/sendouq/q-urls";
import type { FriendsForAddingLoaderData } from "~/features/sendouq/routes/friends-for-adding";
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { logger } from "~/utils/logger";
import { GET_FRIENDS_FOR_ADDING_ROUTE, PATRONS_LIST_ROUTE } from "~/utils/urls";
// TODO: replace with useFetcher after proper errr handling is implemented https://github.com/remix-run/react-router/discussions/10013
const fetcher = (key: string) => async (url: string) => {
const res = await fetch(url);
if (res.status !== 200) {
logger.error(`swr error ${key}: status code ${res.status}`);
throw new Error("fetching failed");
}
return res.json();
};
export function useWeaponUsage(args: {
userId: number;
season: number;
modeShort: ModeShort;
stageId: StageId;
}) {
const { data, error } = useSWRImmutable<WeaponUsageLoaderData>(
getWeaponUsage(args),
fetcher("getWeaponUsage"),
);
return {
weaponUsage: data?.usage,
isLoading: !error && !data,
isError: error,
};
}
export function useFriendsForAdding() {
const { data, error } = useSWRImmutable<FriendsForAddingLoaderData>(
GET_FRIENDS_FOR_ADDING_ROUTE,
fetcher(GET_FRIENDS_FOR_ADDING_ROUTE),
);
return {
friends: data?.friendsForAdding.friends,
teams: data?.friendsForAdding.teams,
isLoading: !error && !data,
isError: error,
};
}
export function usePatrons() {
const { data, error } = useSWRImmutable<PatronsListLoaderData>(
PATRONS_LIST_ROUTE,
fetcher(PATRONS_LIST_ROUTE),
);
return {
patrons: data?.patrons,
isLoading: !error && !data,
isError: error,
};
}

View File

@@ -0,0 +1,59 @@
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { z } from "zod";
import {
type ActionsOf,
type FieldsOf,
serializeFieldValue,
} from "~/utils/action-schemas";
interface UseActionSubmitOptions {
/** Route to submit to. Defaults to the current route. */
action?: string;
/** Defaults to "application/x-www-form-urlencoded". */
encType?: "application/x-www-form-urlencoded" | "application/json";
/** Fetcher to submit with, e.g. to share submitting state. Defaults to own fetcher. */
fetcher?: FetcherWithComponents<unknown>;
}
/**
* Programmatic counterpart of `<ActionButton>`: submits an `_action` mutation
* from an event handler, type checked against the route's zod action schema.
*
* @example
* const { submit } = useActionSubmit(deleteFriendSchema);
* submit("DELETE_FRIEND", { friendshipId });
*/
export function useActionSubmit<TSchema extends z.ZodTypeAny>(
_schema: TSchema,
opts?: UseActionSubmitOptions,
) {
const ownFetcher = useFetcher();
const fetcher = opts?.fetcher ?? ownFetcher;
const submit = <const TAction extends ActionsOf<TSchema>>(
action: TAction,
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
...rest: {} extends FieldsOf<TSchema, TAction>
? [fields?: FieldsOf<TSchema, TAction>]
: [fields: FieldsOf<TSchema, TAction>]
) => {
const fields = (rest[0] ?? {}) as Record<string, unknown>;
if (opts?.encType === "application/json") {
fetcher.submit(
{ _action: action, ...fields } as Parameters<typeof fetcher.submit>[0],
{ method: "post", action: opts?.action, encType: "application/json" },
);
return;
}
const payload: Record<string, string> = { _action: action };
for (const [name, value] of Object.entries(fields)) {
if (value === undefined || value === null) continue;
payload[name] = serializeFieldValue(value);
}
fetcher.submit(payload, { method: "post", action: opts?.action });
};
return { submit, fetcher, state: fetcher.state };
}

View File

@@ -0,0 +1,90 @@
import * as React from "react";
interface AutoRerenderOptions {
alignTo: Date;
}
const EVERY_TO_MS = {
second: 1000,
"ten seconds": 10_000,
minute: 60_000,
} as const;
interface TickStore {
subscribe: (listener: () => void) => () => void;
getSnapshot: () => number;
}
const tickStores = new Map<string, TickStore>();
function getTickStore(everyMs: number, alignToMs: number): TickStore {
const key = `${everyMs}:${alignToMs}`;
const existing = tickStores.get(key);
if (existing) return existing;
const listeners = new Set<() => void>();
let timeout: ReturnType<typeof setTimeout>;
const scheduleNextTick = () => {
const elapsed = Date.now() - alignToMs;
const remainder = ((elapsed % everyMs) + everyMs) % everyMs;
timeout = setTimeout(() => {
for (const listener of listeners) {
listener();
}
scheduleNextTick();
}, everyMs - remainder);
};
const store: TickStore = {
subscribe(listener) {
listeners.add(listener);
if (listeners.size === 1) scheduleNextTick();
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
clearTimeout(timeout);
tickStores.delete(key);
}
};
},
getSnapshot: () => Math.floor((Date.now() - alignToMs) / everyMs),
};
tickStores.set(key, store);
return store;
}
/**
* Forces the component to rerender periodically. Returns the `Date` of the
* latest tick — callers should consume this value (e.g. pass it to date-fns)
* so React Compiler can see the state is observable and won't memoize the
* rerender away.
*
* Backed by a shared tick store: all components subscribed to the same
* interval (and `alignTo` reference time) share a single timer. Ticks are
* aligned via a self-rescheduling `setTimeout`, so with `every: "minute"` the
* component rerenders at exactly `alignTo + N*60s` rather than at arbitrary
* offsets from mount.
*/
export function useAutoRerender(): Date;
export function useAutoRerender(every: "second" | "ten seconds"): Date;
export function useAutoRerender(
every: "minute",
options: AutoRerenderOptions,
): Date;
export function useAutoRerender(
every: "second" | "ten seconds" | "minute" = "second",
options?: AutoRerenderOptions,
): Date {
const everyMs = EVERY_TO_MS[every];
const alignToMs = options?.alignTo.getTime() ?? 0;
const store = getTickStore(everyMs, alignToMs);
const tick = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
);
return new Date(alignToMs + tick * everyMs);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react";
/**
* Keeps an app shell resource route's JSON fresh, outside the router. A
* `useFetcher` load that is still in flight when a navigation starts is folded
* into that navigation and has to settle before it completes (React Router
* reruns cancelled fetcher loads without consulting `shouldRevalidate`), so a
* refresh that only feeds the app shell would hold up every page change it
* happens to overlap with.
*/
export function useBackgroundResource<T>(url: string) {
const [data, setData] = React.useState<T>();
const [isLoading, setIsLoading] = React.useState(false);
const latestRequestRef = React.useRef(0);
// stable so effects that refresh after a mutation don't re-run every render
const refresh = React.useCallback(async () => {
const requestId = ++latestRequestRef.current;
setIsLoading(true);
try {
const response = await fetch(url);
if (!response.ok) return;
const json = (await response.json()) as T;
// a newer refresh already started, its response is the fresher one
if (requestId !== latestRequestRef.current) return;
setData(json);
} catch {
// a background refresh failing just leaves the last data in place
} finally {
if (requestId === latestRequestRef.current) setIsLoading(false);
}
}, [url]);
return { data, isLoading, refresh };
}

View File

@@ -0,0 +1,39 @@
import * as React from "react";
const COPY_SUCCESS_DURATION_MS = 2000;
/**
* Copies a string to the clipboard and exposes `copySuccess`, which stays
* `true` for a short while after a successful copy so a confirmation (e.g. a
* checkmark) can be flashed. `reset` clears `copySuccess` immediately.
*/
export function useCopyToClipboard(): {
copyToClipboard: (value: string) => void;
copySuccess: boolean;
reset: () => void;
} {
const [copySuccess, setCopySuccess] = React.useState(false);
React.useEffect(() => {
if (!copySuccess) return;
const timeout = setTimeout(
() => setCopySuccess(false),
COPY_SUCCESS_DURATION_MS,
);
return () => clearTimeout(timeout);
}, [copySuccess]);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(
() => setCopySuccess(true),
() => {},
);
};
const reset = () => setCopySuccess(false);
return { copyToClipboard, copySuccess, reset };
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
/**
* Runs `fn` once `ms` has elapsed without any value in `deps` changing. The
* timer is (re)started on mount and whenever `ms` or a value in `deps` changes.
*
* Uses the latest-ref pattern instead of `useEffectEvent`: effect events don't
* update past the first render inside `React.memo`/`React.forwardRef` wrapped
* components (React 19.2), which would silently break callers.
*/
export function useDebounce(
fn: () => void,
ms = 0,
deps: React.DependencyList = [],
) {
const callback = React.useRef(fn);
callback.current = fn;
React.useEffect(() => {
const timeout = setTimeout(() => callback.current(), ms);
return () => clearTimeout(timeout);
}, [ms, ...deps]);
}

View File

@@ -0,0 +1,32 @@
import * as React from "react";
// credits: https://github.com/sergiodxa/remix-utils/blob/main/src/react/use-hydrated.ts
function subscribe() {
return () => {};
}
/**
* Return a boolean indicating if the JS has been hydrated already.
* When doing Server-Side Rendering, the result will always be false.
* When doing Client-Side Rendering, the result will always be false on the
* first render and true from then on. Even if a new component renders it will
* always start with true.
*
* Example: Disable a button that needs JS to work.
* ```tsx
* let hydrated = useHydrated();
* return (
* <button type="button" disabled={!hydrated} onClick={doSomethingCustom}>
* Click me
* </button>
* );
* ```
*/
export function useHydrated() {
return React.useSyncExternalStore(
subscribe,
() => true,
() => false,
);
}

View File

@@ -0,0 +1,4 @@
import { useEffect, useLayoutEffect } from "react";
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { useWindowSize } from "./useWindowSize";
const MOBILE_BREAKPOINT = 600;
const DESKTOP_BREAKPOINT = 1000;
type LayoutSize = "mobile" | "tablet" | "desktop";
export function useLayoutSize(): LayoutSize {
const { width } = useWindowSize();
if (width === 0) return "desktop";
if (width < MOBILE_BREAKPOINT) return "mobile";
if (width < DESKTOP_BREAKPOINT) return "tablet";
return "desktop";
}
const listeners = new Set<() => void>();
let observer: ResizeObserver | null = null;
function subscribe(listener: () => void) {
listeners.add(listener);
if (!observer) {
observer = new ResizeObserver(() => {
for (const notify of listeners) {
notify();
}
});
const main = document.querySelector("main");
if (main) observer.observe(main);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
observer?.disconnect();
observer = null;
}
};
}
function getSnapshot() {
return document.querySelector("main")?.clientWidth ?? 0;
}
export function useMainContentWidth() {
return React.useSyncExternalStore(subscribe, getSnapshot, () => 0);
}

View File

@@ -0,0 +1,64 @@
import * as React from "react";
export function usePagination<T>({
items,
pageSize,
scrollToTop = true,
}: {
items: T[];
pageSize: number;
scrollToTop?: boolean;
}) {
const [currentPage, setCurrentPage] = React.useState(1);
const pagesCount = Math.ceil(items.length / pageSize);
const itemsToDisplay = React.useMemo(
() => items.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[currentPage, items, pageSize],
);
const nextPage = React.useCallback(() => {
if (currentPage < pagesCount) {
setCurrentPage((prev) => prev + 1);
if (scrollToTop) window.scrollTo(0, 0);
}
}, [currentPage, pagesCount, scrollToTop]);
const previousPage = React.useCallback(() => {
if (currentPage > 1) {
setCurrentPage((prev) => prev - 1);
if (scrollToTop) window.scrollTo(0, 0);
}
}, [currentPage, scrollToTop]);
const setPage = React.useCallback(
(page: number) => {
if (page > 0 && page <= pagesCount) {
setCurrentPage(page);
if (scrollToTop) window.scrollTo(0, 0);
}
},
[pagesCount, scrollToTop],
);
const thereIsNextPage = currentPage < pagesCount;
const thereIsPreviousPage = currentPage > 1;
// if the list changes from externally it might be that we are on a page that doesn't exist anymore
// setting state inside render looks weird but should be ok
if (itemsToDisplay.length === 0 && currentPage > 1) {
setCurrentPage(1);
}
return {
pagesCount,
currentPage,
itemsToDisplay,
nextPage,
previousPage,
setPage,
thereIsNextPage,
thereIsPreviousPage,
everythingVisible: items.length === itemsToDisplay.length,
};
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function subscribe(callback: () => void) {
const mediaQueryList = window.matchMedia(QUERY);
mediaQueryList.addEventListener("change", callback);
return () => mediaQueryList.removeEventListener("change", callback);
}
/**
* Returns a boolean indicating whether the user has requested reduced motion
* via the `prefers-reduced-motion` media query. Always returns `false` during
* server-side rendering and on the first client render.
*/
export function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false,
);
}

View File

@@ -0,0 +1,20 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import { recentlyReportedWeaponsPersisted } from "./useRecentlyReportedWeapons";
describe("recentlyReportedWeaponsPersisted", () => {
test("round-trips", () => {
assertRoundTrips(recentlyReportedWeaponsPersisted, [[], [0, 10, 8000]]);
});
test("malformed values decode to the default", () => {
assertDecodesToDefault(recentlyReportedWeaponsPersisted, [
"not json",
"[99999]",
'["0"]',
]);
});
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { numericEnum } from "~/utils/zod";
const MAX_REPORTED_WEAPONS = 7;
export const recentlyReportedWeaponsPersisted = PersistedState.define({
key: "sq__recently-reported-weapons",
storage: "local",
schema: z.array(numericEnum(mainWeaponIds)),
default: [],
});
/**
* This hook provides access to the list of recently reported weapons,
* which is persisted in local storage, and a function to add a new weapon
* to the list.
*
* If a weapon is added that already exists in the list, it will be moved to the front of the list.
* If the list exceeds the maximum number of reported weapons, the oldest weapon will be removed.
*/
export function useRecentlyReportedWeapons() {
const [recentlyReportedWeapons, setRecentlyReportedWeapons] =
usePersistedState(recentlyReportedWeaponsPersisted);
const addRecentlyReportedWeapon = (weapon: MainWeaponId) => {
setRecentlyReportedWeapons((previous) =>
PersistedState.prependToRecentList(
previous,
weapon,
MAX_REPORTED_WEAPONS,
),
);
};
return { recentlyReportedWeapons, addRecentlyReportedWeapon };
}

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { z } from "zod";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { GIT_COMMIT } from "~/utils/git-commit";
const reloadedForCommitPersisted = PersistedState.define({
key: "reloadedForCommit",
storage: "session",
schema: z.string(),
default: "",
});
/**
* Reloads the page when the server starts reporting a different build than the
* one this client's JavaScript was loaded from. Pages that stay open for hours
* while revalidating (SendouQ looking above all) otherwise keep running old
* code against new loader data, breaking whenever a deploy changes the shape of
* that data.
*/
export function useReloadOnNewDeploy(serverCommit: string) {
React.useEffect(() => {
if (!GIT_COMMIT || !serverCommit || serverCommit === GIT_COMMIT) return;
// the reload is expected to serve the new bundle, but should it not, the
// same build is never retried so the page can't end up in a reload loop
if (PersistedState.read(reloadedForCommitPersisted) === serverCommit) {
return;
}
PersistedState.write(reloadedForCommitPersisted, serverCommit);
window.location.reload();
}, [serverCommit]);
}

View File

@@ -0,0 +1,47 @@
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import type {
ParamDef,
SearchParamsDefinition,
SearchParamsValues,
} from "~/modules/search-params/search-params";
type PaginatedShape = { page: ParamDef<number> } & Record<
string,
ParamDef<any>
>;
/**
* Pagination state for pages where the current page lives in the `page` search
* param of the given search params definition and the loader takes care of
* slicing the results.
*
* Returns props that can be spread to the `<Pagination />` component.
*
* For paginating a list that is fully available on the client, see `usePagination`.
*/
export function useSearchParamPagination<Shape extends PaginatedShape>({
definition,
currentPage,
pagesCount,
}: {
definition: SearchParamsDefinition<Shape>;
currentPage: number;
pagesCount: number;
}) {
const [, setParams] = useSearchParamsTyped(definition);
const setPage = (page: number) => {
setParams({ page } as Partial<SearchParamsValues<Shape>>, {
replace: false,
preventScrollReset: false,
});
};
return {
currentPage,
pagesCount,
setPage,
nextPage: () => setPage(currentPage + 1),
previousPage: () => setPage(currentPage - 1),
};
}

View File

@@ -0,0 +1,16 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import { revealedTournamentsPersisted } from "./useSpoilerFree";
describe("revealedTournamentsPersisted", () => {
test("round-trips", () => {
assertRoundTrips(revealedTournamentsPersisted, [[], [1, 2, 3]]);
});
test("malformed values decode to the default", () => {
assertDecodesToDefault(revealedTournamentsPersisted, ["not json", "1"]);
});
});

View File

@@ -0,0 +1,33 @@
import { z } from "zod";
import { useUser } from "~/features/auth/core/user";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
export const revealedTournamentsPersisted = PersistedState.define({
key: "spoilerFreeRevealed",
storage: "session",
schema: z.array(z.number()),
default: [],
});
export function useSpoilerFree() {
const user = useUser();
const [revealedIds, setRevealedIds] = usePersistedState(
revealedTournamentsPersisted,
);
const isEnabled = Boolean(user?.preferences.spoilerFreeMode);
const isCensored = (tournamentId: number) =>
isEnabled && !revealedIds.includes(tournamentId);
const reveal = (tournamentId: number) => {
setRevealedIds((previous) => [...previous, tournamentId]);
};
const hide = (tournamentId: number) => {
setRevealedIds((previous) => previous.filter((id) => id !== tournamentId));
};
return { isEnabled, isCensored, reveal, hide };
}

View File

@@ -0,0 +1,43 @@
import * as React from "react";
function subscribe(listener: () => void) {
const observer = new MutationObserver(listener);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}
function getSnapshot() {
return document.documentElement.className;
}
function getServerSnapshot() {
return null;
}
/**
* Resolves CSS custom properties to their computed values (e.g. for canvas
* rendering which can't consume CSS variables directly), re-resolving when the
* theme class on the root element changes. All values are empty strings during
* server-side rendering and the initial hydration render.
*/
export function useThemeColors<K extends string>(
cssVariables: Record<K, string>,
): Record<K, string> {
const themeClass = React.useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const style =
themeClass === null ? null : getComputedStyle(document.documentElement);
const resolved = {} as Record<K, string>;
for (const [key, cssVariable] of Object.entries<string>(cssVariables)) {
resolved[key as K] = style?.getPropertyValue(cssVariable).trim() ?? "";
}
return resolved;
}

View File

@@ -0,0 +1,20 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import { seenFriendRequestsPersisted } from "./useUnseenFriendRequests";
describe("seenFriendRequestsPersisted", () => {
test("round-trips", () => {
assertRoundTrips(seenFriendRequestsPersisted, [[], [3, 2, 1]]);
});
test("malformed values decode to the default", () => {
assertDecodesToDefault(seenFriendRequestsPersisted, [
"not json",
'{"a":1}',
'["a"]',
]);
});
});

View File

@@ -0,0 +1,48 @@
import * as R from "remeda";
import { z } from "zod";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
const MAX_STORED_IDS = 200;
export const seenFriendRequestsPersisted = PersistedState.define({
key: "seen-friend-requests",
storage: "local",
schema: z.array(z.number()),
default: [],
});
/**
* Returns the count of incoming friend requests that the user has not yet seen.
*
* "Seen" requests are tracked per-device in local storage (the dedicated /friends
* page remains the authoritative view). The count updates when requests are marked
* as seen via {@link markFriendRequestsSeen} in the same tab, or when local storage
* changes in another tab.
*/
export function useUnseenFriendRequests(incomingRequestIds: number[]): number {
const [seenIdsList] = usePersistedState(seenFriendRequestsPersisted);
const seenIds = new Set(seenIdsList);
return incomingRequestIds.filter((id) => !seenIds.has(id)).length;
}
/**
* Records the given incoming friend request ids as seen, clearing the unseen badge.
*
* Ids are merged into the existing seen set rather than replacing it, so that
* acting on a request (e.g. declining it) doesn't make an already-seen request
* count as unseen again while other consumers still have a stale list. Only the
* most recent ids are kept to keep the stored set from growing without bound.
*/
export function markFriendRequestsSeen(ids: number[]) {
const merged = R.unique([
...PersistedState.read(seenFriendRequestsPersisted),
...ids,
])
.sort((a, b) => b - a)
.slice(0, MAX_STORED_IDS);
PersistedState.write(seenFriendRequestsPersisted, merged);
}

View File

@@ -0,0 +1,35 @@
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
const CSS_VARIABLE = "--visual-viewport-height";
/**
* Keeps the `--visual-viewport-height` CSS variable in sync with the visual
* viewport height on the document root. CSS has no native way to read the
* visual (as opposed to layout) viewport, so elements that must stay above the
* mobile on-screen keyboard rely on this variable to clamp their height as the
* keyboard opens and closes.
*/
export function useVisualViewportHeight() {
useIsomorphicLayoutEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = () => {
document.documentElement.style.setProperty(
CSS_VARIABLE,
`${viewport.height}px`,
);
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
document.documentElement.style.removeProperty(CSS_VARIABLE);
};
}, []);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
interface WindowSize {
width: number;
height: number;
}
function subscribe(listener: () => void) {
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}
/**
* Returns the current window dimensions, re-rendering on resize. Both
* dimensions are `0` during server-side rendering and the initial hydration
* render.
*/
export function useWindowSize(): WindowSize {
const width = React.useSyncExternalStore(
subscribe,
() => window.innerWidth,
() => 0,
);
const height = React.useSyncExternalStore(
subscribe,
() => window.innerHeight,
() => 0,
);
return { width, height };
}