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>
      }
    />
  );
}