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:
2026-08-23 02:50:23 +00:00
co-authored by Claude Opus 5
parent ca7d3c657b
commit 2137d06498
5 changed files with 90 additions and 8 deletions
@@ -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. */