diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 64b3e4ef..ef5ec3a4 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -107,6 +107,7 @@ export function App() { {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 2fb814ca..cc89abda 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -155,6 +155,7 @@ import { Store, Puzzle, Radar, + Send, } from 'lucide-react'; /** @@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [ { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { 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 // 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 diff --git a/src/apps/officer-web/Screens/Dashboard/SendOff/SendOffScreen.tsx b/src/apps/officer-web/Screens/Dashboard/SendOff/SendOffScreen.tsx new file mode 100644 index 00000000..e6c56cce --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SendOff/SendOffScreen.tsx @@ -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('screens/sendoff', defaultLayout); + + return ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/SendOff/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/SendOff/defaultLayout.ts new file mode 100644 index 00000000..06458b4e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SendOff/defaultLayout.ts @@ -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', +}; diff --git a/src/apps/officer-web/Screens/Dashboard/SendOff/index.tsx b/src/apps/officer-web/Screens/Dashboard/SendOff/index.tsx new file mode 100644 index 00000000..29e49e54 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SendOff/index.tsx @@ -0,0 +1 @@ +export * from './SendOffScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 45b80443..1104195f 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -23,3 +23,4 @@ export * from './Terminal'; export * from './Jobs'; export * from './QrTransfer'; export * from './Minesweeper'; +export * from './SendOff'; diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index b3ab92d6..24829c8e 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper'; +import { appRegistryMetas as sendOffMetas } from '../apps/SendOff'; import { appRegistryMetas as davMetas } from '../apps/Dav'; import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ'; import type { QueryClient } from '@tanstack/react-query'; @@ -30,6 +31,7 @@ export const apps = [ ...monitorMetas, ...qrTransferMetas, ...minesweeperMetas, + ...sendOffMetas, ...davMetas, ...commandHqMetas, ...appStoreMetas, diff --git a/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx b/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx new file mode 100644 index 00000000..ce9aab49 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SendOff/SendOffPanel.tsx @@ -0,0 +1,219 @@ +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, isLoading } = useSendOffTargets(); + const send = useSendOff(); + const [files, setFiles] = useState([]); + const [target, setTarget] = useState(null); + const picker = useRef(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 ( +
+
+
+

Files

+ { + 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 = ''; + }} + /> + + + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + {kb(f.size)} + +
  • + ))} +
  • + {files.length} file{files.length === 1 ? '' : 's'} · {kb(total)} +
  • +
+ )} +
+ +
+

Where

+ {isLoading ? ( +

Loading…

+ ) : ( +
+ ({ + key: `m:${d.id}`, + name: d.name, + target: { kind: 'machine' as const, name: d.name }, + }))} + selected={target} + onSelect={setTarget} + /> + ({ + key: `u:${p.id}`, + name: label(p), + target: { kind: 'user' as const, id: p.id, name: label(p) }, + }))} + selected={target} + onSelect={setTarget} + /> +
+ )} +
+ +
+ + {/* 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. */} + {send.error &&

{send.error.message}

} + {send.data && ( +

+ Landed in {send.data.path} —{' '} + {send.data.files.map((f) => f.name).join(', ')} +

+ )} +
+ + +
+
+ ); +}; + +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) => ( +
+

+ + {heading} +

+ {options.length === 0 ? ( +

{empty}

+ ) : ( +
+ {options.map((o) => ( + + ))} +
+ )} +
+); + +/** + * 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 ( +
+

Who may send to me

+

+ Files from anyone you allow land in Documents/SendOff/their-name. + Turning someone off stops future sends and leaves what they already sent alone. +

+
+ {candidates.map((p) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SendOff/index.ts b/src/workspaces/officerdev/src/apps/SendOff/index.ts new file mode 100644 index 00000000..f30b418a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SendOff/index.ts @@ -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 }, +]; diff --git a/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts b/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts new file mode 100644 index 00000000..bb909215 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SendOff/useSendOff.ts @@ -0,0 +1,83 @@ +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 }; +type Targets = { devices: SendOffDevice[]; people: SendOffPerson[] }; +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({ + queryKey: TARGETS, + queryFn: () => client.get('/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 ?? [], 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({ + queryKey: SENDERS, + queryFn: () => client.get('/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({ + 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('/sendoff/send', form); + }, + onSuccess: () => void qc.invalidateQueries({ queryKey: TARGETS }), + }); +}