import React, { useState, useRef, useEffect, useContext } from "react"; import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc"; import { AuthCtx } from "../context"; import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants"; import { userDisplayLabel } from "../utils"; import { useFollowUser } from "../hooks"; import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui"; import { TweetCard } from "./tweet-card"; import type { User, Tweet, ContextMenuItem } from "../types"; export function FollowButton({ amIFollowing, isPending, onToggle, }: { amIFollowing: boolean; isPending: boolean; onToggle: () => void; }) { return ( ); } export function UserCard({ user }: { user: User }) { const { userId: currentUserId } = useContext(AuthCtx); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const { follow, unfollow, isPending } = useFollowUser(user.id); const userUrl = `${window.location.origin}/users/${user.id}`; const canFollow = !!currentUserId && currentUserId !== user.id; const amIFollowing = user.amIFollowing ?? false; const ctxItems: ContextMenuItem[] = [ { type: "item", label: "Share", onClick: () => navigator.clipboard.writeText(userUrl) }, ...(canFollow ? [ { type: "separator" as const }, amIFollowing ? { type: "item" as const, label: "Unfollow", onClick: unfollow } : { type: "item" as const, label: "Follow", onClick: follow }, ] : []), ]; return (
{ window.location.href = `/users/${user.id}`; }} onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }} >
{userDisplayLabel(user)} {user.username && ( @{user.username} )}
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
{user.followerCount ?? 0} followers {user.followingCount ?? 0} following
)}
{canFollow && (
)} {ctxMenu && ( setCtxMenu(null)} /> )}
); } export function UserList() { const sentinelRef = useRef(null); const { data, isLoading, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ["users"], queryFn: async ({ pageParam }: { pageParam: number }) => { const res = await readUser({ fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"], sort: "username", page: { limit: USERS_PAGE_SIZE, offset: pageParam }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load users"); const pageData = res.data as any; const users: User[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []); const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false); return { users, hasMore, nextOffset: pageParam + USERS_PAGE_SIZE }; }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined, }); useEffect(() => { const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); if (isLoading) return ; if (isError) return ; const users = data?.pages.flatMap((p) => p.users) ?? []; if (users.length === 0) { return (

No users yet

Be the first to sign up.

); } return (
{users.map((u) => ( ))}
{isFetchingNextPage && }
); } export function UserFeed({ userId }: { userId: string }) { const sentinelRef = useRef(null); const { data, isLoading, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ["user-tweets", userId], queryFn: async ({ pageParam }: { pageParam: number }) => { const res = await readTweet({ fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }], sort: "-insertedAt", page: { limit: FEED_PAGE_SIZE, offset: pageParam }, filter: { userId: { eq: userId }, parentTweetId: { isNil: true } }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load tweets"); const pageData = res.data as any; const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []); const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false); return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE }; }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined, }); useEffect(() => { const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); if (isLoading) return ; if (isError) return ; const tweets = data?.pages.flatMap((p) => p.tweets) ?? []; if (tweets.length === 0) { return (

No posts yet

); } return (
{tweets.map((t) => ( ))}
{isFetchingNextPage && }
); } export function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) { const { userId: currentUserId } = useContext(AuthCtx); const { follow, unfollow, isPending } = useFollowUser(userId); const { data: user, isLoading, isError } = useQuery({ queryKey: ["user", userId], queryFn: async () => { const res = await readUser({ fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"], filter: { id: { eq: userId } }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load user"); const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; return (results[0] as User) ?? null; }, }); if (isLoading) return ; if (isError || !user) return ; const isOwnProfile = currentUserId === userId; const canFollow = !!currentUserId && !isOwnProfile; const amIFollowing = user.amIFollowing ?? false; return (
{!isStandalone && ( )}
{userDisplayLabel(user)}
{user.username && (
@{user.username}
)}
{canFollow && ( )}
{user.followerCount ?? 0} followers {user.followingCount ?? 0} following
); }