PersonRow.tsx4.9 KBView on GitHub import { Bot, ChevronDown } from 'lucide-react';
import { ROLE_LABEL, type AudienceEntry, type ShareableRole } from '@/modules/sharing/types';
import { AudienceAvatar } from '@/modules/sharing/components/AudienceAvatar';
import { RoleMenu } from '@/modules/sharing/components/RoleMenu';
import { PersonAvatar } from '@/components/ui/person-avatar';
import { cn } from '@/lib/utils';
/**
* One person: face, name, address, role. Nothing else.
*
* ── WHAT THIS ROW USED TO SAY, AND WHY IT NO LONGER SAYS IT ─────────────────
*
* The second line was the WHY — "Owner · this is their file", "Can view · shared
* directly" — on the argument that a permission nobody can explain is one nobody can
* audit. The argument holds; the placement did not. Every row carrying a sentence
* turns a list of six people into six paragraphs, the role appears twice (once as
* prose, once as the control beside it), and the reader stops reading any of it. The
* second line is now the ADDRESS — identity, not commentary, and the thing that tells
* two people with the same name apart.
*
* ── THE WHOLE ROW IS THE CONTROL ────────────────────────────────────────────
*
* Not the pill at the end of it. A row has one question attached — what can this
* person do here — and putting its only answer in a small target at the far edge
* leaves the rest of the row looking pressable and doing nothing. The pill is still
* drawn, because it is what says the current value; it is just no longer the only
* place you may press. A row the viewer may not act on is a plain div with its role as
* text — never a disabled control, which says "you cannot" without ever saying why
* (wiki/sharing-ui.md gotcha 2).
*/
export function PersonRow({
entry,
/** The viewer's own row — Notion's "(You)", and the reason Remove asks first. */
you,
/** A write about this row is in flight. */
pending,
/** Whether the viewer may change this row at all. */
canShare,
/** How many OTHER principals could still manage this document. */
otherManagers,
onChangeRole,
onRemove,
}: {
entry: AudienceEntry;
you: boolean;
pending: boolean;
canShare: boolean;
otherManagers: number;
onChangeRole: (role: ShareableRole) => void;
onRemove: () => void;
}) {
const label = entry.name ?? entry.email ?? 'Someone';
const seed = entry.userId ?? entry.email ?? label;
// Only when it is not already the line above it: an address shown twice is one line
// of noise on every guest row.
const address = entry.email && entry.email !== label ? entry.email : null;
const role = ROLE_LABEL[entry.role] ?? entry.role;
/**
* An `agent` standing is a CROWD — everyone who holds that agent — and it reaches this
* list only when the grant names some agent OTHER than the one whose namespace the
* document is in (`GeneralAccess` owns that one). It still has to draw, because a
* standing the panel does not show is one nobody can find or take away; it just must
* not draw as a person. Given initials it arrives as a disc reading `EW`, a colleague
* the reader will try and fail to place.
*/
const audience = entry.via === 'agent';
const body = (
<>
{/* Their photo where we have one, their initials where we do not — the app's one
avatar, so the same face is the same mark in the tree, the panel and the
Shared column. */}
{audience ? (
<AudienceAvatar seed={seed} icon={Bot} />
) : (
<PersonAvatar userId={seed} name={entry.name} email={entry.email} image={entry.image} />
)}
<div className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-sm">
{label}
{you ? <span className="text-muted-foreground"> (You)</span> : null}
</span>
{address ? <span className="text-muted-foreground truncate text-xs">{address}</span> : null}
</div>
</>
);
const shell = cn(
'hover:bg-hover flex w-full items-center gap-2 rounded-lg px-2 py-1.5 transition-opacity',
pending && 'pointer-events-none opacity-60',
);
if (!entry.canRemove || !canShare) {
return (
<div className={shell}>
{body}
<span className="text-muted-foreground shrink-0 pr-1.5 text-xs">{role}</span>
</div>
);
}
return (
<RoleMenu
role={entry.role}
otherManagers={otherManagers}
onChange={onChangeRole}
onRemove={onRemove}
trigger={
<button
type="button"
aria-label={`${label} — ${role}`}
className={cn(shell, 'cursor-pointer')}
>
{body}
<span className="text-muted-foreground flex shrink-0 items-center gap-0.5 pr-0.5 text-xs">
{role}
<ChevronDown className="size-3" aria-hidden />
</span>
</button>
}
/>
);
}