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
+18
View File
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import notifyBotMessage from "@/src/push/notify-bot-message";
import notifyBotFace from "@/src/push/notify-bot-face";
import pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user";
@@ -29,6 +31,11 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
session_id: message.session_id,
data: { event: "server:bot-message", data: message },
});
/**
* Not waited on: a push service's latency must not slow the job.
*/
void notifyBotMessage({ message });
}
if (relay) {
@@ -36,9 +43,20 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
session_id: relay.message.session_id,
data: { event: "server:bot-message", data: relay.message },
});
void notifyBotMessage({ message: relay.message });
}
if (bot) {
await pushBotUpdated({ bot_id: bot.id || bot_id, user_id: bot.user_id });
/**
* A message that carries needs-you already notified; this is the
* face-only case (a task running into something only the user
* can solve).
*/
if (!message && bot.face == "needs-you") {
void notifyBotFace({ bot });
}
}
}
@@ -0,0 +1,67 @@
import { PwaManifestPath } from "@/src/data/pwa-data";
/**
* Runs before React mounts, in `<head>`:
* - keeps `beforeinstallprompt` (it can fire before hydration, and only
* one prompt can ever be held)
* - points the browser's theme color at the current theme's paper token,
* so it follows light/dark without repeating the color here
* - lets the page use safe-area insets and resize under the on-screen
* keyboard (bunext emits a fixed viewport meta)
*/
const PwaInitScriptSource = `(function () {
try {
window.__cobbleInstallPrompt = null;
window.addEventListener("beforeinstallprompt", function (event) {
event.preventDefault();
window.__cobbleInstallPrompt = event;
window.dispatchEvent(new CustomEvent("cobble:install-available"));
});
window.addEventListener("appinstalled", function () {
window.__cobbleInstallPrompt = null;
window.dispatchEvent(new CustomEvent("cobble:installed"));
});
} catch (e) {}
try {
var meta = document.querySelector('meta[name="viewport"]');
if (meta) {
meta.setAttribute(
"content",
"width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content",
);
}
} catch (e) {}
try {
var theme_meta = document.querySelector('meta[name="theme-color"]');
if (!theme_meta) {
theme_meta = document.createElement("meta");
theme_meta.setAttribute("name", "theme-color");
document.head.appendChild(theme_meta);
}
var apply_theme_color = function () {
var paper = getComputedStyle(document.documentElement)
.getPropertyValue("--c-paper")
.trim();
if (paper) theme_meta.setAttribute("content", paper);
};
apply_theme_color();
window.addEventListener("load", apply_theme_color);
var dark_query = window.matchMedia("(prefers-color-scheme: dark)");
if (dark_query.addEventListener) dark_query.addEventListener("change", apply_theme_color);
new MutationObserver(apply_theme_color).observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
} catch (e) {}
})();`;
export default function PwaInitScript() {
return (
<>
<script dangerouslySetInnerHTML={{ __html: PwaInitScriptSource }} />
<link rel="manifest" href={PwaManifestPath} />
</>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import { RefreshCw } from "lucide-react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
const UpdateCheckMs = 60 * 60 * 1000;
/**
* Registers the app's service worker (production only, so development
* HMR is never cached) and offers a reload once a new version is
* installed. It never reloads on its own: a bot reply may be running.
*/
export default function ServiceWorkerRegistrar() {
const [update_ready, setUpdateReady] = useState(false);
const [reloading, setReloading] = useState(false);
const registration = useRef<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
const watch = (reg: ServiceWorkerRegistration) => {
if (reg.waiting && navigator.serviceWorker.controller) {
setUpdateReady(true);
}
reg.addEventListener("updatefound", () => {
const installing = reg.installing;
installing?.addEventListener("statechange", () => {
if (cancelled) return;
if (
installing.state == "installed" &&
navigator.serviceWorker.controller
) {
setUpdateReady(true);
}
});
});
};
navigator.serviceWorker
.register(PwaServiceWorkerPath, {
scope: "/",
updateViaCache: "none",
})
.then((reg) => {
if (cancelled) return;
registration.current = reg;
watch(reg);
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
})
.catch((error) => {
console.log("Service worker registration failed:", error);
});
const checkOnVisible = () => {
if (document.visibilityState == "visible") {
registration.current?.update().catch(() => {});
}
};
document.addEventListener("visibilitychange", checkOnVisible);
return () => {
cancelled = true;
if (interval) clearInterval(interval);
document.removeEventListener("visibilitychange", checkOnVisible);
};
}, []);
useEffect(() => {
if (!reloading) return;
const onControllerChange = () => window.location.reload();
navigator.serviceWorker?.addEventListener("controllerchange", onControllerChange);
registration.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
const fallback = setTimeout(() => window.location.reload(), 1500);
return () => {
clearTimeout(fallback);
navigator.serviceWorker?.removeEventListener("controllerchange", onControllerChange);
};
}, [reloading]);
if (!update_ready) return null;
return (
<Paper
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[250] w-[min(320px,calc(100vw-32px))] gap-3! p-4 bg-surface shadow-popover! border-hairline dark:bg-surface dark:border-hairline"
>
<Span className="text-sm font-bold leading-5 text-ink">
A new version is ready
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Reload to use it. Your conversations are saved.
</Span>
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Reload later"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={reloading}
onClick={() => setUpdateReady(false)}
>
Later
</Button>
<Button
title="Reload the app"
type="button"
size="small"
loading={reloading}
beforeIcon={<RefreshCw size={15} />}
onClick={() => setReloading(true)}
>
Reload
</Button>
</Row>
</Paper>
);
}
+14
View File
@@ -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;
+63
View File
@@ -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);
}
});
});
+77
View File
@@ -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"];
+14
View File
@@ -42,6 +42,20 @@ export type KnownEnv = {
* process environment.
*/
export const EnvDict: readonly KnownEnv[] = [
{
key: "VAPID_PUBLIC_KEY",
description:
"Web Push public key, shared with browsers that subscribe. Generated on first start when missing.",
required: false,
secret: false,
},
{
key: "VAPID_PRIVATE_KEY",
description:
"Web Push private key, used to sign notifications. Generated on first start when missing; never share it.",
required: false,
secret: true,
},
{
key: "MAIN_COMPUTER_URL",
description: `The main computer's address, for a server running outside docker (e.g. http://localhost:8170). Empty uses the docker network.`,
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import {
pushMessageTag,
pushNeedsYouTag,
truncatePushBody,
} from "./push-dict";
describe("push tags", () => {
test("a conversation's notifications replace each other", () => {
expect(pushMessageTag({ bot_id: 3, session_id: 9 })).toBe(
"bot-3-session-9",
);
});
test("needs-you has its own tag, so it does not replace a message", () => {
expect(pushNeedsYouTag({ bot_id: 3 })).not.toBe(
pushMessageTag({ bot_id: 3, session_id: 9 }),
);
});
});
describe("truncatePushBody", () => {
test("keeps short text as is", () => {
expect(truncatePushBody("On it.")).toBe("On it.");
});
test("cuts long text at the limit, with one ellipsis", () => {
const long = "a".repeat(AppData.PushBodyMaxChars + 20);
const cut = truncatePushBody(long);
expect(cut.length).toBeLessThanOrEqual(AppData.PushBodyMaxChars);
expect(cut.endsWith("…")).toBe(true);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { AppData } from "@/src/data/app-data";
/**
* A notification's payload. The service worker turns it into a
* notification; `url` is where clicking it goes.
*/
export type PushPayload = {
title: string;
body: string;
url: string;
tag: string;
requireInteraction?: boolean;
};
/**
* One conversation replaces its previous notification, so a burst does not
* stack. A needs-you notice has its own tag.
*/
export function pushMessageTag({
bot_id,
session_id,
}: {
bot_id: number | string;
session_id: number | string;
}) {
return `bot-${bot_id}-session-${session_id}`;
}
export function pushNeedsYouTag({ bot_id }: { bot_id: number | string }) {
return `bot-${bot_id}-needs-you`;
}
export function truncatePushBody(text: string) {
const limit = AppData.PushBodyMaxChars;
if (text.length <= limit) return text;
return `${text.slice(0, limit - 1).trimEnd()}…`;
}
export const PushDefaultIcon = "/icons/icon-192.png";
+6
View File
@@ -1,4 +1,5 @@
import {
Bell,
Bot,
CpuAi,
KeyRound,
@@ -43,6 +44,11 @@ export const SettingsPages: SettingsPage[] = [
Icon: SlidersHorizontal,
},
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{
title: "Notifications",
url: "/admin/settings/notifications",
Icon: Bell,
},
{
title: "Change password",
url: "/admin/settings/change-password",
@@ -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;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
/**
* Changes after a reason the socket may have died: the browser went back
* online, the page was restored, or a hidden tab became visible again.
* twui's `useWebSocket` gives up after three attempts, so the socket is
* remounted instead of waiting for a reload.
*/
export default function useConnectionEpoch() {
const [epoch, setEpoch] = useState(0);
useEffect(() => {
const bump = () => setEpoch((value) => value + 1);
const onVisible = () => {
if (document.visibilityState == "visible") bump();
};
window.addEventListener("online", bump);
window.addEventListener("pageshow", bump);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("online", bump);
window.removeEventListener("pageshow", bump);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
return epoch;
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
export type InstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
declare global {
interface Window {
/**
* Captured by `PwaInitScript` before React mounts.
*/
__cobbleInstallPrompt?: InstallPromptEvent | null;
}
}
const StandaloneQuery = "(display-mode: standalone)";
function isIosDevice() {
if (typeof navigator == "undefined") return false;
const ua = navigator.userAgent;
return (
/iphone|ipad|ipod/i.test(ua) ||
(navigator.platform == "MacIntel" && navigator.maxTouchPoints > 1)
);
}
/**
* Whether this browser can install the app now, whether it is already
* installed, and the iOS steps when Safari has no install event.
*/
export default function useInstallPrompt() {
const [prompt_event, setPromptEvent] = useState<InstallPromptEvent | null>(
null,
);
const [is_standalone, setIsStandalone] = useState(false);
const [is_ios, setIsIos] = useState(false);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (typeof window == "undefined") return;
const standalone_query = window.matchMedia(StandaloneQuery);
const sync_standalone = () =>
setIsStandalone(
standalone_query.matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone),
);
setPromptEvent(window.__cobbleInstallPrompt || null);
setIsIos(isIosDevice());
sync_standalone();
const sync_prompt = () =>
setPromptEvent(window.__cobbleInstallPrompt || null);
const on_installed = () => {
window.__cobbleInstallPrompt = null;
setPromptEvent(null);
sync_standalone();
};
window.addEventListener("cobble:install-available", sync_prompt);
window.addEventListener("cobble:installed", on_installed);
standalone_query.addEventListener("change", sync_standalone);
return () => {
window.removeEventListener("cobble:install-available", sync_prompt);
window.removeEventListener("cobble:installed", on_installed);
standalone_query.removeEventListener("change", sync_standalone);
};
}, []);
const install = async () => {
if (!prompt_event) return false;
setInstalling(true);
try {
await prompt_event.prompt();
const choice = await prompt_event.userChoice;
window.__cobbleInstallPrompt = null;
setPromptEvent(null);
return choice.outcome == "accepted";
} catch (error) {
return false;
} finally {
setInstalling(false);
}
};
return {
can_install: Boolean(prompt_event) && !is_standalone,
is_standalone,
is_ios,
/**
* Safari has no install event, so it needs the Share menu steps.
*/
needs_ios_steps: is_ios && !is_standalone,
installing,
install,
};
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
/**
* `navigator.onLine` as state. Starts online so server and first client
* render match; the real value arrives on mount.
*/
export default function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import useInstallPrompt from "@/src/hooks/use-install-prompt";
import urlBase64ToUint8Array from "@/src/utils/url-base64-to-uint8-array";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
export type PushDeviceState =
| "loading"
| "dev"
| "unsupported"
| "needs-install"
| "denied"
| "off"
| "on";
/**
* The registrant may still be registering when Settings opens, so give it
* a moment before deciding the browser has no worker.
*/
async function grabRegistration() {
const existing = await navigator.serviceWorker.getRegistration();
if (existing) return existing;
return await Promise.race([
navigator.serviceWorker.ready,
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
]);
}
/**
* One device's Web Push subscription. Permission is only ever asked for
* from an explicit tap: a denied permission is permanent in most browsers
* and iOS requires the gesture. In development the service worker is not
* registered, so this reports "dev" instead of hanging.
*/
export default function usePushNotifications({
public_key,
}: {
public_key?: string | null;
}) {
const { is_ios, is_standalone } = useInstallPrompt();
const [state, setState] = useState<PushDeviceState>("loading");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const read_subscription = useCallback(async () => {
if (process.env.NODE_ENV != "production") {
setState("dev");
return null;
}
if (
typeof navigator == "undefined" ||
!("serviceWorker" in navigator) ||
!("PushManager" in window) ||
!("Notification" in window)
) {
setState("unsupported");
return null;
}
if (is_ios && !is_standalone) {
setState("needs-install");
return null;
}
if (Notification.permission == "denied") {
setState("denied");
return null;
}
const registration = await grabRegistration();
if (!registration) {
setState("unsupported");
return null;
}
const subscription =
await registration.pushManager.getSubscription();
setState(subscription ? "on" : "off");
return subscription;
}, [is_ios, is_standalone]);
useEffect(() => {
read_subscription().catch(() => setState("unsupported"));
}, [read_subscription]);
const enable = async () => {
if (!public_key) {
setError("Notifications aren't set up on this server yet.");
return false;
}
setBusy(true);
setError(undefined);
try {
const permission = await Notification.requestPermission();
if (permission != "granted") {
setState(permission == "denied" ? "denied" : "off");
setError(
permission == "denied"
? "Notifications are blocked for this site. Allow them in the browser's site settings, then try again."
: "Notifications were not allowed.",
);
return false;
}
/**
* The registrant normally registers, but a settings page can
* be the first thing opened.
*/
const registration = await navigator.serviceWorker.register(
PwaServiceWorkerPath,
{ scope: "/", updateViaCache: "none" },
);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
const res = await fetchApi("/api/admin/push/subscribe", {
method: "POST",
body: {
endpoint: subscription.endpoint,
keys: subscription.toJSON().keys,
user_agent: navigator.userAgent,
platform: navigator.platform,
},
});
if (!res.success) {
await subscription.unsubscribe().catch(() => {});
setError(res.msg || "Notifications could not be turned on.");
setState("off");
return false;
}
setState("on");
return true;
} catch (caught: any) {
setError(
caught?.message || "Notifications could not be turned on.",
);
return false;
} finally {
setBusy(false);
}
};
const disable = async () => {
setBusy(true);
setError(undefined);
try {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await fetchApi("/api/admin/push/unsubscribe", {
method: "POST",
body: { endpoint: subscription.endpoint },
});
await subscription.unsubscribe();
}
setState("off");
return true;
} catch (caught: any) {
setError(
caught?.message || "Notifications could not be turned off.",
);
return false;
} finally {
setBusy(false);
}
};
const send_test = async () => {
setBusy(true);
setError(undefined);
try {
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/test",
{ method: "POST", body: {} },
);
if (!res.success) {
setError(res.msg || "The test could not be sent.");
return false;
}
return true;
} catch (caught: any) {
setError(caught?.message || "The test could not be sent.");
return false;
} finally {
setBusy(false);
}
};
/**
* The browser rotated the subscription. Re-subscribing needs the key
* and the session, so the page does it.
*/
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (!("serviceWorker" in navigator)) return;
const onMessage = (event: MessageEvent) => {
if (event.data?.type != "PUSH_RESUBSCRIBE") return;
event.data.oldEndpoint &&
fetchApi("/api/admin/push/unsubscribe", {
method: "POST",
body: { endpoint: event.data.oldEndpoint },
}).catch(() => {});
enable().catch(() => {});
};
navigator.serviceWorker.addEventListener("message", onMessage);
return () =>
navigator.serviceWorker.removeEventListener("message", onMessage);
}, [public_key]);
return {
state,
enabled: state == "on",
busy,
error,
clearError: () => setError(undefined),
enable,
disable,
send_test,
};
}
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
*/
export default function AuthLayout({ children }: Props): React.JSX.Element {
return (
<Stack className="w-full min-h-screen items-stretch gap-0! bg-light dark:bg-black">
<Stack className="w-full min-h-dvh items-stretch gap-0! bg-light dark:bg-black">
<AuthTopBar />
<Main className="grow justify-center">{children}</Main>
<AuthBottomBar />
@@ -0,0 +1,17 @@
"use client";
import { createContext } from "react";
export type RailDrawerContextObject = {
open: boolean;
setOpen: (open: boolean) => void;
};
/**
* The rail is a slide-over below `md`, an inline rail above it. The
* pane headers' menu button and the rail itself share this.
*/
export const RailDrawerContext = createContext<RailDrawerContextObject>({
open: false,
setOpen: () => {},
});
+3 -3
View File
@@ -45,14 +45,14 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
aria-current={selected ? "page" : undefined}
title={bot.name}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal",
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name}
</Span>
@@ -60,7 +60,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
{snippet}
</Span>
</Stack>
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
<Stack className="hidden @rail:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
{at ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(at) })}
@@ -0,0 +1,70 @@
"use client";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import { Share, SquarePlus } from "lucide-react";
import { SiteData } from "@/src/data/site-data";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
const Steps = [
{
Icon: Share,
title: "Open the Share menu",
description: "In Safari it is the square with an arrow pointing up, in the toolbar.",
},
{
Icon: SquarePlus,
title: "Choose Add to Home Screen",
description: "Scroll the list if it is not there. Then tap Add.",
},
];
/**
* Safari on iOS has no install event, so installation is the Share menu.
*/
export default function InstallAppModal({ open, setOpen }: Props) {
return (
<Modal
open={open}
setOpen={(next) => setOpen(typeof next == "function" ? next(open) : next)}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
Install {SiteData["SiteName"]}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Installed, it opens in its own window and can send
notifications. Safari installs it from the Share menu.
</Span>
</Stack>
<Stack className="w-full mt-5 gap-4! items-stretch">
{Steps.map((step) => (
<Row
key={step.title}
className="gap-3! flex-nowrap items-start"
>
<Row className="size-9 shrink-0 items-center justify-center rounded-default bg-sunken">
<step.Icon size={17} aria-hidden className="text-ink" />
</Row>
<Stack className="gap-0.5! min-w-0">
<Span className="text-sm font-bold leading-5 text-ink">
{step.title}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{step.description}
</Span>
</Stack>
</Row>
))}
</Stack>
</Modal>
);
}
@@ -0,0 +1,24 @@
"use client";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { WifiOff } from "lucide-react";
/**
* A hairline strip while the browser is offline. Nothing is queued for
* later, so it says messages won't send until the connection is back.
*/
export default function OfflineBanner() {
return (
<Row
role="status"
aria-live="polite"
className="w-full shrink-0 gap-2! flex-nowrap items-center justify-center px-4 py-2 bg-sunken border-0 border-b border-solid border-hairline"
>
<WifiOff size={14} aria-hidden className="shrink-0 text-ink-secondary" />
<Span className="text-[13px] leading-[18px] text-ink-secondary">
You're offline. Messages won't send until you reconnect.
</Span>
</Row>
);
}
@@ -23,7 +23,7 @@ export default function RailAddButton() {
variant="ghost"
color="text"
onClick={openAdd}
className="hidden lg:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
className="hidden @rail:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={18} aria-hidden />
</Button>
@@ -21,12 +21,12 @@ export default function RailBackLink({
<Link
href={href}
title={title}
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
<ArrowLeft size={18} aria-hidden />
</Span>
<Span className="hidden lg:block min-w-0 truncate text-sm font-bold leading-5 text-ink">
<Span className="hidden @rail:block min-w-0 truncate text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link>
@@ -39,7 +39,7 @@ export default function RailBotsSection({ title, workers, selected_id, hrefFor,
<BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} />
))}
{workers.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
{empty}
</Span>
)}
@@ -0,0 +1,23 @@
"use client";
import { useContext } from "react";
import { Menu } from "lucide-react";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { RailDrawerContext } from "../(data)/rail-drawer-context";
/**
* Opens the rail's drawer below `md`; the inline rail has no need for it.
*/
export default function RailDrawerButton() {
const { setOpen } = useContext(RailDrawerContext);
return (
<span className="inline-flex md:hidden">
<HeaderIconButton
icon={Menu}
title="Open the bots rail"
onClick={() => setOpen(true)}
/>
</span>
);
}
+3 -3
View File
@@ -13,13 +13,13 @@ type Props = {
export default function RailHeader({ show_add = true }: Props) {
return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center @rail:justify-between px-0 @rail:pl-5 @rail:pr-6 pt-5">
<Link
href="/admin/bots"
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Wordmark height={15} className="hidden lg:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
<Wordmark height={15} className="hidden @rail:block" />
<LogoIcon icon_size={28} img_props={{ className: "@rail:hidden" }} />
</Link>
{show_add ? <RailAddButton /> : null}
</Row>
@@ -22,7 +22,7 @@ export default function RailPagesList({ pathname }: Props) {
return (
<>
<RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => (
<RailSettingsLink
key={page.url}
@@ -18,13 +18,13 @@ type Props = {
export default function RailSectionLabel({ title, action }: Props) {
return (
<>
<Row className="hidden lg:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
<Row className="hidden @rail:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
{title}
</Span>
{action}
</Row>
<Divider className="lg:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
<Divider className="@rail:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
</>
);
}
@@ -28,15 +28,15 @@ export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
variant="ghost"
color="text"
onClick={onOpen}
className="flex shrink-0 h-10 w-full items-center justify-center lg:justify-start gap-3! rounded-default px-1! lg:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Ellipsis size={18} aria-hidden className="lg:hidden" />
<Row className="hidden lg:flex shrink-0 gap-1! flex-nowrap items-center">
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
<RailSeeAllStone key={bot.id} bot={bot} />
))}
</Row>
<Span className="hidden lg:inline text-[13px] font-bold leading-4">{label}</Span>
<Span className="hidden @rail:inline text-[13px] font-bold leading-4">{label}</Span>
</Button>
);
}
@@ -139,7 +139,7 @@ export default function RailSettingsAside({ pathname }: Props) {
return (
<>
<RailBackLink title={back.title} href={back.href} />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<RailSettingsRecordRow
avatar={avatar}
title={title}
@@ -18,16 +18,16 @@ export default function RailSettingsLink({ title, url, Icon, selected }: Props)
title={title}
aria-current={selected ? "page" : undefined}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
<Icon size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
<Span className="hidden @rail:inline text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link>
@@ -25,7 +25,7 @@ export default function RailSettingsList({ pathname }: Props) {
return (
<>
<RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.filter((page) =>
page.can_view
? page.can_view({ user_types: pageProps?.user_types })
@@ -21,10 +21,10 @@ export default function RailSettingsRecordRow({
return (
<Row
aria-current="page"
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-3 bg-ink/5"
>
{avatar}
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
</Span>
@@ -0,0 +1,49 @@
"use client";
import type { LucideIcon } from "lucide-react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
Icon: LucideIcon;
onClick: () => void;
disabled?: boolean;
};
/**
* A popover item that acts rather than navigates, e.g. Install app.
*/
export default function RailUserMenuAction({
title,
Icon,
onClick,
disabled,
}: Props) {
return (
<Row
role="menuitem"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
title={title}
onClick={() => {
if (!disabled) onClick();
}}
onKeyDown={(event) => {
if (disabled) return;
if (event.key == "Enter" || event.key == " ") {
event.preventDefault();
onClick();
}
}}
className={`w-full h-9 gap-2.5! flex-nowrap items-center px-4 ${
disabled
? "text-ink-disabled cursor-not-allowed"
: "text-ink cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
}`}
>
<Icon size={16} aria-hidden />
<Span className="text-sm leading-5">{title}</Span>
</Row>
);
}
+35 -2
View File
@@ -1,14 +1,21 @@
import { useContext } from "react";
"use client";
import { useContext, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Divider from "@moduletrace/twui/layout/Divider";
import { Download } from "lucide-react";
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
import RailUserMenuEntry from "./rail-user-menu-entry";
import RailUserMenuAction from "./rail-user-menu-action";
import InstallAppModal from "./install-app-modal";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import useInstallPrompt from "@/src/hooks/use-install-prompt";
/**
* 220px popover: the only element in the shell with elevation. Super
* admin items show to super admins only.
* admin items show to super admins only. Install app shows while the
* browser can install it; Safari gets the Share menu steps instead.
*/
export default function RailUserMenu() {
const { pageProps } = useContext(AppContext);
@@ -17,6 +24,12 @@ export default function RailUserMenu() {
const items = shown.filter((item) => !item.destructive);
const destructive = shown.filter((item) => item.destructive);
const { can_install, needs_ios_steps, installing, install } =
useInstallPrompt();
const [ios_modal_open, setIosModalOpen] = useState(false);
const show_install = can_install || needs_ios_steps;
return (
<Paper
role="menu"
@@ -25,10 +38,30 @@ export default function RailUserMenu() {
{items.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
{show_install ? (
<RailUserMenuAction
title={needs_ios_steps ? "How to install" : "Install app"}
Icon={Download}
disabled={installing}
onClick={() => {
if (needs_ios_steps) {
setIosModalOpen(true);
return;
}
install();
}}
/>
) : null}
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
{destructive.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
{needs_ios_steps ? (
<InstallAppModal
open={ios_modal_open}
setOpen={setIosModalOpen}
/>
) : null}
</Paper>
);
}
@@ -23,7 +23,7 @@ export default function RailUserRow() {
.join("");
return (
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline">
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 @rail:px-3 mx-0 @rail:mx-3 border-0 border-t border-solid border-hairline">
<Dropdown
position="top-left"
className="w-full"
@@ -34,7 +34,7 @@ export default function RailUserRow() {
tabIndex={0}
aria-haspopup="menu"
aria-label={`Account menu for ${name}`}
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<UserAvatar
src={user?.image_thumbnail || user?.image}
@@ -42,7 +42,7 @@ export default function RailUserRow() {
initials={initials}
size="md"
/>
<Stack className="hidden lg:flex grow min-w-0 gap-1">
<Stack className="hidden @rail:flex grow min-w-0 gap-1">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{name}
</Span>
@@ -63,7 +63,7 @@ export default function RailUserRow() {
</Stack>
<ChevronRight
size={16}
className="hidden lg:block shrink-0 text-ink"
className="hidden @rail:block shrink-0 text-ink"
aria-hidden
/>
</Row>
@@ -37,7 +37,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
title={on_workspace_page ? "All bots" : `Back to ${workspace.name}`}
href={on_workspace_page ? undefined : base}
/>
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
<RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
@@ -32,7 +32,7 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
))}
{workspaces.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
No workspaces yet. A workspace keeps a project's context, bots and computers together.
</Span>
)}
@@ -21,14 +21,14 @@ export default function WorkspaceRow({ workspace, selected }: Props) {
aria-current={selected ? "page" : undefined}
title={workspace.name}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto lg:mx-0" />
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto @rail:mx-0" />
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{workspace.unread ? "New messages" : workspace.description || ""}
+45 -27
View File
@@ -1,9 +1,12 @@
"use client";
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import useLiveBots from "@/src/hooks/use-live-bots";
import useLiveWorkspaces from "../(hooks)/use-live-workspaces";
import { RailPages } from "@/src/dict/rail-pages-dict";
import { RailDrawerContext } from "../(data)/rail-drawer-context";
import RailHeader from "../(partials)/rail-header";
import RailPagesList from "../(partials)/rail-pages-list";
import RailCrewList from "../(partials)/rail-crew-list";
@@ -16,10 +19,14 @@ import RailWorkspaceList from "../(partials)/rail-workspace-list";
* the latest bots, the workspaces). Inside a workspace it lists that
* workspace's crew instead; on `/admin/settings/*` a back link and the
* settings sub pages, and on routines, activity and help a back link and
* those pages (`RailPages`). Collapses to a 64px icon rail below 1024px.
* those pages (`RailPages`). Below `md` it is a slide-over drawer from the
* pane header's menu button; from `md` it is a 64px icon rail, and from
* `lg` the full 260px rail. Its own width, not the viewport's, decides
* whether it shows names (a container query), so the drawer opens full.
*/
export default function BotsRail() {
const { pageProps } = useContext(AppContext);
const { open, setOpen } = useContext(RailDrawerContext);
const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
@@ -41,32 +48,43 @@ export default function BotsRail() {
const live_workspace = workspaces.find((w) => w.id == workspace?.id);
return (
<Stack
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
>
<RailHeader show_add={!is_settings_page && !is_rail_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
{is_settings_page ? (
<RailSettingsAside pathname={pathname} />
) : is_rail_page ? (
<RailPagesList pathname={pathname} />
) : in_workspace && live_workspace ? (
<RailWorkspaceList
workspace={live_workspace}
bots={bots}
selected_id={pageProps?.bot?.id || undefined}
on_workspace_page={!pageProps?.bot?.id}
/>
) : (
<RailCrewList
bots={bots}
workspaces={workspaces}
selected_id={pageProps?.bot?.id || undefined}
/>
)}
<>
{open ? (
<Stack
aria-hidden
onClick={() => setOpen(false)}
className="fixed inset-0 z-30 bg-ink/40 md:hidden"
/>
) : null}
<Stack
className={`@container fixed inset-y-0 left-0 z-40 w-[280px] max-w-[85vw] shrink-0 h-full gap-0! items-stretch bg-paper border-0 border-r border-solid border-hairline shadow-popover transition-transform duration-[var(--m-base)] pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] md:static md:z-auto md:w-16 md:max-w-none md:shadow-none md:pt-0 md:pb-0 lg:w-[260px] ${
open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
}`}
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
>
<RailHeader show_add={!is_settings_page && !is_rail_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 @rail:px-3 pt-1 pb-3">
{is_settings_page ? (
<RailSettingsAside pathname={pathname} />
) : is_rail_page ? (
<RailPagesList pathname={pathname} />
) : in_workspace && live_workspace ? (
<RailWorkspaceList
workspace={live_workspace}
bots={bots}
selected_id={pageProps?.bot?.id || undefined}
on_workspace_page={!pageProps?.bot?.id}
/>
) : (
<RailCrewList
bots={bots}
workspaces={workspaces}
selected_id={pageProps?.bot?.id || undefined}
/>
)}
</Stack>
<RailUserRow />
</Stack>
<RailUserRow />
</Stack>
</>
);
}
@@ -0,0 +1,13 @@
"use client";
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
/**
* The shell's one socket. Remounted with a new key when the connection
* may have been lost, since twui's hook stops retrying after three tries.
*/
export default function LiveSocket() {
useWebSocket({ url: "/ws" });
return null;
}
+54 -13
View File
@@ -1,33 +1,74 @@
import React from "react";
"use client";
import React, { useContext, useEffect, useMemo, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
import { AppContext } from "@/src/pages/__root";
import useConnectionEpoch from "@/src/hooks/use-connection-epoch";
import useOnlineStatus from "@/src/hooks/use-online-status";
import { RailDrawerContext } from "./(data)/rail-drawer-context";
import BotsRail from "./(sections)/bots-rail";
import LiveSocket from "./(sections)/live-socket";
import AddFlow from "./(partials)/add-flow";
import OfflineBanner from "./(partials)/offline-banner";
/**
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations
* and workspaces, then the page (a conversation, a workspace, or
* settings), inside the Add flow both can open. No cards, no shadows;
* regions are separated by 1px hairlines.
* regions are separated by 1px hairlines. Below `md` the rail slides over
* the page instead of taking width. Height is `dvh`, so the on-screen
* keyboard doesn't push the composer out of view.
*/
export default function BotsLayout({
children,
}: {
children: React.ReactNode;
}) {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
const [drawer_open, setDrawerOpen] = useState(false);
const epoch = useConnectionEpoch();
const online = useOnlineStatus();
/**
* One socket for live bot updates (see `useServerEvent`).
* Opening a conversation closes the drawer on a phone.
*/
useWebSocket({ url: "/ws" });
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
if (!drawer_open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key == "Escape") setDrawerOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [drawer_open]);
const drawer = useMemo(
() => ({ open: drawer_open, setOpen: setDrawerOpen }),
[drawer_open],
);
return (
<AddFlow>
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
<BotsRail />
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
{children}
</Row>
</Row>
</AddFlow>
<RailDrawerContext.Provider value={drawer}>
<AddFlow>
<Stack className="w-full h-dvh gap-0! items-stretch overflow-hidden bg-paper text-ink pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
{online ? null : <OfflineBanner />}
<Row className="grow min-h-0 w-full gap-0! flex-nowrap items-stretch overflow-hidden">
<BotsRail />
<Row className="grow min-w-0 h-full gap-0! flex-nowrap items-stretch">
{children}
</Row>
</Row>
</Stack>
<LiveSocket key={epoch} />
</AddFlow>
</RailDrawerContext.Provider>
);
}
+17
View File
@@ -17,6 +17,8 @@ import Layout from "../layouts/main";
import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots";
import ThemeInitScript from "../components/general/theme-init-script";
import PwaInitScript from "../components/general/pwa-init-script";
import ServiceWorkerRegistrar from "../components/general/service-worker-registrar";
export const AppContext = React.createContext<AppContextObject>(
{} as AppContextObject,
@@ -57,6 +59,7 @@ export default function App({
return (
<AppContext.Provider value={appContextObject}>
<BotsLayout>{children}</BotsLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -68,6 +71,7 @@ export default function App({
return (
<AppContext.Provider value={appContextObject}>
<AuthLayout>{children}</AuthLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -86,6 +90,7 @@ export default function App({
>
{children}
</Layout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -94,6 +99,18 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
return (
<>
<ThemeInitScript />
<PwaInitScript />
<meta name="application-name" content={SiteData["SiteName"]} />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={SiteData["SiteName"]}
/>
<meta
name="apple-mobile-web-app-status-bar-style"
content="default"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
@@ -30,7 +30,7 @@ export default function BotProfileModal({ bot }: Props) {
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
size={35}
/>
);
@@ -65,7 +65,9 @@ export default function BotProfileModal({ bot }: Props) {
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
</Span>
</Stack>
{open ? <BotForm bot={bot} onCancel={() => setOpen(false)} /> : null}
{open ? (
<BotForm bot={bot} onCancel={() => setOpen(false)} />
) : null}
</Modal>
);
}
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null;
return (
<Row className="w-full max-w-container mx-auto gap-2! px-6 pb-3">
<Row className="w-full max-w-container mx-auto gap-2! px-3 md:px-6 pb-3">
{pending.map((item) => (
<ComposerAttachmentChip
key={item.key}
@@ -38,7 +38,7 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
if (!has_tools && !computers.length) return null;
return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3">
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-3 md:px-6 pb-3 -mt-3">
{has_tools ? (
<Span className="text-[12px] leading-4 text-ink-secondary">
Tools
@@ -10,6 +10,7 @@ import type {
import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button";
@@ -40,11 +41,13 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const can_send =
Boolean(text.trim() || files.media_ids.length) &&
!sending &&
!files.uploading;
!files.uploading &&
online;
async function send() {
if (!can_send || !bot.id) return;
@@ -98,7 +101,7 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
onRemove={files.remove}
/>
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
<Row className="w-full max-w-container mx-auto h-[88px] gap-3! md:gap-4! flex-nowrap items-center px-3 md:px-6">
<ComposerAttachButton
disabled={files.full}
onFiles={files.add}
@@ -15,6 +15,7 @@ import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
@@ -50,13 +51,14 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
: canManageCrew({ user_types: pageProps?.user_types });
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<BotProfileModal bot={bot} />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name}
</H1>
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? (
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
) : null}
@@ -86,19 +88,23 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
/>
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
<BotModelsModal
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
/>
{can_open_settings ? (
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
<span className="hidden md:inline-flex">
<BotModelsModal
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</span>
{can_open_settings ? (
<span className="hidden md:inline-flex">
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
</span>
) : null}
</Row>
</Row>
@@ -39,7 +39,7 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
>
{groups.map((group) => (
<MessageGroupBlock
@@ -28,14 +28,22 @@ type Props = {
* settings page from the title. Every list uses it, so the row behaves the
* same, and the open button shows it in the viewer over the page.
*/
export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) {
export default function ComputerListRow({
computer,
badge,
subtitle,
children,
linked = true,
}: Props) {
const name = computer.name || `Computer ${computer.id}`;
const ready = computer.status == "running";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
href={linked ? `/admin/settings/computers/${computer.id}` : undefined}
href={
linked ? `/admin/settings/computers/${computer.id}` : undefined
}
title={name}
badge={badge}
subtitle={
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
type Props = {
title: string;
@@ -15,7 +16,8 @@ type Props = {
/**
* Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page.
* doesn't shift the page. Below `md` it also holds the rail's drawer
* button, since the rail is out of the layout there.
*/
export default function SettingsPaneHeader({
title,
@@ -23,7 +25,8 @@ export default function SettingsPaneHeader({
action,
}: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{title}
@@ -44,7 +44,7 @@ export default function SettingsShell({
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack
className={twMerge(
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16",
"w-full max-w-container mx-auto gap-0! items-stretch px-4 md:px-6 lg:px-10 pb-16",
wide && "max-w-[1200px]",
)}
>
@@ -0,0 +1,46 @@
"use client";
import { Smartphone } from "lucide-react";
import type { PushDeviceOverview } from "@/src/types";
import IconTile from "@/src/components/general/icon-tile";
import relativeTime from "@/src/utils/relative-time";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
subscription: PushDeviceOverview;
removing: boolean;
onRemove: () => void;
};
/**
* One device in the Notifications list.
*/
export default function NotificationDeviceRow({
subscription,
removing,
onRemove,
}: Props) {
const last_used = subscription.last_used_at
? `Last used ${relativeTime({ at: subscription.last_used_at })} ago`
: "Not used yet";
return (
<SettingsListRow
avatar={<IconTile Icon={Smartphone} />}
title={subscription.device}
subtitle={
subscription.last_error
? `Last send failed: ${subscription.last_error}`
: `Added ${relativeTime({ at: subscription.created_at })} ago · ${last_used}`
}
>
<SettingsRowAction
title={`Remove ${subscription.device}`}
label={removing ? "Removing" : "Remove"}
disabled={removing}
onClick={onRemove}
/>
</SettingsListRow>
);
}
@@ -0,0 +1,99 @@
"use client";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { useState } from "react";
import { BellRing, Send } from "lucide-react";
import type usePushNotifications from "@/src/hooks/use-push-notifications";
type Props = ReturnType<typeof usePushNotifications> & {
onChanged?: () => void;
};
const StateCopy: Record<string, string> = {
loading: "Checking this device.",
dev: "Notifications need the installed app. They work in a production build over https, or on localhost.",
unsupported:
"This browser can't receive notifications. On iPhone and iPad, add the app to the Home Screen first, then open it from there.",
"needs-install":
"Add the app to the Home Screen first. iPhone and iPad only allow notifications for installed apps.",
denied:
"Notifications are blocked for this site. Allow them in the browser's site settings, then reload this page.",
};
/**
* The switch for the device the settings are open on, and a test send.
*/
export default function NotificationsDeviceGroup({
state,
enabled,
busy,
error,
enable,
disable,
send_test,
onChanged,
}: Props) {
const [test_status, setTestStatus] = useState<string>();
const can_switch = state == "on" || state == "off";
const copy = error || StateCopy[state];
return (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-3! flex-nowrap items-start">
<Checkbox
checked={enabled}
readOnly={!can_switch || busy}
label="Notifications on this device"
clickDispatchHandler={async (on) => {
if (!can_switch || busy) return;
const ok = on ? await enable() : await disable();
if (ok) onChanged?.();
}}
/>
</Row>
{copy ? (
<Span
role={error ? "alert" : "status"}
className="text-[13px] leading-[18px] text-ink-secondary"
>
{copy}
</Span>
) : null}
<Row className="gap-2! flex-nowrap">
<Button
title="Send a test notification to this user's devices"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={!enabled || busy}
loading={busy}
beforeIcon={
enabled ? <Send size={15} /> : <BellRing size={15} />
}
onClick={async () => {
setTestStatus(undefined);
const ok = await send_test();
if (ok) setTestStatus("Test sent. It may take a few seconds to arrive.");
}}
>
Send a test
</Button>
</Row>
{test_status ? (
<Span
role="status"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{test_status}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { PushDeviceOverview } from "@/src/types";
import SettingsList from "../../(partials)/settings-list";
import NotificationDeviceRow from "./notification-device-row";
type Props = {
subscriptions: PushDeviceOverview[];
status?: string;
onRemoved?: (msg?: string) => void;
};
/**
* Every device this user gets notifications on.
*/
export default function NotificationsDevicesGroup({
subscriptions,
status,
onRemoved,
}: Props) {
const [removing_id, setRemovingId] = useState<number>();
const remove = async (id: number) => {
setRemovingId(id);
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/remove-device",
{ method: "POST", body: { id } },
);
setRemovingId(undefined);
if (res.success) onRemoved?.("Device removed.");
};
return (
<>
<SettingsList
is_empty={!subscriptions.length}
empty="No device yet. Turn notifications on above."
>
{subscriptions.map((subscription) => (
<NotificationDeviceRow
key={subscription.id}
subscription={subscription}
removing={removing_id == subscription.id}
onRemove={() => remove(subscription.id)}
/>
))}
</SettingsList>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import SettingsGroup from "../../(partials)/settings-group";
import NotificationsDeviceGroup from "../(partials)/notifications-device-group";
import NotificationsDevicesGroup from "../(partials)/notifications-devices-group";
import { AppContext } from "@/src/pages/__root";
import usePushNotifications from "@/src/hooks/use-push-notifications";
import type { PushOverview } from "@/src/types";
/**
* Notifications settings: switch this device on or off, send a test, and
* see the other devices that receive notifications.
*/
export default function NotificationsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState<PushOverview>({
public_key: pageProps?.push_overview?.public_key || null,
subscriptions: pageProps?.push_overview?.subscriptions || [],
});
const [devices_status, setDevicesStatus] = useState<string>();
const push = usePushNotifications({ public_key: overview.public_key });
const refresh = async () => {
const res = await fetchApi<{}, APIResponseObject<PushOverview>>(
"/api/admin/push/overview",
{ method: "GET" },
);
if (res.success && res.singleRes) {
setOverview(res.singleRes);
}
};
return (
<>
<SettingsGroup
title="This device"
description="A notification arrives when a bot replies or needs you and the app is closed. While the app is open, nothing is sent."
>
<NotificationsDeviceGroup {...push} onChanged={refresh} />
</SettingsGroup>
<SettingsGroup
title="Devices"
description="Every device that receives notifications. Removing one stops it here."
>
<NotificationsDevicesGroup
subscriptions={overview.subscriptions}
status={devices_status}
onRemoved={(msg) => {
setDevicesStatus(msg);
refresh();
}}
/>
</SettingsGroup>
</>
);
}
@@ -0,0 +1,27 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* The Notifications page: this user's devices and the public key a new
* device subscribes with.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const keys = await ensureVapidKeys().catch(() => null);
return {
props: {
...settings.props,
push_overview: await grabPushOverview({
user_id: settings.props.person.id,
public_key: keys?.public_key || null,
}),
},
};
};
@@ -0,0 +1,16 @@
import NotificationsSection from "./(sections)/notifications-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsNotificationsPage() {
return (
<SettingsShell
title="Notifications"
description="When a bot needs you, and which devices hear about it."
>
<NotificationsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Notifications" });
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import type { PushOverview } from "@/src/types";
/**
* The Notifications page refreshes this user's devices after a change
* without a reload.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<PushOverview>
> = async ({ req }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
return {
success: true,
singleRes: await grabPushOverview({
user_id: user.id,
public_key: (await ensureVapidKeys().catch(() => null))
?.public_key,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscriptionById from "@/src/functions/backend/db/push/delete-push-subscription-by-id";
import { PushSubscriptionIdRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Removes one of the session user's devices from the settings list. Its
* browser keeps its own subscription until it turns notifications off
* there, which also removes it again on the next subscribe/unsubscribe.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { id } = parseWithSchema({
schema: PushSubscriptionIdRequestSchema,
data: body,
});
await deletePushSubscriptionById({ user_id: user.id, id });
return { success: true, msg: "Device removed." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+49
View File
@@ -0,0 +1,49 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import savePushSubscription from "@/src/functions/backend/db/push/save-push-subscription";
import { SavePushSubscriptionSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Saves this device's push subscription for the session user. The
* endpoint is unique, so subscribing again only refreshes the keys.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const data = parseWithSchema({
schema: SavePushSubscriptionSchema,
data: body,
});
await savePushSubscription({
user_id: user.id,
endpoint: data.endpoint,
p256dh: data.keys.p256dh,
auth: data.keys.auth,
user_agent: data.user_agent,
platform: data.platform,
});
return { success: true, msg: "Notifications are on for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+54
View File
@@ -0,0 +1,54 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import deliverToUser from "@/src/push/deliver-to-user";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { SiteData } from "@/src/data/site-data";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Sends one notification to every device this user has subscribed, so
* they can check that notifications arrive without waiting for a bot.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const subscriptions = await grabPushSubscriptions({
user_id: user.id,
});
if (!subscriptions.length) {
throw new AppError(
`Turn notifications on for this device first, then try again.`,
);
}
await deliverToUser({
user_id: user.id,
payload: {
title: `${SiteData["SiteName"]} notifications are on`,
body: "This is what a bot's message will look like.",
url: "/admin/settings/notifications",
tag: "cobble-test",
},
});
return { success: true, msg: "Test sent." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+42
View File
@@ -0,0 +1,42 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscription from "@/src/functions/backend/db/push/delete-push-subscription";
import { PushEndpointRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Forgets this device. The browser also drops its own subscription first,
* so the two never disagree for long.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { endpoint } = parseWithSchema({
schema: PushEndpointRequestSchema,
data: body,
});
await deletePushSubscription({ user_id: user.id, endpoint });
return { success: true, msg: "Notifications are off for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+1 -1
View File
@@ -21,7 +21,7 @@ export default function LogoutPage() {
return (
<Section
className={twMerge(
"h-screen w-screen px-0 items-center justify-center",
"h-dvh w-screen px-0 items-center justify-center",
)}
>
<Center>
+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;
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
const PushEndpointField = z
.string()
.trim()
.min(1, "Missing the device's push endpoint")
.max(2048, "That push endpoint is too long")
.refine(
(value) => value.startsWith("https://"),
"A push endpoint must use https",
);
export const SavePushSubscriptionSchema = z.object({
endpoint: PushEndpointField,
keys: z.object({
p256dh: z.string().trim().min(1).max(200),
auth: z.string().trim().min(1).max(100),
}),
/**
* Sent by the browser, only to label the device in Settings.
*/
user_agent: z.string().max(400).optional(),
platform: z.string().max(100).optional(),
});
export type SavePushSubscriptionRequest = z.infer<
typeof SavePushSubscriptionSchema
>;
export const PushEndpointRequestSchema = z.object({
endpoint: PushEndpointField,
});
export const PushSubscriptionIdRequestSchema = z.object({
id: z.coerce.number().int().positive("Pick a device"),
});
+300 -9
View File
@@ -1,14 +1,38 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import path from "path";
import sharp from "sharp";
import { SiteData } from "../data/site-data";
import {
PwaIcons,
PwaManifestName,
PwaManifestPath,
PwaOfflinePath,
PwaScope,
PwaShortName,
PwaShortcuts,
PwaStartPath,
} from "../data/pwa-data";
const ROOT = path.resolve(import.meta.dir, "../..");
const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg");
const PUBLIC_DIR = path.join(ROOT, "public");
const ICONS_DIR = path.join(PUBLIC_DIR, "icons");
const MAIN_CSS = path.join(ROOT, "src/styles/main.css");
const ICO_SIZES = [16, 24, 32, 48] as const;
const PNG_SIZES = [...ICO_SIZES, 180, 512] as const;
/**
* The app icon is the stone on paper, with room around it for the
* launcher's mask. The favicons keep the transparent stone.
*/
const AppIconMarkScale = 0.8;
/**
* A maskable icon is cropped to a circle of 80% of the canvas, so the
* stone stays inside it with room to spare.
*/
const MaskableMarkScale = 0.52;
function buildIco(pngs: Record<number, Buffer>): Buffer {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
@@ -38,16 +62,245 @@ function buildIco(pngs: Record<number, Buffer>): Buffer {
return Buffer.concat([header, ...entries, ...blobs]);
}
function escapeHtml(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
type ThemeColors = {
paper: string;
ink: string;
ink_secondary: string;
hairline: string;
dark_paper: string;
dark_ink: string;
dark_ink_secondary: string;
dark_hairline: string;
};
/**
* The theme's colors come from `src/styles/main.css`, the only place they
* are defined, so the manifest and the offline page never drift from it.
*/
async function grabThemeColors(): Promise<ThemeColors> {
const css = await Bun.file(MAIN_CSS).text();
const root_block = css.match(/:root\s*\{([^}]*)\}/s)?.[1] || "";
const dark_block = css.match(/\.dark\s*\{([^}]*)\}/s)?.[1] || "";
const token = (block: string, name: string) =>
block.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1]?.trim();
const paper = token(root_block, "c-paper");
const ink = token(root_block, "c-ink");
const ink_secondary = token(root_block, "c-ink-secondary");
const hairline = token(root_block, "c-border");
const dark_paper = token(dark_block, "c-paper");
const dark_ink = token(dark_block, "c-ink");
const dark_ink_secondary = token(dark_block, "c-ink-secondary");
const dark_hairline = token(dark_block, "c-border");
if (
!paper ||
!ink ||
!ink_secondary ||
!hairline ||
!dark_paper ||
!dark_ink ||
!dark_ink_secondary ||
!dark_hairline
) {
throw new Error("Cobble color tokens not found in src/styles/main.css");
}
return {
paper,
ink,
ink_secondary,
hairline,
dark_paper,
dark_ink,
dark_ink_secondary,
dark_hairline,
};
}
async function renderAppIcon({
svg,
size,
scale,
background,
}: {
svg: Buffer;
size: number;
scale: number;
background: string;
}) {
const mark_size = Math.max(1, Math.round(size * scale));
const mark = await sharp(svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background,
},
})
.composite([{ input: mark, gravity: "center" }])
.png()
.toBuffer();
}
function buildManifest({ colors }: { colors: ThemeColors }) {
return {
id: "/",
name: PwaManifestName,
short_name: PwaShortName,
description: "Your crew of small AI worker bots.",
lang: "en",
dir: "ltr",
start_url: PwaStartPath,
scope: PwaScope,
display: "standalone",
display_override: ["standalone", "minimal-ui"],
orientation: "any",
background_color: colors.paper,
theme_color: colors.paper,
categories: ["productivity", "utilities"],
prefer_related_applications: false,
icons: PwaIcons,
shortcuts: PwaShortcuts,
};
}
function buildOfflinePage({
colors,
logo,
}: {
colors: ThemeColors;
logo: string;
}) {
const name = escapeHtml(SiteData["SiteName"]);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="${colors.paper}" />
<title>Offline | ${name}</title>
<link rel="manifest" href="${PwaManifestPath}" />
<style>
:root {
--paper: ${colors.paper};
--ink: ${colors.ink};
--ink-secondary: ${colors.ink_secondary};
--hairline: ${colors.hairline};
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: ${colors.dark_paper};
--ink: ${colors.dark_ink};
--ink-secondary: ${colors.dark_ink_secondary};
--hairline: ${colors.dark_hairline};
}
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
background: var(--paper);
color: var(--ink);
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
main {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
h1 {
margin: 8px 0 0;
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
margin: 0;
font-size: 15px;
line-height: 1.5;
color: var(--ink-secondary);
}
button {
margin-top: 12px;
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: var(--ink);
color: var(--paper);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
</style>
</head>
<body>
<main>
${logo}
<h1>${name}</h1>
<p>You're offline.</p>
<p>Reconnect to keep working with your bots. Anything you sent before you lost the connection is still on its way.</p>
<button type="button" onclick="location.reload()">Try again</button>
</main>
</body>
</html>
`;
}
/**
* The logo inline, so the offline page has no request to make while
* offline. Its own width and height give way to ours.
*/
function inlineLogo(raw: string) {
const cleaned = raw.replace(/<\?xml[^>]*\?>/, "").replace(/<!DOCTYPE[^>]*>/, "");
return cleaned.replace(/<svg([^>]*)>/, (match, attrs: string) => {
const trimmed = attrs
.replace(/\swidth="[^"]*"/, "")
.replace(/\sheight="[^"]*"/, "");
return `<svg width="48" height="48" role="img" aria-label="${escapeHtml(
SiteData["SiteName"],
)}"${trimmed}>`;
});
}
async function main() {
if (!existsSync(LOGO_SVG)) {
throw new Error(`Logo SVG not found at ${LOGO_SVG}`);
}
const svg = await Bun.file(LOGO_SVG).arrayBuffer();
const svg = Buffer.from(await Bun.file(LOGO_SVG).arrayBuffer());
const colors = await grabThemeColors();
const pngs: Record<number, Buffer> = {};
for (const size of PNG_SIZES) {
pngs[size] = await sharp(Buffer.from(svg))
pngs[size] = await sharp(svg)
.resize(size, size)
.png()
.toBuffer();
@@ -57,16 +310,54 @@ async function main() {
writeFileSync(path.join(PUBLIC_DIR, "favicon.ico"), buildIco(pngs));
writeFileSync(
path.join(PUBLIC_DIR, "apple-touch-icon.png"),
pngs[180]!,
await renderAppIcon({
svg,
size: 180,
scale: AppIconMarkScale,
background: colors.paper,
}),
);
mkdirSync(path.join(PUBLIC_DIR, "logos"), { recursive: true });
writeFileSync(path.join(PUBLIC_DIR, "logos/logo.png"), pngs[512]!);
console.log("Generated brand assets from public/logos/logo.svg:");
console.log(" public/favicon.ico (16/24/32/48 multi-size)");
console.log(" public/favicon.png (32x32)");
console.log(" public/apple-touch-icon.png (180x180)");
console.log(" public/logos/logo.png (512x512)");
mkdirSync(ICONS_DIR, { recursive: true });
for (const icon of PwaIcons) {
const size = Number(icon.sizes.split("x")[0]);
const maskable = icon.purpose == "maskable";
writeFileSync(
path.join(PUBLIC_DIR, icon.src.replace(/^\//, "")),
await renderAppIcon({
svg,
size,
scale: maskable ? MaskableMarkScale : AppIconMarkScale,
background: colors.paper,
}),
);
}
writeFileSync(
path.join(PUBLIC_DIR, "manifest.webmanifest"),
`${JSON.stringify(buildManifest({ colors }), null, 4)}\n`,
);
writeFileSync(
path.join(PUBLIC_DIR, "offline.html"),
buildOfflinePage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }),
);
console.log("Generated brand and PWA assets from public/logos/logo.svg:");
console.log(" public/favicon.ico (16/24/32/48 multi-size)");
console.log(" public/favicon.png (32x32)");
console.log(" public/apple-touch-icon.png (180x180, opaque paper)");
console.log(" public/logos/logo.png (512x512)");
console.log(" public/icons/icon-192.png (app icon)");
console.log(" public/icons/icon-512.png (app icon)");
console.log(" public/icons/maskable-192.png (maskable, safe zone)");
console.log(" public/icons/maskable-512.png (maskable, safe zone)");
console.log(" public/manifest.webmanifest (name, colors and icons)");
console.log(" public/offline.html (self-contained offline page)");
}
main()
@@ -74,4 +365,4 @@ main()
.catch((error) => {
console.error("Brand asset generation failed:", error);
process.exit(1);
});
});
+18
View File
@@ -9,8 +9,10 @@ import botsQueue from "./bots-queue";
import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import { ComputerProxyPrefix } from "./dict/computer-dict";
import ensureDockerNetwork from "./functions/backend/docker/ensure-docker-network";
import { ensureVapidKeys } from "./push/vapid";
declare global {}
@@ -22,6 +24,18 @@ const port = process.env.PORT || 7227;
await enableForeignKeys();
await bunext.bunextInit();
/**
* Web Push needs a key pair; the first start of the main install creates
* it (in the Environment settings) so notifications work without setup.
*/
if (process.env.NODE_ENV != "production" || process.env.MAIN_SERVER) {
await ensureVapidKeys().catch((error) =>
logError({ title: "Could not prepare Web Push keys", error }).catch(
() => {},
),
);
}
/**
* Computers are docker containers on this network, so the app doesn't
* start without docker.
@@ -39,6 +53,10 @@ const server = Bun.serve({
try {
const url = new URL(req.url);
const pwa_file = await handlePwaFile({ pathname: url.pathname });
if (pwa_file) return pwa_file;
if (url.pathname.startsWith("/media")) {
try {
return await handleMediaServer({ req });
+3
View File
@@ -153,6 +153,9 @@
--radius-default-xl: 14px;
--container-container: 870px;
/* The rail's full (named) width: below it the rail shows icons only.
A container query, so the mobile drawer opens full too. */
--container-rail: 220px;
}
body {
+21
View File
@@ -652,6 +652,7 @@ export type PagePropsType = {
harness_overview?: HarnessOverviewPayload | null;
bot_harness_overview?: BotHarnessOverviewPayload | null;
envs_overview?: EnvsOverviewPayload | null;
push_overview?: PushOverview | null;
mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null;
bot_extensions?: BotExtensionsPayload | null;
@@ -1086,3 +1087,23 @@ export type FeatureItem = {
title: string;
description: string;
};
/**
* One device with push notifications on, as Settings lists it. Never the
* endpoint: that is a capability URL held by the browser.
*/
export type PushDeviceOverview = {
id: number;
device: string;
created_at: number;
last_used_at: number;
last_error: string | null;
};
export type PushOverview = {
/**
* The install's VAPID public key, which a subscribing browser needs.
*/
public_key: string | null;
subscriptions: PushDeviceOverview[];
};
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import describePushDevice from "./describe-push-device";
describe("describePushDevice", () => {
test("names the browser and the device", () => {
expect(
describePushDevice({
user_agent:
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
platform: "Linux armv8l",
}),
).toBe("Chrome on Android");
});
test("knows iOS browsers are WebKit but still Chrome", () => {
expect(
describePushDevice({
user_agent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.0.0 Mobile/15E148 Safari/604.1",
}),
).toBe("Chrome on iPhone");
});
test("falls back to the browser, then the platform, then a generic device", () => {
expect(describePushDevice({ user_agent: "curl/8.0" })).toBe(
"A browser",
);
expect(
describePushDevice({ user_agent: "something", platform: "linux" }),
).toBe("Linux");
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* A subscription's user agent as a short device label for the settings
* list, e.g. "Chrome on Android". Unknown parts are left out.
*/
export default function describePushDevice({
user_agent,
platform,
}: {
user_agent?: string | null;
platform?: string | null;
}): string {
const ua = user_agent || "";
const browser = /Edg\//.test(ua)
? "Edge"
: /OPR\/|Opera/.test(ua)
? "Opera"
: /Firefox\/|FxiOS/.test(ua)
? "Firefox"
: /CriOS/.test(ua)
? "Chrome"
: /Chrome\//.test(ua)
? "Chrome"
: /Safari\//.test(ua)
? "Safari"
: "";
const device = /iPhone/.test(ua)
? "iPhone"
: /iPad/.test(ua)
? "iPad"
: /Android/.test(ua)
? "Android"
: /Windows/.test(ua)
? "Windows"
: /Macintosh|Mac OS X/.test(ua)
? "Mac"
: /Linux/.test(ua)
? "Linux"
: platform
? platform.charAt(0).toUpperCase() + platform.slice(1)
: "";
if (browser && device) return `${browser} on ${device}`;
if (browser) return browser;
if (device) return device;
return "A browser";
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import urlBase64ToUint8Array from "./url-base64-to-uint8-array";
describe("urlBase64ToUint8Array", () => {
test("decodes base64url without padding", () => {
expect([...urlBase64ToUint8Array("AQID")]).toEqual([1, 2, 3]);
});
test("handles the URL-safe characters and missing padding", () => {
const bytes = urlBase64ToUint8Array("-_8");
expect([...bytes]).toEqual([0xfb, 0xff]);
});
test("returns a fresh typed array", () => {
const bytes = urlBase64ToUint8Array("AQID");
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.byteLength).toBe(3);
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* A base64url VAPID public key as the bytes `PushManager.subscribe`
* expects.
*/
export default function urlBase64ToUint8Array(base64_string: string) {
const padding = "=".repeat((4 - (base64_string.length % 4)) % 4);
const base64 = (base64_string + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let index = 0; index < raw.length; index += 1) {
bytes[index] = raw.charCodeAt(index);
}
return bytes;
}
+13
View File
@@ -11,6 +11,7 @@ import { wsSendData } from "./(functions)/ws-send-data";
import checkSessionAccess from "./(functions)/check-session-access";
import logEvent from "@/src/functions/backend/logger";
import { computerProxySocket } from "@/src/functions/server/computers/computer-proxy-socket";
import touchUserPresence from "@/src/functions/backend/db/push/touch-user-presence";
/**
* The twui `useWebSocket` hook sends a raw keepalive string to keep the
@@ -34,6 +35,10 @@ export const websocket = {
joinRoom(ws, "public");
if (user.id) {
void touchUserPresence({ user_id: user.id }).catch(() => {});
}
wsSendData({
ws,
data: {
@@ -59,6 +64,14 @@ export const websocket = {
) {
if (ws.data.proxy) return computerProxySocket.message(ws, message);
/**
* Every keepalive counts, including twui's raw ping string, so
* presence stays fresh while the app is open.
*/
if (ws.data.user?.id) {
void touchUserPresence({ user_id: ws.data.user.id }).catch(() => {});
}
const data = parseMessage(message);
if (!data) return;