/** * The modal that posts a dump from a link or a file, then offers to add it to * the poster's playlists. Every "new dump" button opens it. * * @module */ import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../../../config/api.ts"; import { DUMP_MAX_FILE_SIZE_BYTES, VALIDATION, } from "../../../../shared/validation.ts"; import type { CreateUrlDumpRequest, Dump, DumpUrlMatch, LinkPreview, RichContent, } from "../../../../shared/dump.ts"; import type { PlaylistMembership } from "../../../../shared/playlist.ts"; import type { Wire } from "../../../../shared/api.ts"; import { deserializeDump, deserializeDumpUrlMatch, deserializePlaylistMembership, } from "../../../utils/shared/deserialize.ts"; import { useAuth } from "../../../hooks/auth/useAuth.ts"; import { useWS } from "../../../hooks/ws/useWS.ts"; import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts"; import { formatBytes, titleFromFileName, } from "../../../utils/shared/format.ts"; import { clearDumpDraft, type DumpDraft, EMPTY_DUMP_DRAFT, loadDumpDraft, saveDumpDraft, } from "../../../utils/dump/draft.ts"; import { RichContentCard } from "../RichContentCard.tsx"; import { Modal } from "../../shared/Modal.tsx"; import type { TextEditorHandle } from "../../editor/TextEditor.tsx"; import { CategorySelect } from "../../category/CategorySelect.tsx"; import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx"; import { expectOk } from "../../../utils/shared/api.ts"; import { FileField } from "../../form/FileField.tsx"; import { FormError } from "../../form/FormError.tsx"; import { FormProvider } from "react-hook-form"; import { RichTextField } from "../../form/RichTextField.tsx"; import { SubmitButton } from "../../form/SubmitButton.tsx"; import { useApiForm } from "../../../hooks/form/useApiForm.ts"; import { VisibilityToggle } from "../../form/SegmentedField.tsx"; import { DuplicateNotice } from "./DuplicateNotice.tsx"; import { LocalFilePreview } from "./LocalFilePreview.tsx"; import { type Step, StepIndicator } from "./StepIndicator.tsx"; import { TitleField } from "./TitleField.tsx"; /** Kept stable so `DuplicateNotice` doesn't re-render on every keystroke. */ const NO_DUPLICATES: DumpUrlMatch[] = []; /** `reached` is false when the server couldn't fetch the page at all. */ type UrlPreview = | { status: "idle" } | { status: "loading" } | { status: "done"; reached: boolean; richContent: RichContent | null }; interface CreateValues { url: string; file: File | null; title: string; comment: string; isPublic: boolean; } /** * Checks whether the pointer is coarse. On a touch device, focusing a field * raises a keyboard that covers most of the modal before the user has seen it. */ function prefersNoAutoFocus(): boolean { return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false; } /** * Passed to {@link DumpCreateModal} by the button that opens it. The parent * unmounts the modal on `onClose`, which the modal also calls after a * confirmed dismissal. */ export interface DumpCreateModalProps { onClose: () => void; /** * A link shared from Android's share sheet, which `AppHeader` passes on from * `Index`. It replaces any saved draft. */ initialUrl?: string; } /** * Renders the dump creation modal. The first panel takes a link, previewed * through `GET /api/preview` and checked against `GET /api/dumps/by-url`, or a * file. The second takes the "why", categories and visibility, and posts to * `POST /api/dumps`. The third adds the new dump to playlists. The new dump is * handed to `WSContext`'s `injectDump` so the feeds show it at once. Everything * but the file is saved as a draft (`utils/dump/draft.ts`) until it is posted. */ export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, ): React.JSX.Element { const { authFetch, authUpload } = useAuth(); const { injectDump } = useWS(); const [step, setStep] = useState("what"); const [direction, setDirection] = useState<"forward" | "back">("forward"); const [createdDump, setCreatedDump] = useState(null); const goToStep = (next: Step, dir: "forward" | "back") => { setDirection(dir); setStep(next); }; // Arriving with a URL (the Android share sheet) is a request to post *that*, // so an abandoned draft is dropped whole rather than half-merged: pairing // someone's old "why" with a link they just shared from another app would be // more confusing than starting them clean. const [restoredDraft] = useState(() => initialUrl ? null : loadDumpDraft() ); const draft = restoredDraft ?? EMPTY_DUMP_DRAFT; const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft); const [titleEditing, setTitleEditing] = useState(false); const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser); const [urlFetchState, setUrlFetchState] = useState({ status: "idle", }); // Tagged with the URL they describe, so a result never lingers over the next // link during the debounce, and clearing them needs no effect. const [duplicateState, setDuplicateState] = useState< { url: string; matches: DumpUrlMatch[] } >({ url: "", matches: [] }); // Undefined when no upload runs, null once the body is sent and the server // is still processing it, else the fraction sent. const [uploadProgress, setUploadProgress] = useState(); const [confirmClose, setConfirmClose] = useState(false); const debounceRef = useRef | null>(null); const commentEditorRef = useRef(null); const form = useApiForm({ defaultValues: { url: initialUrl || draft.url, file: null, title: draft.title, comment: draft.comment, isPublic: draft.isPublic, }, }); const { register, setValue, watch, clearErrors, reset } = form; const submitting = form.formState.isSubmitting; const url = watch("url"); const file = watch("file"); const title = watch("title"); const comment = watch("comment"); const isPublic = watch("isPublic"); // The dump's kind is whatever the user actually provided, so there is no mode // to pick: attaching a file makes it a file dump, typing a link makes it a // link dump, and clearing either offers both again. const mode: "url" | "file" = file ? "file" : "url"; const [selectedCategoryIds, setSelectedCategoryIds] = useState>( () => new Set(draft.categoryIds), ); const toggleCategory = (id: string) => setSelectedCategoryIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const [memberships, setMemberships] = useState([]); const [playlistsLoading, setPlaylistsLoading] = useState(false); const selectFile = useCallback((f: File | null) => { setValue("file", f); if (f) { setValue("title", titleFromFileName(f.name)); setTitleFromUser(false); } setTitleEditing(false); }, [setValue]); // The effects below key on this rather than on the raw input, so that // "example.com" and the "https://example.com" onBlur rewrites it to don't // fetch the preview twice. let canonicalUrl: string | null; try { const u = new URL(normalizeUrl(url)); canonicalUrl = (u.protocol === "http:" || u.protocol === "https:") ? u.toString() : null; } catch { canonicalUrl = null; } // Deriving "idle" here spares the effect below from resetting the state when // the link is cleared. const urlPreview: UrlPreview = canonicalUrl ? urlFetchState : { status: "idle" }; const duplicates = canonicalUrl && duplicateState.url === canonicalUrl ? duplicateState.matches : NO_DUPLICATES; // The preview and the duplicate lookup concern the same link, so they share // one debounce. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (!canonicalUrl) return; // Deferred: the set-state-in-effect lint rule forbids setting state // synchronously in an effect. queueMicrotask(() => setUrlFetchState({ status: "loading" })); // A preview can take seconds, since the server fetches the page. An answer // for a link that has since changed must not show, or set the title. const controller = new AbortController(); const { signal } = controller; debounceRef.current = setTimeout(() => { const encoded = encodeURIComponent(canonicalUrl); authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { if (signal.aborted) return; const data = body.success ? body.data as LinkPreview : undefined; setUrlFetchState({ status: "done", reached: data?.reached ?? false, richContent: data?.richContent ?? null, }); }) .catch(() => { if (signal.aborted) return; setUrlFetchState({ status: "done", reached: false, richContent: null, }); }); // Signed in, so that the poster's own private dumps count as duplicates. authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { if (signal.aborted) return; setDuplicateState({ url: canonicalUrl, matches: body.success ? (body.data as Wire[]).map(deserializeDumpUrlMatch) : [], }); }) .catch(() => { if (!signal.aborted) { setDuplicateState({ url: canonicalUrl, matches: [] }); } }); }, 600); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); controller.abort(); }; }, [canonicalUrl, authFetch]); // Until the poster edits it, the title follows the preview. An empty // suggestion is written too, or the previous link's title would stay. const suggestedTitle = urlPreview.status === "done" ? urlPreview.richContent?.title ?? "" : ""; useEffect(() => { if (mode !== "url" || titleFromUser) return; setValue("title", suggestedTitle); }, [mode, titleFromUser, suggestedTitle, setValue]); // Once posted, the draft is cleared and must not be written back. useEffect(() => { if (step === "playlist") return; saveDumpDraft({ url, title, titleFromUser, comment, isPublic, categoryIds: [...selectedCategoryIds], }); }, [ step, url, title, titleFromUser, comment, isPublic, selectedCategoryIds, ]); const discardDraft = () => { clearDumpDraft(); reset({ url: "", file: null, title: "", comment: "", isPublic: true, }); setSelectedCategoryIds(new Set()); setTitleFromUser(false); setTitleEditing(false); setDraftNoticeVisible(false); }; // Not on touch, where the keyboard would cover the categories. useEffect(() => { if (step !== "details" || prefersNoAutoFocus()) return; commentEditorRef.current?.focus(); }, [step]); // Only the attached file is lost on close, since the draft keeps the rest, // so only a file makes the modal ask first. const handleBeforeClose = useCallback(() => { if (step === "playlist" || submitting || !file || confirmClose) return true; setConfirmClose(true); return false; }, [step, submitting, file, confirmClose]); // Cancel asks first too, like Escape and the ✕. const requestClose = () => { if (handleBeforeClose()) onClose(); }; // A paste anywhere in the page takes a file or a link. useEffect(() => { const handler = (e: ClipboardEvent) => { // Fields handle their own paste: the "why" editor uploads a pasted image // as an attachment, and the link field has its own handler below. const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; const pastedFile = e.clipboardData?.files[0]; if (pastedFile) { setValue("url", ""); selectFile(pastedFile); clearErrors("root"); return; } const text = e.clipboardData?.getData("text") ?? ""; try { const u = new URL(normalizeUrl(text)); if (u.protocol === "http:" || u.protocol === "https:") { selectFile(null); setValue("url", u.toString()); clearErrors("root"); } } catch { /* not a URL */ } }; globalThis.addEventListener("paste", handler); return () => globalThis.removeEventListener("paste", handler); }, [selectFile, setValue, clearErrors]); const [dragging, setDragging] = useState(false); // The whole modal takes a drop, since the drop zone is hidden while a link // is typed. const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); if (submitting) return; const dropped = e.dataTransfer.files[0]; if (dropped) { setValue("url", ""); selectFile(dropped); clearErrors("root"); } }; const onSubmit = form.submit(async ( { url, file, title, comment, isPublic }, ) => { const isPrivate = !isPublic; let res: Response; if (!file) { const normalizedUrl = normalizeUrl(url); if (!normalizedUrl) throw new Error(t`URL is required.`); setValue("url", normalizedUrl); const body: CreateUrlDumpRequest = { url: normalizedUrl, // Left to the server unless typed, so it takes the page's title // itself. title: titleFromUser && title.trim() ? title.trim() : undefined, comment: comment.trim() || undefined, isPrivate, categoryIds: [...selectedCategoryIds], }; res = await authFetch(`${API_URL}/api/dumps`, { method: "POST", body: JSON.stringify(body), }); } else { if (!title.trim()) throw new Error(t`Title is required.`); if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { const max = formatBytes(DUMP_MAX_FILE_SIZE_BYTES); throw new Error(t`File too large (max ${max}).`); } const formData = new FormData(); formData.append("file", file); if (comment.trim()) formData.append("comment", comment.trim()); if (title.trim()) formData.append("title", title.trim()); formData.append("isPrivate", String(isPrivate)); formData.append("categoryIds", JSON.stringify([...selectedCategoryIds])); setUploadProgress(0); try { res = await authUpload( `${API_URL}/api/dumps`, formData, setUploadProgress, ); } finally { setUploadProgress(undefined); } } const dump = deserializeDump(await expectOk>(res)); clearDumpDraft(); injectDump(dump); setCreatedDump(dump); goToStep("playlist", "forward"); setPlaylistsLoading(true); authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`) .then((r) => r.json()) .then((body) => { if (body.success) { setMemberships( (body.data as Wire[]).map( deserializePlaylistMembership, ), ); } }) .catch(() => {}) .finally(() => setPlaylistsLoading(false)); }); const toggleMembership = async (membership: PlaylistMembership) => { if (!createdDump) return; const { playlist, hasDump } = membership; // Not optimistic: the box changes only once the server agrees. try { const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, { method: hasDump ? "DELETE" : "POST" }, ); if (!res.ok) return; } catch { return; } setMemberships((prev) => prev.map((m) => m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m ) ); }; const canProceed = !!file || !!canonicalUrl; const recap = title.trim() || file?.name || normalizeUrl(url); const uploadPercentLabel = t`Uploading ${ Math.round((uploadProgress ?? 0) * 100) }%`; // Enter on the first panel moves on to the second rather than posting. const onFormSubmit = (e: React.FormEvent) => { if (step === "what") { e.preventDefault(); if (canProceed) goToStep("details", "forward"); return; } void onSubmit(e); }; return (
{ e.preventDefault(); if (!submitting && step !== "playlist") setDragging(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDragging(false); } }} onDrop={handleDrop} > {confirmClose && (

Close without posting? The attached file won't be kept — everything you typed will.

)} {draftNoticeVisible && step === "what" && (
Picked up where you left off.
)} {step === "playlist" ? (
{createdDump && (

Dumped!{" "} View dump →

)} setMemberships((prev) => [membership, ...prev])} />
) : (
{/* Keyed so a new panel remounts and plays its slide-in. */}
{step === "what" ? ( <> {mode === "url" && ( <>
setValue("url", normalizeUrl(e.target.value))} onPaste={(e) => { const pastedFile = e.clipboardData.files[0]; if (pastedFile) { e.preventDefault(); setValue("url", ""); selectFile(pastedFile); clearErrors("root"); } }} disabled={submitting} placeholder={t`Paste a link…`} autoFocus={!prefersNoAutoFocus()} />
{urlPreview.status === "loading" && (

Fetching preview…

)} {urlPreview.status === "done" && ( <> {urlPreview.richContent && ( )} {!urlPreview.reached && (

Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.

)} )} {duplicates.length > 0 && ( )} {urlPreview.status === "done" && ( { setTitleEditing(true); setTitleFromUser(true); }} onReset={titleFromUser && suggestedTitle ? () => { setTitleFromUser(false); setTitleEditing(false); setValue("title", suggestedTitle); } : undefined} disabled={submitting} /> )} )} { /* An empty panel offers both a link and a file. */ } {!(mode === "url" && url.trim()) && ( name="file" onValueChange={selectFile} disabled={submitting} hint={t`Drop a file here, or paste one`} maxBytes={DUMP_MAX_FILE_SIZE_BYTES} /> )} {file && ( <> { setTitleEditing(true); setTitleFromUser(true); }} disabled={submitting} /> )} ) : ( <> { /* The link or file is a panel back, so recall it. */ }

{" "} {recap}

{uploadProgress !== undefined && (
)} name="comment" label={t`Why?`} placeholder={t`What makes it worth it?`} rows={3} maxLength={VALIDATION.DUMP_COMMENT_MAX} disabled={submitting} editorRef={commentEditorRef} /> name="isPublic" label={t`Who can see it`} disabled={submitting} /> )}
{step === "details" && ( )}
{step === "what" && ( )} Posting… : typeof uploadProgress === "number" ? uploadPercentLabel : Uploading…} > {step === "what" ? Next → : Dump it}
)}
); }