import React, { useState, useRef, useEffect, useContext } from "react"; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { readTweet, destroyTweet, updateTweet, likeTweet, unlikeTweet, buildCSRFHeaders } from "../ash_rpc"; import { AuthCtx } from "../context"; import { getAssetHost, userDisplayLabel } from "../utils"; import { COMMENTS_PAGE_SIZE } from "../constants"; import { Spinner, ErrorBanner, Avatar } from "./ui"; import { MediaLightbox } from "./media"; import { CommentIcon, CommentCard } from "./tweet-card"; import { ComposeComment } from "./compose"; import type { Tweet, MediaItem } from "../types"; export function TweetDetail({ tweetId }: { tweetId: string }) { const { userId: currentUserId, email } = useContext(AuthCtx); const [lightboxItem, setLightboxItem] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(""); const [error, setError] = useState(null); const qc = useQueryClient(); const assetHost = getAssetHost(); const { data: tweet, isLoading, isError } = useQuery({ queryKey: ["tweet", tweetId], queryFn: async () => { const res = await readTweet({ fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }], filter: { id: { eq: tweetId } }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load tweet"); const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; return (results[0] as Tweet) ?? null; }, }); const commentsSentinelRef = useRef(null); const { data: commentsData, isLoading: commentsLoading, fetchNextPage: fetchNextComments, hasNextPage: hasMoreComments, isFetchingNextPage: isFetchingMoreComments, } = useInfiniteQuery({ queryKey: ["comments", tweetId], queryFn: async ({ pageParam }: { pageParam: number }) => { const res = await readTweet({ fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }], filter: { parentTweetId: { eq: tweetId } }, sort: "insertedAt", page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load comments"); const pageData = res.data as any; const comments: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []); const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false); return { comments, hasMore, nextOffset: pageParam + COMMENTS_PAGE_SIZE }; }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined, }); useEffect(() => { const el = commentsSentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMoreComments && !isFetchingMoreComments) { fetchNextComments(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [hasMoreComments, isFetchingMoreComments, fetchNextComments]); const deleteMutation = useMutation({ mutationFn: async () => { const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() }); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete"); }, onSuccess: () => { window.location.href = "/feed"; }, onError: (e: Error) => setError(e.message), }); const updateMutation = useMutation({ mutationFn: async (content: string) => { const res = await updateTweet({ identity: tweetId, input: { content }, fields: ["id", "content", "userId", "state"], headers: buildCSRFHeaders(), }); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update"); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["tweet", tweetId] }); setEditing(false); setError(null); }, onError: (e: Error) => setError(e.message), }); const likeMutation = useMutation({ mutationFn: async () => { if (!tweet) return; const action = tweet.likedByMe ? unlikeTweet : likeTweet; const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() }); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like"); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }), onError: (e: Error) => setError(e.message), }); if (isLoading) return ; if (isError || !tweet) return ; const canModify = !!currentUserId && tweet.userId === currentUserId; const canLike = !!currentUserId; return (
Back {canModify && (
)}
{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })} {tweet.userUsername && (
@{tweet.userUsername}
)}
{editing ? (