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