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