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,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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user