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