Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
17 changed files with 650 additions and 4 deletions
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.23.01", "version": "2026.08.23.02",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} /> <Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} /> <Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} /> <Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} /> <Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -155,6 +155,7 @@ import {
Store, Store,
Puzzle, Puzzle,
Radar, Radar,
Send,
} from 'lucide-react'; } from 'lucide-react';
/** /**
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and // Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working // contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure // while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs'; export * from './Jobs';
export * from './QrTransfer'; export * from './QrTransfer';
export * from './Minesweeper'; export * from './Minesweeper';
export * from './SendOff';
+21
View File
@@ -49,3 +49,24 @@ export function safeFilename(name: string): string {
.slice(0, 200); .slice(0, 200);
return cleaned || 'file'; return cleaned || 'file';
} }
/**
* The name an account appears as, and therefore the name of the folder its files land in.
*
* One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says
* "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked
* for this to be served rather than derived, which is right — three clients reimplementing a fallback is
* how three clients disagree.
*
* `users.name` is nullable, so the fallback chain ends at the account id, which is always there.
*/
export function displayNameFor(account: { id: number; name: string | null; email: string }): string {
const chosen = account.name?.trim();
if (chosen) return chosen;
const local = account.email.split('@')[0]?.trim();
return local || `user-${account.id}`;
}
/** What that name becomes on disk — the same value, through the segment sanitiser. */
export const folderNameFor = (account: { id: number; name: string | null; email: string }): string =>
safeSegment(displayNameFor(account)) || `user-${account.id}`;
+15 -3
View File
@@ -17,7 +17,7 @@ import {
upsertSendoffDevice, upsertSendoffDevice,
} from 'officerdb'; } from 'officerdb';
import { getNotifyServerUrl } from '../notify/router'; import { getNotifyServerUrl } from '../notify/router';
import { safeFilename, safeSegment, sendoffDirFor } from './paths'; import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
/* /*
SendOff — files land in a real directory in the recipient's own tree. SendOff — files land in a real directory in the recipient's own tree.
@@ -140,7 +140,7 @@ sendoffRouter.post('/send', async (ctx) => {
targetUserId = toUser; targetUserId = toUser;
targetAccount = { email: recipient.email, isOwner: full?.id === 1 }; targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
// From the TOKEN. This is the whole reason the folder name can be trusted. // From the TOKEN. This is the whole reason the folder name can be trusted.
folder = safeSegment(me.name || me.email.split('@')[0] || `user-${me.id}`); folder = folderNameFor({ id: me.id, name: me.name ?? null, email: me.email });
} }
if (!folder) throw errors.BAD_REQUEST('that name has no usable characters'); if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
@@ -172,7 +172,19 @@ sendoffRouter.get('/targets', async (ctx) => {
listSendoffDevices(me.id), listSendoffDevices(me.id),
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id), isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id),
]); ]);
return ctx.json({ devices, people }); /*
`displayName` is served rather than derived, at mobile's request and for the right reason: the writer
already computes this exact string to name a folder, and a picker that computes its own would sooner or
later disagree with it — which means telling somebody the wrong place to look.
`myFolder` answers the question a picker actually wants: files I send land in the RECIPIENT's tree under
MY name, so it is one value for the whole list, not a field on each row.
*/
return ctx.json({
devices,
people: people.map((p) => ({ ...p, displayName: displayNameFor(p) })),
myFolder: folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }),
});
}); });
/** GET /sendoff/senders — who I have allowed to send to me. */ /** GET /sendoff/senders — who I have allowed to send to me. */
@@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper'; import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav'; import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ'; import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query';
@@ -30,6 +31,7 @@ export const apps = [
...monitorMetas, ...monitorMetas,
...qrTransferMetas, ...qrTransferMetas,
...minesweeperMetas, ...minesweeperMetas,
...sendOffMetas,
...davMetas, ...davMetas,
...commandHqMetas, ...commandHqMetas,
...appStoreMetas, ...appStoreMetas,
@@ -0,0 +1,226 @@
import { useRef, useState } from 'react';
import { Laptop, Send, Upload, User, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
const kb = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; name: string };
/**
* SendOff — pick files, pick where, send.
*
* Deliberately has no "received" view. Files land in `Documents/SendOff/` in the recipient's own tree, so
* the file browser is already the receiving UI and a second one here would be a worse copy of it that
* needed keeping in sync.
*/
export const SendOffPanel = () => {
const { devices, people, myFolder, isLoading } = useSendOffTargets();
const send = useSendOff();
const [files, setFiles] = useState<File[]>([]);
const [target, setTarget] = useState<Target | null>(null);
const picker = useRef<HTMLInputElement>(null);
const total = files.reduce((n, f) => n + f.size, 0);
const ready = files.length > 0 && target !== null;
const doSend = async () => {
if (!target) return;
await send
.mutateAsync({ files, target: target.kind === 'machine' ? { machine: target.name } : { toUser: target.id } })
// Clear only the files. The target stays selected because sending several batches to the same place
// is the common second action, and re-picking it every time is the kind of small tax that stops
// somebody using a thing.
.then(() => setFiles([]))
.catch(() => {});
};
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
<section>
<h3 className="text-sm font-semibold text-foreground">Files</h3>
<input
ref={picker}
type="file"
multiple
className="hidden"
onChange={(ev) => {
setFiles((prev) => [...prev, ...Array.from(ev.target.files ?? [])]);
// Reset the input, or picking the same file twice in a row does nothing: `change` does not
// fire when the value is unchanged.
ev.target.value = '';
}}
/>
<Button variant="outline" className="mt-2 w-full justify-start" onClick={() => picker.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
{files.length ? 'Add more files…' : 'Choose files…'}
</Button>
{files.length > 0 && (
<ul className="mt-2 flex flex-col gap-1">
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1 text-sm"
>
<span className="truncate text-foreground">{f.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
<button
type="button"
aria-label={`Remove ${f.name}`}
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
<li className="px-2 pt-1 text-xs text-muted-foreground">
{files.length} file{files.length === 1 ? '' : 's'} · {kb(total)}
</li>
</ul>
)}
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">Where</h3>
{isLoading ? (
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
) : (
<div className="mt-2 flex flex-col gap-3">
<TargetGroup
icon={Laptop}
heading="My machines"
empty="No machines registered yet — an Officer app registers itself on start."
options={devices.map((d) => ({
key: `m:${d.id}`,
name: d.name,
target: { kind: 'machine' as const, name: d.name },
}))}
selected={target}
onSelect={setTarget}
/>
<TargetGroup
icon={User}
heading="People"
empty="Nobody has allowed you to send to them yet."
// `displayName` from the server, never computed here: it is the folder the writer will
// create, and a picker that guessed it would sooner or later name the wrong directory.
options={people.map((p) => ({
key: `u:${p.id}`,
name: p.displayName,
target: { kind: 'user' as const, id: p.id, name: p.displayName },
}))}
selected={target}
onSelect={setTarget}
/>
</div>
)}
</section>
<section className="flex flex-col gap-2">
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
<Send className="mr-2 h-4 w-4" />
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
</Button>
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
revoked you are different problems with different answers. */}
{target?.kind === 'user' && myFolder && (
<p className="text-xs text-muted-foreground">
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
</p>
)}
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
{send.data && (
<p className="text-sm text-muted-foreground">
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
{send.data.files.map((f) => f.name).join(', ')}
</p>
)}
</section>
<WhoMaySend />
</div>
</div>
);
};
type TargetGroupProps = {
icon: typeof Laptop;
heading: string;
empty: string;
options: { key: string; name: string; target: Target }[];
selected: Target | null;
onSelect: (t: Target) => void;
};
const sameTarget = (a: Target | null, b: Target): boolean =>
a !== null &&
a.kind === b.kind &&
(b.kind === 'machine' ? a.kind === 'machine' && a.name === b.name : a.kind === 'user' && a.id === b.id);
const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }: TargetGroupProps) => (
<div>
<p className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{heading}
</p>
{options.length === 0 ? (
<p className="mt-1 text-sm text-muted-foreground">{empty}</p>
) : (
<div className="mt-1 flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o.key}
size="sm"
variant={sameTarget(selected, o.target) ? 'default' : 'outline'}
onClick={() => onSelect(o.target)}
>
{o.name}
</Button>
))}
</div>
)}
</div>
);
/**
* The consent list, on the same screen as sending.
*
* It belongs here rather than in settings because the two questions arrive together: the first time
* somebody opens SendOff and finds nobody to send to, the reason is that consent runs the other way, and
* the answer has to be visible in the same place the question was asked.
*/
const WhoMaySend = () => {
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
if (isLoading || candidates.length === 0) return null;
return (
<section className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
<p className="mt-1 text-xs text-muted-foreground">
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
Turning someone off stops future sends and leaves what they already sent alone.
</p>
<div className="mt-2 flex flex-col gap-1">
{candidates.map((p) => (
<label
key={p.id}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
>
<span className="truncate text-foreground">{label(p)}</span>
<input
type="checkbox"
checked={p.allowed}
disabled={isSaving}
onChange={(ev) => void setAllowed(p.id, ev.target.checked).catch(() => {})}
/>
</label>
))}
</div>
</section>
);
};
@@ -0,0 +1,10 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Send } from 'lucide-react';
import { SendOffPanel } from './SendOffPanel';
export { SendOffPanel } from './SendOffPanel';
export { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'sendoff', name: 'SendOff', icon: Send, component: SendOffPanel },
];
@@ -0,0 +1,85 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
export type SendOffPerson = { id: number; email: string; name: string | null };
/** As served by `/targets` — `displayName` is the folder name, computed once on the server. */
export type SendOffTargetPerson = SendOffPerson & { displayName: string };
type Targets = { devices: SendOffDevice[]; people: SendOffTargetPerson[]; myFolder: string };
type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
const TARGETS = ['sendoff', 'targets'];
const SENDERS = ['sendoff', 'senders'];
/** Where a file can go: my own named machines, and the accounts that have allowed me. */
export function useSendOffTargets() {
const client = useClient();
const { data, isLoading, error } = useQuery<Targets>({
queryKey: TARGETS,
queryFn: () => client.get<Targets>('/sendoff/targets'),
// A machine registered from a phone a moment ago should appear without a reload, and this is a
// two-row query — the panel is the only reader and it is open for seconds at a time.
staleTime: 0,
});
return { devices: data?.devices ?? [], people: data?.people ?? [], myFolder: data?.myFolder ?? '', isLoading, error };
}
/** Who I have allowed to send to me — the consent list, which only I can change. */
export function useSendOffSenders() {
const client = useClient();
const qc = useQueryClient();
const { data, isLoading } = useQuery<Senders>({
queryKey: SENDERS,
queryFn: () => client.get<Senders>('/sendoff/senders'),
staleTime: 0,
});
const allow = useMutation({
mutationFn: (userId: number) => client.post('/sendoff/senders', { userId }),
// Both: granting changes who may send to ME, and on somebody else's screen it changes who they may
// send TO. Only this browser's cache is reachable from here, so it invalidates its own of each.
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
const revoke = useMutation({
mutationFn: (userId: number) => client.delete(`/sendoff/senders/${userId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
return {
candidates: data?.candidates ?? [],
isLoading,
setAllowed: (userId: number, allowed: boolean) =>
allowed ? allow.mutateAsync(userId) : revoke.mutateAsync(userId),
isSaving: allow.isPending || revoke.isPending,
};
}
export type SendResult = { path: string; files: { name: string; size: number }[] };
/**
* The send itself — multipart, through the ordinary client.
*
* `client.post` already recognises a `FormData` and drops the JSON content-type when it sees one, so the
* browser picks the boundary. That is the only thing that made this look like it needed a raw `fetch`.
*/
export function useSendOff() {
const client = useClient();
const qc = useQueryClient();
return useMutation<SendResult, Error, { files: File[]; target: { machine: string } | { toUser: number } }>({
mutationFn: ({ files, target }) => {
const form = new FormData();
for (const f of files) form.append('file', f);
if ('machine' in target) form.append('machine', target.machine);
else form.append('toUser', String(target.toUser));
return client.post<SendResult>('/sendoff/send', form);
},
onSuccess: () => void qc.invalidateQueries({ queryKey: TARGETS }),
});
}