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