paginating comments and letting tweet authors delete comments on their post

This commit is contained in:
2026-04-06 14:15:36 -04:00
parent faa96d88f5
commit 109ef398ee
2 changed files with 54 additions and 17 deletions

View File

@@ -813,21 +813,49 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
}, },
}); });
const { data: comments, isLoading: commentsLoading } = useQuery({ const commentsSentinelRef = useRef<HTMLDivElement>(null);
const {
data: commentsData,
isLoading: commentsLoading,
fetchNextPage: fetchNextComments,
hasNextPage: hasMoreComments,
isFetchingNextPage: isFetchingMoreComments,
} = useInfiniteQuery({
queryKey: ["comments", tweetId], queryKey: ["comments", tweetId],
queryFn: async () => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
filter: { parentTweetId: { eq: tweetId } }, filter: { parentTweetId: { eq: tweetId } },
sort: "insertedAt", sort: "insertedAt",
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load comments"); if (!res.success) throw new Error("Failed to load comments");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; const pageData = res.data as any;
return results as Tweet[]; 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({ const deleteMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() }); const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
@@ -1006,26 +1034,33 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
{commentsLoading ? ( {commentsLoading ? (
<Spinner /> <Spinner />
) : comments && comments.length > 0 ? ( ) : (() => {
<div className="mx-comments-list"> const comments = commentsData?.pages.flatMap((p) => p.comments) ?? [];
{comments.map((c) => ( return comments.length > 0 ? (
<CommentCard key={c.id} comment={c} /> <div className="mx-comments-list">
))} {comments.map((c) => (
</div> <CommentCard key={c.id} comment={c} parentTweetOwnerId={tweet?.userId} />
) : ( ))}
<div className="mx-empty mx-empty--sm"> <div ref={commentsSentinelRef} style={{ height: "1px" }} />
<p className="mx-empty-sub">No replies yet. Be the first!</p> {isFetchingMoreComments && <Spinner />}
</div> </div>
)} ) : (
<div className="mx-empty mx-empty--sm">
<p className="mx-empty-sub">No replies yet. Be the first!</p>
</div>
);
})()}
</div> </div>
</div> </div>
); );
} }
function CommentCard({ comment }: { comment: Tweet }) { function CommentCard({ comment, parentTweetOwnerId }: { comment: Tweet; parentTweetOwnerId?: string }) {
const { userId: currentUserId } = useContext(AuthCtx); const { userId: currentUserId } = useContext(AuthCtx);
const canLike = !!currentUserId; const canLike = !!currentUserId;
const canModify = !!currentUserId && comment.userId === currentUserId; const canModify = !!currentUserId && (
comment.userId === currentUserId || parentTweetOwnerId === currentUserId
);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const qc = useQueryClient(); const qc = useQueryClient();
@@ -1116,6 +1151,7 @@ function CommentCard({ comment }: { comment: Tweet }) {
} }
const FEED_PAGE_SIZE = 10; const FEED_PAGE_SIZE = 10;
const COMMENTS_PAGE_SIZE = 10;
function FollowingFeed() { function FollowingFeed() {
const { userId } = useContext(AuthCtx); const { userId } = useContext(AuthCtx);

View File

@@ -140,6 +140,7 @@ defmodule Mixer.Posts.Tweet do
policy action(:destroy) do policy action(:destroy) do
authorize_if relates_to_actor_via(:user) authorize_if relates_to_actor_via(:user)
authorize_if relates_to_actor_via([:parent_tweet, :user])
end end
policy action(:like) do policy action(:like) do