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:
@@ -22,6 +22,11 @@ DATA_DIR=/data
|
||||
# Set to 1 on the one container that should run cron tasks in production
|
||||
MAIN_SERVER=
|
||||
|
||||
# Web Push: generated into Settings > Environment on first start. Set a
|
||||
# fixed pair here only if you want to keep one across installs.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
# Optional public envs exposed to the client through __root.server.ts
|
||||
PAYSTACK_PUBLIC_KEY=
|
||||
R2_PUBLIC_DOMAIN=
|
||||
|
||||
@@ -30,7 +30,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
|
||||
| Production | `bun run start` (`NODE_ENV=production bun src/server.ts`) |
|
||||
| Type-check only | `bun run typecheck` |
|
||||
| Run tests | `bun run test` |
|
||||
| Regenerate favicon/brand assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) |
|
||||
| Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) |
|
||||
| DB schema (tables) | `bun run db:schema` |
|
||||
| DB admin UI | `bun run db:admin` |
|
||||
| DB backup | `bun run db:backup` |
|
||||
@@ -116,10 +116,20 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
|
||||
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it).
|
||||
|
||||
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL` and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens.
|
||||
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL`, the `VAPID_*` Web Push keys and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens.
|
||||
- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`).
|
||||
- **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back).
|
||||
|
||||
### PWA and notifications
|
||||
|
||||
- **Generated assets:** `public/manifest.webmanifest`, `public/icons/*.png` and `public/offline.html` are generated from `public/logos/logo.svg` and the theme tokens by `bun run gen:favicon` (`src/scripts/generate-brand-assets.ts` reads `src/styles/main.css`, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons. `src/data/pwa-data.ts` holds the shared paths, icon list, start path (`/admin/bots`) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS).
|
||||
- **Head and install:** `src/pages/__root.tsx` `Head` links the manifest, sets the apple/mobile meta tags and renders `PwaInitScript`, which captures `beforeinstallprompt` before hydration (twui's hook reads it later), points `theme-color` at `--c-paper` (following the theme without repeating the color), and rewrites bunext's fixed viewport meta to add `viewport-fit=cover` and `interactive-widget=resizes-content`. `use-install-prompt` exposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opens `install-app-modal` with the Safari Share steps on iOS.
|
||||
- **Service worker:** `public/sw.js`, served by `src/server.ts` through `handlePwaFile` with `no-store` and `Service-Worker-Allowed: /` (bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only with `public/offline.html` as the fallback; authenticated HTML is never cached. `/.bunext/`, `/public/`, icons and logos are stale-while-revalidate; `/api/`, `/ws`, `/media/`, `/computers/` are never intercepted. `ServiceWorkerRegistrar` in `__root.tsx` registers it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off.
|
||||
- **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding.
|
||||
- **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline.
|
||||
- **Web Push:** `push_subscriptions` (one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) and `user_presence` (one row per user, refreshed by the socket keepalive in `src/websocket/index.ts`). VAPID keys are app settings in the `envs` table (`VAPID_PUBLIC_KEY` not secret, `VAPID_PRIVATE_KEY` secret), generated by `ensureVapidKeys` on the main server's first start; the subject is `SiteData.SiteURL`. `pushBotNotice` is the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped while `user_presence.last_seen_at` is within `AppData.PushPresenceWindowMs`, so an app open anywhere wins. `src/dict/push-dict.ts` owns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API in `api/admin/push/` (subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (`notifications/`) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app.
|
||||
- **Risks and notes:** push needs HTTPS (or localhost). Cloudflare never caches `/sw.js` (no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignores `interactive-widget`, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed for `PushPresenceWindowMs` (deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap.
|
||||
|
||||
### Harness (the model layer)
|
||||
|
||||
`src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`.
|
||||
@@ -149,7 +159,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
||||
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
||||
- Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
|
||||
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||
|
||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"turndown": "^7.2.4",
|
||||
"unpdf": "^1.8.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "4.1.12",
|
||||
},
|
||||
@@ -46,6 +47,7 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/sanitize-html": "^2.16.2",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -340,6 +342,8 @@
|
||||
|
||||
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/web-push": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
@@ -394,6 +398,8 @@
|
||||
|
||||
"adler-32": ["[email protected]", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
||||
|
||||
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
@@ -404,6 +410,8 @@
|
||||
|
||||
"array-find-index": ["[email protected]", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="],
|
||||
|
||||
"asn1.js": ["[email protected]", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"astring": ["[email protected]", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
|
||||
|
||||
"bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
@@ -412,10 +420,14 @@
|
||||
|
||||
"bluebird": ["[email protected]", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
|
||||
|
||||
"bn.js": ["[email protected]", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
|
||||
|
||||
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"body-scroll-lock": ["[email protected]", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun": ["[email protected]", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
|
||||
|
||||
"bun-plugin-tailwind": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||
@@ -520,6 +532,8 @@
|
||||
|
||||
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
@@ -652,6 +666,10 @@
|
||||
|
||||
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http_ece": ["[email protected]", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||
|
||||
"immediate": ["[email protected]", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
@@ -714,6 +732,10 @@
|
||||
|
||||
"jszip": ["[email protected]", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
|
||||
@@ -892,6 +914,8 @@
|
||||
|
||||
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimalistic-assert": ["[email protected]", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -1206,6 +1230,8 @@
|
||||
|
||||
"web-namespaces": ["[email protected]", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-push": ["[email protected]", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wmf": ["[email protected]", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
|
||||
|
||||
@@ -2077,6 +2077,94 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "user_presence",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "user_id",
|
||||
fieldDescription: `The user. One row each`,
|
||||
dataType: "INTEGER",
|
||||
unique: true,
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "users",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "user_presence_user_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "last_seen_at",
|
||||
fieldDescription: `Unix timestamp of the last open socket or keepalive, for skipping push notifications while the app is open`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_user_presence_last_seen_at",
|
||||
indexTableFields: ["last_seen_at"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "push_subscriptions",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "user_id",
|
||||
fieldDescription: `The user the device belongs to`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "users",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "push_subscriptions_user_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "endpoint",
|
||||
fieldDescription: `The push service URL the browser gave. Unique: one device, one row`,
|
||||
dataType: "TEXT",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fieldName: "p256dh",
|
||||
fieldDescription: `The subscription's public key`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "auth",
|
||||
fieldDescription: `The subscription's auth secret`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "user_agent",
|
||||
fieldDescription: `What the device said it was, for the settings list`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "platform",
|
||||
fieldDescription: `The browser's platform, for the settings list`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "last_used_at",
|
||||
fieldDescription: `Unix timestamp of the last notification sent to it`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "last_error",
|
||||
fieldDescription: `Why the last send failed, if it did`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_push_subscriptions_user_id",
|
||||
indexTableFields: ["user_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "envs",
|
||||
fields: [
|
||||
|
||||
+73
-1
@@ -36,6 +36,8 @@ export const BunSQLiteTables = [
|
||||
"bot_custom_tools",
|
||||
"workspace_custom_tools",
|
||||
"logs",
|
||||
"user_presence",
|
||||
"push_subscriptions",
|
||||
"envs",
|
||||
] as const
|
||||
|
||||
@@ -1262,6 +1264,76 @@ export type BUN_SQLITE_COBBLE_LOGS = {
|
||||
ip_address?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_USER_PRESENCE = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
/**
|
||||
* The user. One row each
|
||||
*/
|
||||
user_id?: number | "";
|
||||
/**
|
||||
* Unix timestamp of the last open socket or keepalive, for skipping push notifications while the app is open
|
||||
*/
|
||||
last_seen_at?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
/**
|
||||
* The user the device belongs to
|
||||
*/
|
||||
user_id?: number | "";
|
||||
/**
|
||||
* The push service URL the browser gave. Unique: one device, one row
|
||||
*/
|
||||
endpoint?: string;
|
||||
/**
|
||||
* The subscription's public key
|
||||
*/
|
||||
p256dh?: string;
|
||||
/**
|
||||
* The subscription's auth secret
|
||||
*/
|
||||
auth?: string;
|
||||
/**
|
||||
* What the device said it was, for the settings list
|
||||
*/
|
||||
user_agent?: string;
|
||||
/**
|
||||
* The browser's platform, for the settings list
|
||||
*/
|
||||
platform?: string;
|
||||
/**
|
||||
* Unix timestamp of the last notification sent to it
|
||||
*/
|
||||
last_used_at?: number | "";
|
||||
/**
|
||||
* Why the last send failed, if it did
|
||||
*/
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_ENVS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -1309,4 +1381,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
||||
expose_to_computers?: 0 | 1 | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS
|
||||
@@ -29,6 +29,7 @@
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/sanitize-html": "^2.16.2",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -61,6 +62,7 @@
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"turndown": "^7.2.4",
|
||||
"unpdf": "^1.8.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "cobble",
|
||||
"short_name": "cobble",
|
||||
"description": "Your crew of small AI worker bots.",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"start_url": "/admin/bots",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": [
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"background_color": "#fbfaf7",
|
||||
"theme_color": "#fbfaf7",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Cobble's service worker.
|
||||
*
|
||||
* The app is server-rendered and private, so authenticated HTML is never
|
||||
* cached: navigating offline lands on the generated offline page instead
|
||||
* of a stale conversation or a cached login redirect. Only the shell
|
||||
* (icons, manifest, offline page) and versioned app assets are cached.
|
||||
*
|
||||
* Bump `CACHE_VERSION` when this file changes in a way that must drop the
|
||||
* old caches.
|
||||
*/
|
||||
const CACHE_VERSION = "cobble-v1";
|
||||
const CACHE_NAME = `cobble-shell-${CACHE_VERSION}`;
|
||||
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
|
||||
const SHELL = [
|
||||
OFFLINE_URL,
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/maskable-192.png",
|
||||
"/icons/maskable-512.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/favicon.ico",
|
||||
"/logos/logo.svg",
|
||||
];
|
||||
|
||||
/**
|
||||
* Versioned app code and public assets: safe to refresh in the background.
|
||||
*/
|
||||
const ASSET_PREFIXES = ["/.bunext/", "/public/", "/icons/", "/logos/", "/brand/"];
|
||||
const ASSET_PATHS = ["/favicon.ico", "/favicon.png", "/apple-touch-icon.png"];
|
||||
|
||||
/**
|
||||
* Never intercepted: live data, sockets, media bytes, proxied computers,
|
||||
* auth side effects, and the worker itself.
|
||||
*/
|
||||
const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"];
|
||||
const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout"];
|
||||
|
||||
const DEFAULT_ICON = "/icons/icon-192.png";
|
||||
const DEFAULT_BADGE = "/icons/icon-192.png";
|
||||
|
||||
function isAssetRequest(url) {
|
||||
return (
|
||||
ASSET_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)) ||
|
||||
ASSET_PATHS.includes(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
function isBypassRequest(url) {
|
||||
return (
|
||||
BYPASS_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)) ||
|
||||
BYPASS_PATHS.includes(url.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) =>
|
||||
Promise.allSettled(SHELL.map((url) => cache.add(url))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (isBypassRequest(url)) return;
|
||||
|
||||
/**
|
||||
* A navigation: network only. Anything private stays out of storage;
|
||||
* offline gets the shell's offline page.
|
||||
*/
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(async () => {
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
return offline || new Response("You're offline.", { status: 503 });
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAssetRequest(url)) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
cache.put(request, response.clone()).catch(() => undefined);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return cached || (await network) || Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The page asks for a new version from its update notice. Nothing skips
|
||||
* the wait until the user says so.
|
||||
*/
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const title = payload.title || "Cobble";
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
icon: payload.icon || DEFAULT_ICON,
|
||||
badge: payload.badge || DEFAULT_BADGE,
|
||||
tag: payload.tag || undefined,
|
||||
renotify: Boolean(payload.tag),
|
||||
requireInteraction: Boolean(payload.requireInteraction),
|
||||
data: {
|
||||
url: payload.url || "/admin/bots",
|
||||
...(payload.data || {}),
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const target = new URL(
|
||||
event.notification.data?.url || "/admin/bots",
|
||||
self.location.origin,
|
||||
).href;
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then(async (clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url.startsWith(self.location.origin)) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(target).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await self.clients.openWindow(target);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The browser rotated the endpoint. The page holds the VAPID key and the
|
||||
* session, so it re-subscribes; this only tells it to.
|
||||
*/
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
for (const client of clients) {
|
||||
client.postMessage({
|
||||
type: "PUSH_RESUBSCRIBE",
|
||||
oldEndpoint: event.oldSubscription?.endpoint || null,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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