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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user