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);
|
.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}`;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
|
|||||||
* needed keeping in sync.
|
* needed keeping in sync.
|
||||||
*/
|
*/
|
||||||
export const SendOffPanel = () => {
|
export const SendOffPanel = () => {
|
||||||
const { devices, people, isLoading } = useSendOffTargets();
|
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
||||||
const send = useSendOff();
|
const send = useSendOff();
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [target, setTarget] = useState<Target | null>(null);
|
const [target, setTarget] = useState<Target | null>(null);
|
||||||
@@ -107,10 +107,12 @@ export const SendOffPanel = () => {
|
|||||||
icon={User}
|
icon={User}
|
||||||
heading="People"
|
heading="People"
|
||||||
empty="Nobody has allowed you to send to them yet."
|
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) => ({
|
options={people.map((p) => ({
|
||||||
key: `u:${p.id}`,
|
key: `u:${p.id}`,
|
||||||
name: label(p),
|
name: p.displayName,
|
||||||
target: { kind: 'user' as const, id: p.id, name: label(p) },
|
target: { kind: 'user' as const, id: p.id, name: p.displayName },
|
||||||
}))}
|
}))}
|
||||||
selected={target}
|
selected={target}
|
||||||
onSelect={setTarget}
|
onSelect={setTarget}
|
||||||
@@ -126,6 +128,11 @@ export const SendOffPanel = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
{/* 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. */}
|
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.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
||||||
{send.data && (
|
{send.data && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<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 SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
|
||||||
export type SendOffPerson = { id: number; email: string; name: string | null };
|
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 })[] };
|
type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
|
||||||
|
|
||||||
const TARGETS = ['sendoff', 'targets'];
|
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.
|
// two-row query — the panel is the only reader and it is open for seconds at a time.
|
||||||
staleTime: 0,
|
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. */
|
/** Who I have allowed to send to me — the consent list, which only I can change. */
|
||||||
|
|||||||
Reference in New Issue
Block a user