Make the app an installable PWA with phone layout and Web Push
Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
@@ -275,4 +275,18 @@ export const AppData = {
|
||||
* Largest picture a bot or a workspace can have.
|
||||
*/
|
||||
ProfileImageMaxBytes: 5 * 1024 * 1024,
|
||||
/**
|
||||
* A user with an open socket seen this recently gets no push: they
|
||||
* are already looking at the app.
|
||||
*/
|
||||
PushPresenceWindowMs: 45_000,
|
||||
/**
|
||||
* A notification's body is one line, cut here.
|
||||
*/
|
||||
PushBodyMaxChars: 140,
|
||||
/**
|
||||
* How long a push service may hold a notification for a device that
|
||||
* is offline.
|
||||
*/
|
||||
PushTtlSeconds: 60 * 60,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import path from "path";
|
||||
import { SiteData } from "./site-data";
|
||||
import {
|
||||
PwaIcons,
|
||||
PwaManifestName,
|
||||
PwaOfflinePath,
|
||||
PwaScope,
|
||||
PwaStartPath,
|
||||
} from "./pwa-data";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..");
|
||||
|
||||
describe("PwaData", () => {
|
||||
test("has the sizes a browser needs to install", () => {
|
||||
const any_sizes = PwaIcons.filter((icon) => icon.purpose == "any").map(
|
||||
(icon) => icon.sizes,
|
||||
);
|
||||
const maskable_sizes = PwaIcons.filter(
|
||||
(icon) => icon.purpose == "maskable",
|
||||
).map((icon) => icon.sizes);
|
||||
|
||||
expect(any_sizes).toContain("192x192");
|
||||
expect(any_sizes).toContain("512x512");
|
||||
expect(maskable_sizes).toContain("192x192");
|
||||
expect(maskable_sizes).toContain("512x512");
|
||||
});
|
||||
|
||||
test("names the app from SiteData and starts on the crew", () => {
|
||||
expect(PwaManifestName).toBe(SiteData["SiteName"]);
|
||||
expect(PwaStartPath).toBe("/admin/bots");
|
||||
expect(PwaScope).toBe("/");
|
||||
});
|
||||
|
||||
test("the generated manifest matches this config", async () => {
|
||||
const manifest = (await Bun.file(
|
||||
path.join(ROOT, "public/manifest.webmanifest"),
|
||||
).json()) as {
|
||||
name: string;
|
||||
start_url: string;
|
||||
scope: string;
|
||||
icons: { src: string; purpose: string }[];
|
||||
};
|
||||
|
||||
expect(manifest.name).toBe(PwaManifestName);
|
||||
expect(manifest.start_url).toBe(PwaStartPath);
|
||||
expect(manifest.scope).toBe(PwaScope);
|
||||
expect(
|
||||
manifest.icons.map((icon) => `${icon.src}:${icon.purpose}`),
|
||||
).toEqual(PwaIcons.map((icon) => `${icon.src}:${icon.purpose}`));
|
||||
});
|
||||
|
||||
test("the offline page and every icon exist", async () => {
|
||||
expect(
|
||||
await Bun.file(path.join(ROOT, "public", PwaOfflinePath)).exists(),
|
||||
).toBe(true);
|
||||
|
||||
for (const icon of PwaIcons) {
|
||||
const file = Bun.file(path.join(ROOT, "public", icon.src));
|
||||
expect(await file.exists()).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { SiteData } from "./site-data";
|
||||
|
||||
/**
|
||||
* What makes the app installable, shared by the pages that advertise it,
|
||||
* the server that serves it and the script that generates it.
|
||||
*/
|
||||
export const PwaStartPath = "/admin/bots";
|
||||
export const PwaScope = "/";
|
||||
export const PwaManifestPath = "/manifest.webmanifest";
|
||||
export const PwaServiceWorkerPath = "/sw.js";
|
||||
export const PwaOfflinePath = "/offline.html";
|
||||
|
||||
export type PwaIcon = {
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
purpose: "any" | "maskable";
|
||||
};
|
||||
|
||||
/**
|
||||
* Generated from `public/logos/logo.svg` by `bun run gen:favicon`.
|
||||
*/
|
||||
export const PwaIcons: PwaIcon[] = [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/maskable-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/icons/maskable-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
];
|
||||
|
||||
export type PwaShortcut = {
|
||||
name: string;
|
||||
url: string;
|
||||
icons: { src: string; sizes: string; type: string }[];
|
||||
};
|
||||
|
||||
export const PwaShortcuts: PwaShortcut[] = [
|
||||
{
|
||||
name: "Routines",
|
||||
url: "/admin/routines",
|
||||
icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
|
||||
},
|
||||
{
|
||||
name: "Help",
|
||||
url: "/admin/help",
|
||||
icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
|
||||
},
|
||||
];
|
||||
|
||||
export const PwaIconPath = (name: `icon` | `maskable`, size: number) =>
|
||||
`/icons/${name}-${size}.png`;
|
||||
|
||||
/**
|
||||
* The app's install name: `SiteName` is lowercase, `PwaShortName` fits
|
||||
* under an icon.
|
||||
*/
|
||||
export const PwaManifestName = SiteData["SiteName"];
|
||||
export const PwaShortName = SiteData["SiteName"];
|
||||
Reference in New Issue
Block a user