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, followUser, unfollowUser, buildCSRFHeaders, } from "./ash_rpc"; import { uploadFile } from "./upload"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } }, }); // ── Types ────────────────────────────────────────────────────────────────────── type User = { id: string; email: string; followerCount?: number; followingCount?: number; amIFollowing?: boolean; myFollowId?: string | null; }; 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(insertedAt?: string | null): string { if (!insertedAt) return "just now"; const now = Date.now(); const then = new Date(insertedAt).getTime(); const diffSec = Math.floor((now - then) / 1000); if (diffSec < 5) return "just now"; if (diffSec < 60) return `${diffSec}s`; const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return `${diffMin}m`; const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return `${diffHr}h`; const diffDay = Math.floor(diffHr / 24); if (diffDay < 7) return `${diffDay}d`; return new Date(insertedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function getAssetHost(): string { const appEl = document.getElementById("app"); return appEl?.dataset.assetHost ?? "http://localhost:9000"; } // ── Context menu ────────────────────────────────────────────────────────────── type ContextMenuItem = | { type: "item"; label: string; onClick: () => void } | { type: "separator" }; function ContextMenu({ x, y, items, onClose, }: { x: number; y: number; items: ContextMenuItem[]; onClose: () => void; }) { const ref = useRef(null); useEffect(() => { function handleMouseDown(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); } function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } document.addEventListener("mousedown", handleMouseDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("keydown", handleKeyDown); }; }, [onClose]); const itemCount = items.filter((i) => i.type === "item").length; const sepCount = items.filter((i) => i.type === "separator").length; const menuH = itemCount * 34 + sepCount * 9 + 8; const menuW = 180; const left = Math.min(x, window.innerWidth - menuW - 8); const top = Math.min(y, window.innerHeight - menuH - 8); return createPortal(
e.preventDefault()} > {items.map((item, i) => item.type === "separator" ? (
) : ( ) )}
, document.body ); } // ── 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