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
1 change: 1 addition & 0 deletions packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://gravatar.com" />
<title>Sugabots</title>
<script>
/*
Expand Down
16 changes: 12 additions & 4 deletions packages/web/src/ui/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,22 @@ export function PersonAvatar({
const GRAVATAR_PIXEL_RATIO = 2;

/**
* gravatarUrl returns the address of `email`'s Gravatar image, sized for `size`
* CSS pixels. The address answers 404 when `email` has no Gravatar, rather than
* serving a generated placeholder.
* Avatars up to this many CSS pixels all ask Gravatar for the same image, so a
* person's image is downloaded once however many sizes the page shows it at.
*/
const LARGEST_SHARED_GRAVATAR_PX = 64;

/**
* gravatarUrl returns the address of `email`'s Gravatar image, sharp when shown
* at `size` CSS pixels. The address is the same for every `size` up to
* `LARGEST_SHARED_GRAVATAR_PX`. It answers 404 when `email` has no Gravatar,
* rather than serving a generated placeholder.
*/
function gravatarUrl(email: string, size: number): string {
// Not `crypto.subtle`: browsers leave it undefined on pages served over plain HTTP.
const hash = bytesToHex(sha256(utf8ToBytes(email.trim().toLowerCase())));
return `https://gravatar.com/avatar/${hash}?s=${size * GRAVATAR_PIXEL_RATIO}&d=404`;
const requestedPx = Math.max(size, LARGEST_SHARED_GRAVATAR_PX) * GRAVATAR_PIXEL_RATIO;
return `https://gravatar.com/avatar/${hash}?s=${requestedPx}&d=404`;
}

function initials(name: string): string {
Expand Down
24 changes: 20 additions & 4 deletions packages/web/src/ui/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,33 @@ describe("PersonAvatar", () => {
});

it("shows the Gravatar for the normalized email", async () => {
render(
<PersonAvatar person={{ name: "Rowan Ellis", email: " ROWAN@Example.com" }} size={32} />,
);
render(<PersonAvatar person={{ name: "Rowan Ellis", email: " ROWAN@Example.com" }} />);

await vi.waitFor(() =>
expect(screen.getByTitle("Rowan Ellis").querySelector("img")?.getAttribute("src")).toBe(
"https://gravatar.com/avatar/d9d745372fba31973b3ba22d208479cdbb4d80fe62434ab86e1e93fb87fefde0?s=64&d=404",
"https://gravatar.com/avatar/d9d745372fba31973b3ba22d208479cdbb4d80fe62434ab86e1e93fb87fefde0?s=128&d=404",
),
);
});

it("asks for the same Gravatar image for avatars up to 64 pixels", async () => {
const rowan = { name: "Rowan Ellis", email: "rowan@example.com" };
render(
<>
<PersonAvatar person={rowan} size={20} />
<PersonAvatar person={rowan} size={64} />
</>,
);

await vi.waitFor(() => {
const [small, large] = screen
.getAllByTitle("Rowan Ellis")
.map((avatar) => avatar.querySelector("img")?.getAttribute("src"));
expect(small).toBeDefined();
expect(small).toBe(large);
});
});

it("keeps the initials when there is no Gravatar", async () => {
render(<PersonAvatar person={{ name: "Rowan Ellis", email: "rowan@example.com" }} />);
const gravatar = await vi.waitFor(() => {
Expand Down
33 changes: 33 additions & 0 deletions packages/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
react(),
tailwindcss(),
preloadMe(),
preloadRubik(),
// The logo and icons belong to `@sugabots/avatars`. `index.html` cannot
// import from a package, so they are copied to the root instead.
viteStaticCopy({
Expand Down Expand Up @@ -86,6 +87,38 @@ function preloadMe(): Plugin {
};
}

/** Rubik's Latin file, which every page's text uses. Its other scripts load as text needs them. */
const RUBIK_LATIN_FILE = "@fontsource-variable/rubik/files/rubik-latin-wght-normal.woff2";

/**
* Starts Rubik, the app's text font, as the page arrives rather than once the
* stylesheet has loaded and the app has drawn text. Its name is hashed, so only
* a build can find it; the dev server and Storybook's iframe are left alone.
*/
function preloadRubik(): Plugin {
return {
name: "preload-rubik",
transformIndexHtml: (_html, { path, bundle }) => {
if (path !== "/index.html" || !bundle) return undefined;
const rubik = Object.values(bundle).find(
(file) =>
file.type === "asset" &&
file.originalFileNames.some((name) => name.endsWith(RUBIK_LATIN_FILE)),
);
// Fails the build rather than quietly dropping the preload if the package renames it.
if (!rubik) throw new Error(`The build has no ${RUBIK_LATIN_FILE} to preload`);
const href = `/${rubik.fileName}`;
return [
{
tag: "link",
attrs: { rel: "preload", as: "font", type: "font/woff2", crossorigin: true, href },
injectTo: "head",
},
];
},
};
}

/**
* A file `@sugabots/avatars` exports, as a path the copy plugin's globbing
* accepts: forward slashes, even on Windows.
Expand Down
Loading