LinkSection.tsx21.8 KBView on GitHub import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { formatDistanceToNowStrict } from 'date-fns';
import { Check, Globe } from 'lucide-react';
import {
asDate,
audienceNeedsValues,
errorMessage,
expiryFromDays,
isLinkLive,
LINK_AUDIENCE_LABEL,
LINK_AUDIENCES,
LINK_DEFAULTS,
LINK_EXPIRY_CHOICES,
LINK_ROLE_LADDER,
revokeConsequence,
ROLE_LABEL,
shareLinkUrl,
viewsPhrase,
type LinkAudience,
type LinkRole,
type ShareLink,
} from '@/modules/sharing/types';
import { FormActions, FormError, TextField } from '@/components/ui/field';
import { PersonAvatarStack } from '@/components/ui/person-avatar';
import { PillMenu } from '@/modules/sharing/components/PillMenu';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* Typed share links — the section that replaces one anonymous, eternal token.
*
* ── WHY A LINK'S SETTINGS CANNOT BE EDITED ──────────────────────────────────
*
* There is no `updateShareLink`, and there should not be. Every setting on a live
* link — audience, role, expiry, password — is a promise made to a URL that has
* already been sent to somebody. Editing one in place would either change what that
* URL does under its holder (an expiry moved forward silently locks out the person
* reading through it) or rotate the token, which breaks the URL with no way for
* anyone to notice except by clicking it.
*
* So the settings are chosen ONCE, in a composer, before the link exists; after that
* the link is a fact with two acts on it, Copy and Revoke. The audience picker says
* so at the foot of its list rather than offering options that would refuse — the
* same rule `RoleMenu` follows for the last manager, and for the same reason: a
* missing option with no sentence beside it is indistinguishable from a bug.
*
* This is the one place in the panel with a Save-shaped button, and R1 ("apply on
* change; there is no Save") is not violated by it: R1 is about the panel's ambient
* STATE — a role, the org's access, the private switch — where a Save button would
* falsely imply nothing had happened yet. Minting a bearer credential is not a state
* change, it is a creation, and a half-configured one must not exist even for the
* moment between two clicks.
*
* ── WHY EXPIRED LINKS ARE STILL LISTED ──────────────────────────────────────
*
* `listShareLinks` returns them deliberately: a lapsed link confers nothing but it is
* still the thing somebody is emailing you about ("your link doesn't work"). It reads
* `Expired` and keeps its Revoke, and it does NOT count towards whether link sharing
* is on — a document whose only link expired on Tuesday says `Off`, which is true.
*
* ── WHY IT IS A BIG BUTTON AND NOT A ROW CONTROL ────────────────────────────
*
* Publishing is not a setting on this document, it is an ACT you either have or have
* not performed — and a tab whose entire subject is that act opened on a hairline row
* reading `Off` beside a chevron, which is how you draw a preference, not a decision.
* The tab now says what publishing is and offers one button, the way Notion's does;
* the choices that were crowding the row appear when you press it, which is the same
* flow it always ran, reached from something you can see.
*
* Design: apps/server/docs/sharing.md §3.2 E10-E12, Phase 4.
*/
/** The composer's contents, before anything has been minted. */
interface LinkDraft {
audience: LinkAudience;
role: LinkRole;
days: number | null;
/** Addresses or domains, as typed. Split on write, not on every keystroke. */
values: string;
password=[redacted];
}
function newDraft(audience: LinkAudience): LinkDraft {
return {
audience,
role: LINK_DEFAULTS.role,
days: LINK_DEFAULTS.days,
values: '',
password: '',
};
}
/** "<email>, <email>" → two values. Commas, spaces and newlines all separate. */
function splitValues(input: string): string[] {
return input
.split(/[\s,;]+/)
.map((value) => value.trim())
.filter(Boolean);
}
/**
* The links on a document, and the two writes that change them.
*
* Lives here rather than in `use-share-state` because it is gated differently: the
* audience is readable by anybody who can read the document, while this list carries
* TOKENS and the route refuses anyone below `manager` (services/documents/
* share-links.ts). Asking for it as a viewer would be a guaranteed FORBIDDEN on every
* open of the panel, so `canShare` gates the query rather than the rendering.
*/
export function useShareLinks(documentId: string, canShare: boolean) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const listOptions = trpc.files.listShareLinks.queryOptions({ documentId }, { enabled: canShare });
const { data } = useQuery(listOptions);
const refresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey=[redacted] });
// eslint-disable-next-line react-hooks/exhaustive-deps -- the key is stable per documentId
}, [queryClient, documentId]);
const { mutate: create, isPending: creating } = useMutation({
...trpc.files.createShareLink.mutationOptions(),
onSuccess: () => {
setError(null);
refresh();
},
// In the panel, under the form it happened in. Never a toast — a permission
// error is a sentence to act on, not a notification (R3).
onError: (err) => setError(errorMessage(err, 'Could not make a link.')),
});
const { mutate: revoke, isPending: revoking } = useMutation({
...trpc.files.revokeShareLink.mutationOptions(),
onSuccess: () => {
setError(null);
refresh();
},
onError: (err) => setError(errorMessage(err, 'Could not revoke that link.')),
});
const links = useMemo(() => data ?? [], [data]);
const live = useMemo(() => links.filter((link) => isLinkLive(link)), [links]);
return {
links,
live,
/** What the panel's own Copy button hands over when there is one. */
newestLive: live[0] ?? null,
error,
clearError: useCallback(() => setError(null), []),
busy: creating || revoking,
create,
revoke,
};
}
export type ShareLinksState = ReturnType<typeof useShareLinks>;
/**
* "Can view · Anyone at Cedar" over "Expires in 29 days · opened 34 times".
*
* Counted in DAYS rather than by `formatDistanceToNow`'s own units. Two reasons, and
* the second is the real one: "in about 1 month" is longer than "in 30 days" and this
* line has two buttons beside it in a 360px panel, so the fuzzy version is the one
* that truncates — and it is fuzzy about exactly the number the reader chose from the
* menu two clicks ago.
*/
function linkFacts(link: ShareLink): { standing: string; life: string } {
const expires = asDate(link.expiresAt);
const life =
expires === null
? 'Never expires'
: expires.getTime() <= Date.now()
? 'Expired'
: `Expires ${formatDistanceToNowStrict(expires, { addSuffix: true, unit: 'day' })}`;
return {
standing: `${ROLE_LABEL[link.role] ?? link.role} · ${LINK_AUDIENCE_LABEL[link.audience]}`,
life: `${life} · ${viewsPhrase(link.viewCount)}`,
};
}
export function LinkSection({
documentId,
state,
canShare,
className,
}: {
documentId: string;
state: ShareLinksState;
canShare: boolean;
className?: string;
}) {
const { links, live, error, clearError, busy, create, revoke } = state;
/** The composer. Null until `Publish` is pressed; there is no half-made link. */
const [draft, setDraft] = useState<LinkDraft | null>(null);
const [valuesError, setValuesError] = useState<string | null>(null);
/** Which link the reader is being asked about, and whether it is all of them. */
const [revoking, setRevoking] = useState<{ links: ShareLink[] } | null>(null);
const [copied, setCopied] = useState<string | null>(null);
/**
* The copy-confirm timer, held so unmounting can cancel it.
*
* This panel lives in a popover that unmounts on Escape, so a timer left running
* calls `setCopied` on a component that is gone — every single time somebody copies
* a link and then closes the panel, which is the ordinary way to use it.
*/
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
},
[],
);
const published = live[0] ?? null;
const lapsed = links.filter((link) => !isLinkLive(link));
const submit = () => {
if (!draft) return;
const values = splitValues(draft.values);
if (audienceNeedsValues(draft.audience) && values.length === 0) {
// Refused here as well as on the server, because an audience with no values
// admits nobody: the link would look broken to everyone who opened it,
// including the person who made it.
setValuesError('Name at least one address or domain.');
return;
}
setValuesError(null);
const expiresAt = expiryFromDays(draft.days);
create(
{
documentId,
role: draft.role,
audience: draft.audience,
...(values.length > 0 ? { audienceValues: values } : {}),
...(expiresAt ? { expiresAt } : {}),
...(draft.password ? { password=[redacted] } : {}),
},
// The composer is discarded ON SUCCESS, not at submit. Cleared optimistically,
// a refused create left the reader with a sentence and no form — the addresses,
// the expiry and the password they had just chosen all gone, and nothing to
// retry from. A per-call callback because the draft is the COMPONENT'S state
// and the mutation lives in the hook.
{ onSuccess: () => setDraft(null) },
);
};
const copy = (link: ShareLink) => {
void navigator.clipboard?.writeText(shareLinkUrl(link.token, window.location.origin));
setCopied(link.id);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(null), 1500);
};
return (
<div className={cn('flex flex-col', className)}>
{/* One tween per surface, so the composer opening and the published state landing
read as one movement rather than two (CLAUDE.md → UI affordances). */}
<AnimatePresence initial={false} mode="wait">
{published ? (
<motion.div
key=[redacted]
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 px-3.5 pb-2 pt-3">
<div className="bg-sunken border-surface-border flex items-center gap-2 rounded-lg border px-3 py-1.5">
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">
{shareLinkUrl(published.token, window.location.origin)}
</span>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 cursor-pointer gap-1.5 px-2 text-xs"
onClick={() => copy(published)}
>
{copied === published.id ? (
<>
<Check className="size-3" aria-hidden /> Copied
</>
) : (
'Copy'
)}
</Button>
</div>
{/* The facts, not controls: every setting on a live link is a promise made
to a URL somebody already holds. Changing one in place would either move
it under its holder or rotate the token and break it silently. */}
<div className="flex items-center gap-2">
<p className="text-muted-foreground min-w-0 flex-1 text-xs">
{linkFacts(published).standing} · {linkFacts(published).life}
</p>
{/* WHO, beside HOW MANY — a face carries the answer with no sentence
under it, and the count already says how many opens there were.
Only opens Cedar could name appear here, so the stack is a subset
of the count and never a contradiction of it: a link opened by
nobody identifiable simply has no discs. Each disc names its person
in its own `title`. */}
{published.viewers.length > 0 ? (
<PersonAvatarStack people={published.viewers} max={4} />
) : null}
</div>
{canShare ? (
<FormActions>
<Button
size="sm"
variant="destructive"
className="h-6 cursor-pointer px-2.5 text-xs"
disabled={busy}
onClick={() => setRevoking({ links: live })}
>
Unpublish
</Button>
</FormActions>
) : null}
</div>
</motion.div>
) : draft ? (
<motion.div
key=[redacted]
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 px-1.5 pb-1.5 pt-1.5">
<div className="flex items-center gap-2 rounded-lg px-2 py-1">
<span className="flex-1 truncate text-sm">Who can open it</span>
<PillMenu
value={draft.audience}
label={LINK_AUDIENCE_LABEL[draft.audience]}
ariaLabel="Link audience"
options={[
LINK_DEFAULTS.audience,
...LINK_AUDIENCES.filter((audience) => audience !== LINK_DEFAULTS.audience),
].map((audience) => ({
value: audience,
label: LINK_AUDIENCE_LABEL[audience],
}))}
onPick={(audience) => {
const picked = LINK_AUDIENCES.find((candidate) => candidate === audience);
if (picked) setDraft({ ...draft, audience: picked });
}}
/>
</div>
<div className="flex items-center gap-2 rounded-lg px-2 py-1">
<span className="flex-1 truncate text-sm">They can</span>
<PillMenu
value={draft.role}
label={ROLE_LABEL[draft.role] ?? draft.role}
ariaLabel="Link role"
options={LINK_ROLE_LADDER.map((role) => ({
value: role,
label: ROLE_LABEL[role],
}))}
footnote="Only someone you name can re-share."
onPick={(role) => setDraft({ ...draft, role })}
/>
</div>
<div className="flex items-center gap-2 rounded-lg px-2 py-1">
<span className="flex-1 truncate text-sm">Expires</span>
<PillMenu
value={String(draft.days)}
label={
LINK_EXPIRY_CHOICES.find((choice) => choice.days === draft.days)?.label ??
'Never'
}
ariaLabel="Link expiry"
options={LINK_EXPIRY_CHOICES.map((choice) => ({
value: String(choice.days),
label: choice.label,
}))}
onPick={(value) => {
const choice = LINK_EXPIRY_CHOICES.find((c) => String(c.days) === value);
if (choice) setDraft({ ...draft, days: choice.days });
}}
/>
</div>
<div className="flex flex-col gap-2 px-2 pt-0.5">
{audienceNeedsValues(draft.audience) ? (
<TextField
label={draft.audience === 'domain' ? 'Domains' : 'Addresses'}
error={valuesError}
value={draft.values}
placeholder={draft.audience === 'domain' ? 'acme.com' : '<email>'}
onChange={(event) => setDraft({ ...draft, values: event.target.value })}
/>
) : null}
<TextField
label="Password"
optional
type="password"
value={draft.password}
onChange={(event) => setDraft({ ...draft, password=[redacted] })}
/>
<FormActions>
<Button
size="sm"
className="h-6 cursor-pointer px-2.5 text-xs"
disabled={busy}
onClick={submit}
>
Publish
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 cursor-pointer px-2.5 text-xs"
onClick={() => {
setDraft(null);
setValuesError(null);
}}
>
Cancel
</Button>
</FormActions>
</div>
</div>
</motion.div>
) : (
<motion.div
key=[redacted]
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
className="overflow-hidden"
>
{/* The whole tab is one act, so it is one button. */}
<div className="flex flex-col items-center gap-1 px-6 pb-4 pt-5 text-center">
<span className="bg-control text-muted-foreground mb-1 flex size-8 items-center justify-center rounded-full">
<Globe className="size-4" aria-hidden />
</span>
<p className="text-sm font-medium">Publish to web</p>
<p className="text-muted-foreground text-xs">
Make a link that admits people who were never invited.
</p>
{canShare ? (
<Button
className="mt-2 h-8 w-full cursor-pointer text-xs font-medium"
disabled={busy}
onClick={() => {
clearError();
setValuesError(null);
setDraft(newDraft(LINK_DEFAULTS.audience));
}}
>
Publish
</Button>
) : (
// Never a disabled button: a control you may not use is a question the
// panel should have answered.
<p className="text-muted-foreground pt-1 text-xs">
Only someone with full access can publish this.
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* A lapsed link confers nothing, but it is still the thing somebody is emailing
you about ("your link doesn't work"), so it stays until it is cleared. */}
{lapsed.length > 0 && canShare ? (
<div className="border-surface-border border-t px-1.5 py-1.5">
{lapsed.map((link) => (
<div key=[redacted] className="flex items-center gap-2 rounded-lg px-2 py-1.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{linkFacts(link).standing}</span>
<span className="text-muted-foreground truncate text-xs">
{linkFacts(link).life}
</span>
</div>
<Button
size="sm"
variant="ghost"
className="text-destructive h-6 shrink-0 cursor-pointer px-2 text-xs"
onClick={() => setRevoking({ links: [link] })}
>
Revoke
</Button>
</div>
))}
</div>
) : null}
{revoking ? (
<div className="flex flex-col gap-1.5 px-3.5 pb-2 pt-1">
{/* R2: the consequence stated as a COUNT, before the click. A link nobody
used costs nothing to kill; one opened thirty-four times is somebody's
working reference, and the two must not look alike. */}
<p className="text-sm">
{revokeConsequence(
revoking.links.reduce((total, link) => total + link.viewCount, 0),
revoking.links.length,
)}
</p>
<FormActions>
<Button
size="sm"
variant="destructive"
className="h-6 cursor-pointer px-2.5 text-xs"
disabled={busy}
onClick={() => {
for (const link of revoking.links) revoke({ linkId: link.id });
setRevoking(null);
}}
>
{revoking.links.length > 1 ? 'Revoke them' : 'Revoke link'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 cursor-pointer px-2.5 text-xs"
onClick={() => setRevoking(null)}
>
Cancel
</Button>
</FormActions>
</div>
) : null}
{error ? <FormError className="mx-3.5 mb-2">{error}</FormError> : null}
</div>
);
}