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
12 changes: 12 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ const config: Config = {
},
],

// Makes the site installable ("Add to Home screen" / "Install app"): the manifest names
// the app and its icons. There is deliberately no service worker, so the installed app
// needs a connection, like the site itself.
headTags: [
{ tagName: 'link', attributes: { rel: 'manifest', href: '/manifest.webmanifest' } },
{ tagName: 'meta', attributes: { name: 'theme-color', content: '#1b1b1d' } },
{ tagName: 'link', attributes: { rel: 'apple-touch-icon', href: '/img/app/icon-192.png' } },
],

// Catches the browser's install offer for the homepage's "Install app" button.
clientModules: ['./src/clientModules/installPrompt.ts'],

presets: [
[
'classic',
Expand Down
37 changes: 37 additions & 0 deletions e2e/install.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';

test('links a web app manifest with installable icons', async ({ page, request }) => {
await page.goto('/');

await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');

const manifest = await (await request.get('/manifest.webmanifest')).json();
expect(manifest.display).toBe('standalone');
for (const size of ['192x192', '512x512']) {
const icon = manifest.icons.find((candidate: { sizes: string }) => candidate.sizes === size);
expect((await request.get(icon.src)).status()).toBe(200);
}
});

test('shows the install button only while the browser offers to install', async ({ page }) => {
await page.goto('/');
const button = page.getByRole('button', { name: 'Install app' });
await expect(button).toHaveCount(0);

// Browsers only fire the real event for a site they consider installable, so stand in for it.
await page.evaluate(() => {
const offer = new Event('beforeinstallprompt', { cancelable: true });
Object.assign(offer, {
prompt: () => {
document.body.dataset.installPrompted = 'yes';
return Promise.resolve();
},
});
window.dispatchEvent(offer);
});
await expect(button).toBeVisible();

await button.click();
await expect(page.locator('body')).toHaveAttribute('data-install-prompted', 'yes');
await expect(button).toHaveCount(0);
});
53 changes: 53 additions & 0 deletions src/clientModules/installPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';

/**
* Chromium browsers announce that the site can be installed through a single
* `beforeinstallprompt` event, usually right after the page loads and before React has
* hydrated. This client module runs as soon as the bundle does, keeps the event, and lets
* the InstallButton component ask for it later, on any page and after any navigation.
* Safari and Firefox never fire the event, so nothing is ever offered there.
*/
export interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
}

type Listener = (available: boolean) => void;

let deferred: BeforeInstallPromptEvent | null = null;
const listeners = new Set<Listener>();

function notify(): void {
listeners.forEach((listener) => listener(deferred !== null));
}

/** Calls `listener` now and whenever the install offer appears or goes away. */
export function onInstallAvailable(listener: Listener): () => void {
listeners.add(listener);
listener(deferred !== null);
return () => {
listeners.delete(listener);
};
}

/** Raises the browser's install dialog. An offer can be used once. */
export function promptInstall(): void {
const prompt = deferred;
deferred = null;
notify();
prompt?.prompt().catch(() => {
// Already used or blocked: the browser's own menu still offers installing.
});
}

if (ExecutionEnvironment.canUseDOM) {
window.addEventListener('beforeinstallprompt', (event) => {
// The button replaces the browser's own mini-infobar.
event.preventDefault();
deferred = event as BeforeInstallPromptEvent;
notify();
});
window.addEventListener('appinstalled', () => {
deferred = null;
notify();
});
}
21 changes: 21 additions & 0 deletions src/components/InstallButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import { onInstallAvailable, promptInstall } from "../../clientModules/installPrompt";

/**
* "Install app": rendered only while the browser offers to install the site (see
* clientModules/installPrompt.ts), so it never shows in Safari or Firefox, or once the
* site is installed.
*/
export default function InstallButton(): JSX.Element | null {
const [available, setAvailable] = useState(false);
useEffect(() => onInstallAvailable(setAvailable), []);

if (!available) {
return null;
}
return (
<button type="button" className="button button--secondary button--lg" onClick={promptInstall}>
馃摬 Install app
</button>
);
}
2 changes: 2 additions & 0 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import HomepageFeatures from "../components/HomepageFeatures";
import InstallButton from "../components/InstallButton";
import Heading from "@theme/Heading";

import styles from "./index.module.css";
Expand All @@ -17,6 +18,7 @@ function HomepageHeader() {
</Heading>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<InstallButton />
{/* <Link
className="button button--secondary button--lg"
to="/docs/intro">
Expand Down
Binary file added static/img/app/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/app/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/img/app/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions static/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "codeuctivity",
"short_name": "codeuctivity",
"description": "Turning Coffee into Code: A Journey of Joyful Creation",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#1b1b1d",
"background_color": "#1b1b1d",
"icons": [
{ "src": "/img/app/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "/img/app/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
{ "src": "/img/app/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}
Loading