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>
This commit is contained in:
@@ -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.
|
||||
@@ -49,3 +49,24 @@ export function safeFilename(name: string): string {
|
||||
.slice(0, 200);
|
||||
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}`;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
upsertSendoffDevice,
|
||||
} from 'officerdb';
|
||||
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.
|
||||
@@ -140,7 +140,7 @@ sendoffRouter.post('/send', async (ctx) => {
|
||||
targetUserId = toUser;
|
||||
targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
|
||||
// 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');
|
||||
@@ -172,7 +172,19 @@ sendoffRouter.get('/targets', async (ctx) => {
|
||||
listSendoffDevices(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. */
|
||||
|
||||
@@ -17,7 +17,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
|
||||
* needed keeping in sync.
|
||||
*/
|
||||
export const SendOffPanel = () => {
|
||||
const { devices, people, isLoading } = useSendOffTargets();
|
||||
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
||||
const send = useSendOff();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
@@ -107,10 +107,12 @@ export const SendOffPanel = () => {
|
||||
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: label(p),
|
||||
target: { kind: 'user' as const, id: p.id, name: label(p) },
|
||||
name: p.displayName,
|
||||
target: { kind: 'user' as const, id: p.id, name: p.displayName },
|
||||
}))}
|
||||
selected={target}
|
||||
onSelect={setTarget}
|
||||
@@ -126,6 +128,11 @@ export const SendOffPanel = () => {
|
||||
</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">
|
||||
|
||||
@@ -3,7 +3,9 @@ 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[] };
|
||||
/** 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'];
|
||||
@@ -19,7 +21,7 @@ export function useSendOffTargets() {
|
||||
// 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 };
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user