import React, { createContext, useContext, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; import { createPortal } from "react-dom"; import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient, } from "@tanstack/react-query"; import { createTweet, readTweet, destroyTweet, likeTweet, unlikeTweet, updateTweet, readUser, buildCSRFHeaders, } from "./ash_rpc"; import { uploadFile } from "./upload"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } }, }); // ── Types ────────────────────────────────────────────────────────────────────── type User = { id: string; email: string }; type MediaItem = { id: string; s3Key: string }; type Tweet = { id: string; content: string; likes: number; likedByMe?: boolean; userId: string; state: string; media?: MediaItem[]; userEmail?: string | null; insertedAt?: string | null; }; // ── Auth context ─────────────────────────────────────────────────────────────── const AuthCtx = createContext({ email: "", userId: "" }); // ── Helpers ──────────────────────────────────────────────────────────────────── function timeAgo(): string { return "just now"; } function getAssetHost(): string { const appEl = document.getElementById("app"); return appEl?.dataset.assetHost ?? "http://localhost:9000"; } // ── Components ───────────────────────────────────────────────────────────────── function Spinner() { return (
); } function ErrorBanner({ message }: { message: string }) { return (
{message}
); } function CharCount({ current, max }: { current: number; max: number }) { const remaining = max - current; const pct = current / max; const color = pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)"; return ( {remaining} ); } function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { const [text, setText] = useState(""); const [error, setError] = useState(null); const [pendingFile, setPendingFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [mediaId, setMediaId] = useState(null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const qc = useQueryClient(); const MAX = 280; const mutation = useMutation({ mutationFn: async (content: string) => { const res = await createTweet({ input: { content, mediaId: mediaId ?? undefined }, fields: ["id", "content", "userId", "state"], headers: buildCSRFHeaders(), }); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed"); return res.data; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["tweets"] }); setText(""); setError(null); setMediaId(null); setPendingFile(null); setUploadError(null); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null); } onSuccess?.(); }, onError: (e: Error) => setError(e.message), }); async function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; // Reset the input so the same file can be re-selected after removal e.target.value = ""; // Revoke any previous object URL to avoid memory leaks if (previewUrl) URL.revokeObjectURL(previewUrl); const localUrl = URL.createObjectURL(file); setPendingFile(file); setPreviewUrl(localUrl); setMediaId(null); setUploadError(null); setUploading(true); const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string; const result = await uploadFile(file, csrfToken); setUploading(false); if ("error" in result) { setUploadError(result.error); setPendingFile(null); URL.revokeObjectURL(localUrl); setPreviewUrl(null); } else { setMediaId(result.mediaId); } } function removeAttachment() { if (previewUrl) URL.revokeObjectURL(previewUrl); setPendingFile(null); setPreviewUrl(null); setMediaId(null); setUploadError(null); } function handleKeyDown(e: React.KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); submit(); } } function submit() { const trimmed = text.trim(); if (!trimmed) return; if (trimmed.length > MAX) { setError(`Max ${MAX} characters`); return; } setError(null); mutation.mutate(trimmed); } // Auto-resize textarea useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }, [text]); return (
M