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:
@@ -0,0 +1,18 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
/**
|
||||
* Removes one of the user's devices by its row id (the settings list
|
||||
* never sees endpoints, which are capability URLs).
|
||||
*/
|
||||
export default async function deletePushSubscriptionById({
|
||||
user_id,
|
||||
id,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
id: number | string;
|
||||
}) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM push_subscriptions WHERE user_id = ? AND id = ?`,
|
||||
values: [user_id, id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
/**
|
||||
* Removes one of the user's devices. The endpoint is the identity; a
|
||||
* subscription another user saved can't be touched.
|
||||
*/
|
||||
export default async function deletePushSubscription({
|
||||
user_id,
|
||||
endpoint,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
endpoint: string;
|
||||
}) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`,
|
||||
values: [user_id, endpoint],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PushOverview } from "@/src/types";
|
||||
import describePushDevice from "@/src/utils/describe-push-device";
|
||||
import grabPushSubscriptions from "./grab-push-subscriptions";
|
||||
|
||||
/**
|
||||
* What the Notifications settings page shows: the public key a device
|
||||
* subscribes with, and the user's devices without their endpoints (a
|
||||
* push endpoint is a capability URL).
|
||||
*/
|
||||
export default async function grabPushOverview({
|
||||
user_id,
|
||||
public_key,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
public_key?: string | null;
|
||||
}): Promise<PushOverview> {
|
||||
const subscriptions = await grabPushSubscriptions({ user_id });
|
||||
|
||||
return {
|
||||
public_key: public_key || null,
|
||||
subscriptions: subscriptions.map((subscription) => ({
|
||||
id: Number(subscription.id),
|
||||
device: describePushDevice({
|
||||
user_agent: subscription.user_agent,
|
||||
platform: subscription.platform,
|
||||
}),
|
||||
created_at: Number(subscription.created_at) || 0,
|
||||
last_used_at: Number(subscription.last_used_at) || 0,
|
||||
last_error: subscription.last_error || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS } from "@/db/types/db";
|
||||
|
||||
/**
|
||||
* A user's subscribed devices, newest first.
|
||||
*/
|
||||
export default async function grabPushSubscriptions({
|
||||
user_id,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
}) {
|
||||
return (
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS>({
|
||||
sql: `SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC`,
|
||||
values: [user_id],
|
||||
})
|
||||
).payload || []
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_USER_PRESENCE } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
/**
|
||||
* Whether the app is open for this user right now (any process), based on
|
||||
* the socket keepalive. Push notifications are skipped while it is.
|
||||
*/
|
||||
export default async function isUserPresent({
|
||||
user_id,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
}) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_USER_PRESENCE>({
|
||||
sql: `SELECT last_seen_at FROM user_presence WHERE user_id = ? LIMIT 1`,
|
||||
values: [user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row?.last_seen_at) return false;
|
||||
|
||||
return Date.now() - Number(row.last_seen_at) <= AppData.PushPresenceWindowMs;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
/**
|
||||
* Records the result of a send. A subscription the push service says is
|
||||
* gone is deleted; anything else keeps its error for the settings list.
|
||||
*/
|
||||
export default async function markPushSubscription({
|
||||
endpoint,
|
||||
error,
|
||||
}: {
|
||||
endpoint: string;
|
||||
error?: string | null;
|
||||
}) {
|
||||
if (error) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE push_subscriptions SET last_error = ?, updated_at = ? WHERE endpoint = ?`,
|
||||
values: [error, Date.now(), endpoint],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE push_subscriptions SET last_used_at = ?, last_error = NULL, updated_at = ? WHERE endpoint = ?`,
|
||||
values: [Date.now(), Date.now(), endpoint],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The push service no longer knows this device (gone, or a new endpoint).
|
||||
*/
|
||||
export async function deleteGonePushSubscription({ endpoint }: { endpoint: string }) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM push_subscriptions WHERE endpoint = ?`,
|
||||
values: [endpoint],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
user_agent?: string | null;
|
||||
platform?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a device's subscription. The endpoint is unique: subscribing again
|
||||
* (an updated key, or a shared device signing in as someone else) replaces
|
||||
* the keys and moves it to this user.
|
||||
*/
|
||||
export default async function savePushSubscription({
|
||||
user_id,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
user_agent,
|
||||
platform,
|
||||
}: Params): Promise<void> {
|
||||
const now = Date.now();
|
||||
const fields = {
|
||||
user_id: Number(user_id),
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
user_agent: user_agent || null,
|
||||
platform: platform || null,
|
||||
last_error: null,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT INTO push_subscriptions (created_at, updated_at, user_id, endpoint, p256dh, auth, user_agent, platform, last_used_at, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = COALESCE(excluded.user_agent, push_subscriptions.user_agent),
|
||||
platform = COALESCE(excluded.platform, push_subscriptions.platform),
|
||||
last_error = NULL,
|
||||
updated_at = excluded.updated_at`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
fields.user_id,
|
||||
fields.endpoint,
|
||||
fields.p256dh,
|
||||
fields.auth,
|
||||
fields.user_agent,
|
||||
fields.platform,
|
||||
now,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
/**
|
||||
* At most one write per user every 15s in this process; the keepalive
|
||||
* arrives every 30s per socket, and a burst of sockets would otherwise
|
||||
* write repeatedly for no extra accuracy.
|
||||
*/
|
||||
const last_written_at = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Marks a user as having an open app socket. The bots layout keeps its
|
||||
* socket alive with a raw keepalive, so this is refreshed while the app
|
||||
* is open. A stale value only means a push may be sent a minute late.
|
||||
*/
|
||||
export default async function touchUserPresence({
|
||||
user_id,
|
||||
}: {
|
||||
user_id: number | string;
|
||||
}) {
|
||||
const key = String(user_id);
|
||||
const now = Date.now();
|
||||
|
||||
if (now - (last_written_at.get(key) || 0) < 15_000) return;
|
||||
|
||||
last_written_at.set(key, now);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT INTO user_presence (created_at, updated_at, user_id, last_seen_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at`,
|
||||
values: [now, now, user_id, now],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from "path";
|
||||
import { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
||||
|
||||
const PUBLIC_DIR = path.resolve(import.meta.dir, "../../../../public");
|
||||
|
||||
const PwaFiles: Record<string, string> = {
|
||||
[PwaServiceWorkerPath]: "sw.js",
|
||||
[PwaManifestPath]: "manifest.webmanifest",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves the service worker and the manifest with headers bunext's public
|
||||
* handler can't give them: neither may be cached by a browser or a CDN,
|
||||
* or a deploy waits an hour to reach installed apps. Only these two files
|
||||
* are special; every other public asset keeps its normal caching.
|
||||
*/
|
||||
export default async function handlePwaFile({
|
||||
pathname,
|
||||
}: {
|
||||
pathname: string;
|
||||
}): Promise<Response | null> {
|
||||
const file_name = PwaFiles[pathname];
|
||||
|
||||
if (!file_name) return null;
|
||||
|
||||
const file = Bun.file(path.join(PUBLIC_DIR, file_name));
|
||||
|
||||
if (!(await file.exists())) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type":
|
||||
file_name.endsWith(".webmanifest")
|
||||
? "application/manifest+json; charset=utf-8"
|
||||
: "text/javascript; charset=utf-8",
|
||||
"Cache-Control": "no-store, max-age=0, must-revalidate",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
|
||||
if (file_name == "sw.js") {
|
||||
headers.set("Service-Worker-Allowed", "/");
|
||||
}
|
||||
|
||||
const response = new Response(file, { headers });
|
||||
|
||||
/**
|
||||
* `Bun.file` adds a filename disposition; a manifest is inline data.
|
||||
*/
|
||||
response.headers.delete("content-disposition");
|
||||
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user