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
+23
View File
@@ -0,0 +1,23 @@
import type { PushPayload } from "@/src/dict/push-dict";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import sendWebPush from "./send-web-push";
/**
* Sends to every device the user subscribed. Best effort: one failed
* device does not stop the others.
*/
export default async function deliverToUser({
user_id,
payload,
}: {
user_id: number | string;
payload: PushPayload;
}) {
const subscriptions = await grabPushSubscriptions({ user_id });
await Promise.all(
subscriptions.map((subscription) =>
sendWebPush({ subscription, payload }),
),
);
}
+65
View File
@@ -0,0 +1,65 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import { logError } from "@/src/functions/backend/logger";
import { pushNeedsYouTag, truncatePushBody } from "@/src/dict/push-dict";
import isUserPresent from "@/src/functions/backend/db/push/is-user-present";
import deliverToUser from "./deliver-to-user";
type Row = {
bot_name: string | null;
workspace_id: number | null;
session_id: number | null;
};
/**
* A bot changing to needs-you without posting a message (a task that
* finished running into something only the user can solve). Messages that
* carry needs-you already notify from `notifyBotMessage`.
*/
export default async function notifyBotFace({
bot,
}: {
bot: BotFaceChange;
}) {
try {
if (bot.face != "needs-you") return;
if (await isUserPresent({ user_id: bot.user_id })) return;
const row = (
await BunSQLite.sql<Row>({
sql: `SELECT b.name AS bot_name, s.workspace_id, s.id AS session_id
FROM bots b
LEFT JOIN bot_sessions s ON s.bot_id = b.id AND s.user_id = ? AND s.status = 'active'
WHERE b.id = ?
ORDER BY s.last_activity_at DESC
LIMIT 1`,
values: [bot.user_id, bot.id],
})
).payload?.[0];
const bot_name = row?.bot_name || "A bot";
const url = row?.workspace_id
? `/admin/workspaces/${row.workspace_id}/bots/${bot.id}`
: `/admin/bots/${bot.id}`;
await deliverToUser({
user_id: bot.user_id,
payload: {
title: `${bot_name} needs you`,
body: truncatePushBody(
bot.status_line || "Open the conversation to carry on.",
),
url,
tag: pushNeedsYouTag({ bot_id: bot.id }),
requireInteraction: true,
},
});
} catch (error) {
await logError({
title: "Could not send a needs-you notification",
error,
metadata: { bot_id: bot.id, user_id: bot.user_id },
}).catch(() => {});
}
}
+81
View File
@@ -0,0 +1,81 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { logError } from "@/src/functions/backend/logger";
import { pushMessageTag, truncatePushBody } from "@/src/dict/push-dict";
import messagePreview from "@/src/utils/message-preview";
import isUserPresent from "@/src/functions/backend/db/push/is-user-present";
import deliverToUser from "./deliver-to-user";
type Row = {
user_id: number;
workspace_id: number | null;
workspace_name: string | null;
bot_name: string | null;
};
/**
* A bot's message in one of the user's conversations. Only the bot's own
* messages notify; the user's are already on their screen. Skipped while
* the app is open anywhere.
*/
export default async function notifyBotMessage({
message,
}: {
message: BUN_SQLITE_COBBLE_BOT_MESSAGES;
}) {
try {
const bot_id = Number(message.bot_id);
const session_id = Number(message.session_id);
if (message.author != "bot" || !bot_id || !session_id) return;
const row = (
await BunSQLite.sql<Row>({
sql: `SELECT s.user_id, s.workspace_id, w.name AS workspace_name, b.name AS bot_name
FROM bot_sessions s
LEFT JOIN workspaces w ON w.id = s.workspace_id
LEFT JOIN bots b ON b.id = ?
WHERE s.id = ? LIMIT 1`,
values: [bot_id, session_id],
})
).payload?.[0];
if (!row?.user_id) return;
if (await isUserPresent({ user_id: row.user_id })) return;
const preview = messagePreview({ text: message.text || "" });
const body = preview
? truncatePushBody(preview)
: "Sent you an attachment.";
const bot_name = row.bot_name || "A bot";
const title = row.workspace_name
? `${bot_name} in ${row.workspace_name}`
: bot_name;
const url = row.workspace_id
? `/admin/workspaces/${row.workspace_id}/bots/${bot_id}`
: `/admin/bots/${bot_id}`;
await deliverToUser({
user_id: row.user_id,
payload: {
title,
body,
url,
tag: pushMessageTag({
bot_id,
session_id,
}),
requireInteraction: Boolean(message.needs_you),
},
});
} catch (error) {
await logError({
title: "Could not send a message notification",
error,
metadata: { message_id: message.id },
}).catch(() => {});
}
}
+68
View File
@@ -0,0 +1,68 @@
import webpush from "web-push";
import type { BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS } from "@/db/types/db";
import type { PushPayload } from "@/src/dict/push-dict";
import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import markPushSubscription, {
deleteGonePushSubscription,
} from "@/src/functions/backend/db/push/mark-push-subscription";
import { configureWebPush } from "./vapid";
type Params = {
subscription: BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS;
payload: PushPayload;
};
/**
* Sends one notification and records how it went. A device the push
* service no longer knows is removed. Never throws: a notification is
* never worth failing the work that produced it.
*/
export default async function sendWebPush({ subscription, payload }: Params) {
try {
const keys = await configureWebPush();
if (!keys) return false;
await webpush.sendNotification(
{
endpoint: subscription.endpoint || "",
keys: {
p256dh: subscription.p256dh || "",
auth: subscription.auth || "",
},
},
JSON.stringify(payload),
{
TTL: AppData.PushTtlSeconds,
urgency: payload.requireInteraction ? "high" : "normal",
},
);
await markPushSubscription({ endpoint: subscription.endpoint || "" });
return true;
} catch (error: any) {
const status = Number(error?.statusCode);
if (status == 404 || status == 410) {
await deleteGonePushSubscription({
endpoint: subscription.endpoint || "",
}).catch(() => {});
return false;
}
await markPushSubscription({
endpoint: subscription.endpoint || "",
error: error?.message || "Send failed",
}).catch(() => {});
await logError({
title: "Web Push send failed",
error,
metadata: { endpoint: subscription.endpoint?.slice(0, 120) },
}).catch(() => {});
return false;
}
}
+102
View File
@@ -0,0 +1,102 @@
import webpush from "web-push";
import { SiteData } from "@/src/data/site-data";
import grabAllEnvs from "@/src/functions/backend/db/envs/grab-all-envs";
import grabEnv from "@/src/functions/backend/db/envs/grab-env";
import saveEnv from "@/src/functions/backend/db/envs/save-env";
import deleteEnv from "@/src/functions/backend/db/envs/delete-env";
import { logError } from "@/src/functions/backend/logger";
export type VapidKeys = {
public_key: string;
private_key: string;
};
let configured_for: string | undefined;
/**
* The install's Web Push key pair, from the Environment settings. The
* first server that starts generates it, so push works without setup;
* a super admin can replace it in Settings.
*
* A pair must match, so when either half is missing both are generated
* and the stray half is replaced. Not part of a request path's
* correctness: a failure to read or save only means no notifications
* until it is fixed.
*/
export async function ensureVapidKeys(): Promise<VapidKeys | null> {
const public_key = await grabEnv({ key: "VAPID_PUBLIC_KEY" });
const private_key = await grabEnv({ key: "VAPID_PRIVATE_KEY" });
if (public_key && private_key) {
return { public_key, private_key };
}
const generated = webpush.generateVAPIDKeys();
/**
* A saved half would clash with the new one (`saveEnv` allows one row
* per key), and a mismatched pair can't sign.
*/
const saved = await grabAllEnvs();
const stale = saved.filter(
(env) =>
env.paradigm == "general" &&
(env.key == "VAPID_PUBLIC_KEY" || env.key == "VAPID_PRIVATE_KEY"),
);
for (const env of stale) {
await deleteEnv({ env_id: env.id });
}
await saveEnv({
user_id: 0,
key: "VAPID_PUBLIC_KEY",
value: generated.publicKey,
paradigm: "general",
secret: false,
expose_to_computers: false,
});
await saveEnv({
user_id: 0,
key: "VAPID_PRIVATE_KEY",
value: generated.privateKey,
paradigm: "general",
secret: true,
expose_to_computers: false,
});
const saved_public = await grabEnv({ key: "VAPID_PUBLIC_KEY" });
const saved_private = await grabEnv({ key: "VAPID_PRIVATE_KEY" });
if (!saved_public || !saved_private) return null;
return { public_key: saved_public, private_key: saved_private };
}
/**
* `web-push` keeps the details globally; set them when the pair changes.
*/
export async function configureWebPush(): Promise<VapidKeys | null> {
const keys = await ensureVapidKeys().catch(async (error) => {
await logError({ title: "Could not load Web Push keys", error }).catch(
() => {},
);
return null;
});
if (!keys) return null;
const fingerprint = `${keys.public_key}:${keys.private_key}`;
if (configured_for != fingerprint) {
webpush.setVapidDetails(
SiteData["SiteURL"],
keys.public_key,
keys.private_key,
);
configured_for = fingerprint;
}
return keys;
}