import { useCallback, useEffect, useRef, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import type {
CreateUrlDumpRequest,
Dump,
DumpUrlMatch,
PlaylistMembership,
RawDump,
RawDumpUrlMatch,
RawPlaylistMembership,
UrlPreviewResponse,
} from "../model.ts";
import {
deserializeDump,
deserializeDumpUrlMatch,
deserializePlaylistMembership,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { dumpUrl, normalizeUrl } from "../utils/urls.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { titleFromFileName } from "../utils/format.ts";
import {
clearDumpDraft,
type DumpDraft,
EMPTY_DUMP_DRAFT,
loadDumpDraft,
saveDumpDraft,
} from "../utils/dumpDraft.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
import { Modal } from "./Modal.tsx";
import type { TextEditorHandle } from "./TextEditor.tsx";
import { CategorySelect } from "./CategorySelect.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import {
expectOk,
FileField,
FormError,
FormProvider,
RichTextField,
SubmitButton,
useApiForm,
VisibilityToggle,
} from "./form/index.ts";
/**
* Posting a dump walks three panels. The split is deliberate: categories and
* visibility used to sit behind a disclosure, where they read as optional and
* went untouched — they are neither, so "why & where" is a stop on the way to
* posting rather than something to go looking for.
*/
type Step = "what" | "details" | "playlist";
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
const NO_DUPLICATES: DumpUrlMatch[] = [];
const STEPS: { id: Step; label: () => string }[] = [
{ id: "what", label: () => t`Link or file` },
{ id: "details", label: () => t`Why & where` },
{ id: "playlist", label: () => t`Playlists` },
];
/** Where you are in the three panels — and, past the first, how far you got. */
function StepIndicator({ current }: { current: Step }) {
const currentIndex = STEPS.findIndex((s) => s.id === current);
return (
{STEPS.map((step, i) => {
const state = i < currentIndex
? "done"
: i === currentIndex
? "current"
: "todo";
return (
{state === "done" ? "✓" : i + 1}
{step.label()}
);
})}
);
}
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;
}
/**
* Whether the pointer is coarse — a touch device, where autofocusing the URL
* input 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;
}
function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState(null);
useEffect(() => {
const url = URL.createObjectURL(file);
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrc(url);
return () => {
URL.revokeObjectURL(url);
};
}, [file]);
if (!src) return null;
const mime = file.type;
if (mime.startsWith("image/")) {
return ;
}
if (mime.startsWith("video/")) {
return ;
}
if (mime.startsWith("audio/")) {
return ;
}
return null;
}
/**
* Title row shared by both kinds of dump: the suggested title (scraped from the
* page, or derived from the filename) sits there read-only until the ✎ is used,
* which is enough to stop stray typing while keeping a correction one click
* away. Once edited, a reset offers the suggestion back.
*/
function TitleField({
registration,
editing,
onEdit,
onReset,
disabled,
}: {
registration: UseFormRegisterReturn<"title">;
editing: boolean;
onEdit: () => void;
onReset?: () => void;
disabled: boolean;
}) {
const inputRef = useRef(null);
const { ref, ...titleReg } = registration;
return (
);
}
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
function DuplicateNotice(
{ matches, onNavigate }: {
matches: DumpUrlMatch[];
onNavigate: () => void;
},
) {
const [first, ...rest] = matches;
return (
↩
Already dumped by {first.username} {relativeTime(first.createdAt)}
{first.title}
{rest.length > 0 && (
)}
You can post it anyway.
);
}
interface DumpCreateModalProps {
onClose: () => void;
initialUrl?: string;
}
export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
const { authFetch, authUpload } = useAuth();
const { injectDump } = useWS();
const [step, setStep] = useState("what");
// Which way the next panel should come in from. Purely presentational.
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);
// Composition state
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 so clearing them needs no effect).
const [duplicateState, setDuplicateState] = useState<
{ url: string; matches: DumpUrlMatch[] }
>({ url: "", matches: [] });
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;
});
// Playlist step state
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]);
// Canonical form of `url`, used (rather than the raw input text) to decide
// whether the preview actually needs to re-fetch — e.g. "example.com" and
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
// to the same value, so they shouldn't trigger two separate fetches.
let canonicalUrl: string | null;
try {
const u = new URL(normalizeUrl(url));
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
? u.toString()
: null;
} catch {
canonicalUrl = null;
}
// Idle state is derived below from canonicalUrl rather than set here, so
// this effect only ever needs to set loading/done (inside the timeout).
const urlPreview: UrlPreview = canonicalUrl
? urlFetchState
: { status: "idle" };
const duplicates = canonicalUrl && duplicateState.url === canonicalUrl
? duplicateState.matches
: NO_DUPLICATES;
// Debounced preview + "already dumped?" lookup. Both answer questions about
// the same link and settle at the same time, so they share one debounce.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!canonicalUrl) return;
// Deferred to a microtask (rather than called directly) so the immediate
// "loading" feedback doesn't fire synchronously within the effect body —
// same timing for the user, just not in the same tick.
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl);
fetch(`${API_URL}/api/preview?url=${encoded}`)
.then((res) => res.json())
.then((body) => {
const data = body.success
? body.data as UrlPreviewResponse
: undefined;
setUrlFetchState({
status: "done",
reached: data?.reached ?? false,
richContent: data?.richContent ?? null,
});
})
.catch(() => {
setUrlFetchState({ status: "done", reached: false, richContent: null });
});
// Signed in, so that a private dump of the poster's own counts as a
// duplicate for them and for nobody else.
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`)
.then((res) => res.json())
.then((body) => {
setDuplicateState({
url: canonicalUrl,
matches: body.success
? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch)
: [],
});
})
.catch(() =>
setDuplicateState({ url: canonicalUrl, matches: [] })
);
}, 600);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [canonicalUrl, authFetch]);
// Until the poster takes the field over, the title *is* the current page's
// title — including when the new link suggests nothing, which is why this
// writes an empty suggestion too rather than leaving the previous link's.
const suggestedTitle = urlPreview.status === "done"
? urlPreview.richContent?.title ?? ""
: "";
useEffect(() => {
if (mode !== "url" || titleFromUser) return;
setValue("title", suggestedTitle);
}, [mode, titleFromUser, suggestedTitle, setValue]);
// Mirror the draft on every change so a dismissal — deliberate or not — never
// costs more than the attached file, which cannot be stored.
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);
};
// Land the caret in the "why" box on arrival, so the second panel is
// immediately typeable — except on touch, where it would raise a keyboard
// over the categories this panel exists to show.
useEffect(() => {
if (step !== "details" || prefersNoAutoFocus()) return;
commentEditorRef.current?.focus();
}, [step]);
// Only the attached file is unrecoverable, so it is the only thing worth
// interrupting a dismissal for — the text comes back on its own.
const handleBeforeClose = useCallback(() => {
if (step === "playlist" || submitting || !file || confirmClose) return true;
setConfirmClose(true);
return false;
}, [step, submitting, file, confirmClose]);
/** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */
const requestClose = () => {
if (handleBeforeClose()) onClose();
};
// Paste handler
useEffect(() => {
const handler = (e: ClipboardEvent) => {
// Fields own their paste: the comment editor uploads pasted images as
// inline attachments, and the URL input has its own file handler below.
// Without this guard, pasting an image into the description would flip
// the whole modal over to a file dump.
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);
// Dropping is accepted over the whole modal, not just the drop zone: once a
// link is being typed the zone is hidden, and aiming at it was never the
// point of dragging a file into a "new dump" dialog.
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,
// Only when the poster typed it: otherwise the server would be pinned
// to whatever the preview happened to scrape a moment ago.
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 > MAX_FILE_SIZE) {
throw new Error(t`File too large (max 50 MB).`);
}
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 RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
});
const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return;
const { playlist, hasDump } = membership;
if (hasDump) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "DELETE" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
);
}
};
const canProceed = !!file || !!canonicalUrl;
// What the second panel's choices apply to, in one line.
const recap = title.trim() || file?.name || normalizeUrl(url);
const uploadPercentLabel = t`Uploading ${
Math.round((uploadProgress ?? 0) * 100)
}%`;
// Enter in the link field advances rather than posts, so the panels behave
// like a form the whole way through.
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.