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:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
@@ -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],
});
}