MoveAudienceGate.tsx4.9 KBView on GitHub
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { FileScope } from '@/modules/files/store/documentsSlice';
import { useTRPC } from '@/providers/query-provider';

export interface PendingMove {
  documentId: string;
  newParentId: string | null;
  scope: FileScope;
  /** For the sentence. A move is confirmed about a named file, never about "this item". */
  title: string;
}

/**
 * The one prompt a move is allowed to raise, and the three quarters of moves that raise none.
 *
 * ── WHY A MOVE IS A SHARING ACT AT ALL ──────────────────────────────────────
 *
 * Dragging a file into a folder does not touch a single grant — the ones written on the
 * file move with it, re-addressed in the same transaction. What changes is everything the
 * file INHERITED: the old folder's audience stops reaching it and the new folder's starts.
 * So a move can hand a document to nine people or take it from five without any grant
 * being written, which is precisely the class of change this project states as a count
 * before the click.
 *
 * ── AND WHY MOST MOVES MUST NOT PROMPT ──────────────────────────────────────
 *
 * Almost every move is within one audience — a file from one org folder to another org
 * folder, seen by exactly the same people before and after. Prompting on those is worse
 * than not prompting on any of them: it trains the hand to dismiss the dialog, and the
 * two moves a year that genuinely widen a document get dismissed with the same reflex.
 * So `sameAudience` applies straight through, and this component renders nothing at all.
 *
 * The count comes from `files.movePreview`, which computes it with the enforcing
 * resolver — the number in the sentence is the number that becomes true.
 *
 * Design: apps/server/docs/sharing.md §3.2 item 23.
 */
export function MoveAudienceGate({
  move,
  onResolve,
}: {
  move: PendingMove;
  /** `true` applies the move, `false` abandons it. Called exactly once either way. */
  onResolve: (confirmed: boolean) => void;
}) {
  const trpc = useTRPC();
  const { data: preview, isPending, isError } = useQuery({
    ...trpc.files.movePreview.queryOptions({
      documentId: move.documentId,
      targetParentId: move.newParentId,
    }),
    // The answer is about THIS drop and nothing else; a cached one from a previous drag
    // would be a confirm about a move that is not happening.
    staleTime: 0,
    gcTime: 0,
    retry: false,
  });

  useEffect(() => {
    // Nothing changes for anybody → no dialog, no pause, straight through.
    if (preview?.sameAudience) onResolve(true);
    // A refused or unreachable preview must not become a silent block: `moveNode` runs
    // the same checks and will refuse for the same reason, in a place that can say so.
    if (isError) onResolve(true);
  }, [preview, isError, onResolve]);

  if (isPending || isError || !preview || preview.sameAudience) return null;

  const widening = preview.direction === 'widening';
  const people = widening ? preview.gaining : preview.losing;
  const count = people.length;
  const named = people
    .slice(0, 3)
    .map((person) => person.name ?? 'someone')
    .join(', ');

  return (
    <AlertDialog open onOpenChange={(open) => !open && onResolve(false)}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>
            {widening
              ? `${count} ${count === 1 ? 'person gains' : 'people gain'} access to ${move.title}`
              : `${count} ${count === 1 ? 'person loses' : 'people lose'} access to ${move.title}`}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {/* The two directions get different sentences because they are different
                worries: widening is about who can now read it, narrowing is about who
                will come looking for it and not find it. */}
            {widening
              ? `Moving it there lets ${named}${count > 3 ? ' and others' : ''} see it. The people you shared it with directly keep the access you gave them.`
              : `${named}${count > 3 ? ' and others' : ''} can see it today only because of where it is. Anyone you shared it with directly keeps their access.`}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel className="cursor-pointer" onClick={() => onResolve(false)}>
            Cancel
          </AlertDialogCancel>
          <AlertDialogAction className="cursor-pointer" onClick={() => onResolve(true)}>
            Move it
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}