diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index d638ba6..d7de9ad 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -843,6 +843,73 @@ export async function validateLikeTweet( } +export type ReadFollowingFeedFields = UnifiedFieldSelection[]; +export type InferReadFollowingFeedResult< + Fields extends ReadFollowingFeedFields, +> = Array>; + +export type ReadFollowingFeedResult = | { success: true; data: InferReadFollowingFeedResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Read Tweet records + * + * @ashActionType :read + */ +export async function readFollowingFeed( + config: { + tenant?: string; + fields: Fields; + filter?: tweetsFilterInput; + sort?: SortString | SortString[]; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "read_following_feed", + ...(config.tenant !== undefined && { tenant: config.tenant }), + ...(config.fields !== undefined && { fields: config.fields }), + ...(config.filter && { filter: config.filter }), + ...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Read Tweet records + * + * @ashActionType :read + * @validation true + */ +export async function validateReadFollowingFeed( + config: { + tenant?: string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "read_following_feed", + ...(config.tenant !== undefined && { tenant: config.tenant }) + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type ReadTweetFields = UnifiedFieldSelection[]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index bf2465c..52c39c1 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -12,6 +12,7 @@ import { import { createTweet, readTweet, + readFollowingFeed, destroyTweet, likeTweet, unlikeTweet, @@ -224,6 +225,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) { }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); setText(""); setError(null); setMediaId(null); @@ -477,7 +479,10 @@ function TweetCard({ tweet }: { tweet: Tweet }) { }); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete"); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); + }, onError: (e: Error) => setError(e.message), }); @@ -493,6 +498,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) { }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); setEditing(false); setError(null); }, @@ -511,6 +517,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) { }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["tweets"] }); + qc.invalidateQueries({ queryKey: ["following_tweets"] }); setError(null); }, onError: (e: Error) => setError(e.message), @@ -864,6 +871,100 @@ function TweetDetail({ tweetId }: { tweetId: string }) { const FEED_PAGE_SIZE = 10; +function FollowingFeed() { + const { userId } = useContext(AuthCtx); + const sentinelRef = useRef(null); + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["following_tweets"], + queryFn: async ({ pageParam }: { pageParam: number }) => { + const res = await readFollowingFeed({ + fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], + sort: "-insertedAt", + page: { limit: FEED_PAGE_SIZE, offset: pageParam }, + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error("Failed to load following feed"); + 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, + enabled: !!userId, + }); + + 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 (!userId) { + return ( +
+
+

Your personalised feed

+

+ Sign in + {" "}to see posts from people you follow. +

+
+ ); + } + + if (isLoading) return ; + if (isError) { + return ( + + ); + } + + const tweets = data?.pages.flatMap((p) => p.tweets) ?? []; + + if (tweets.length === 0) { + return ( +
+
+

Nothing here yet

+

+ Follow some people from the{" "} + Users + {" "}page to fill this feed. +

+
+ ); + } + + return ( +
+ {tweets.map((t) => ( + + ))} +
+ {isFetchingNextPage && } +
+ ); +} + function Feed() { const sentinelRef = useRef(null); @@ -941,12 +1042,12 @@ function Feed() { ); } -function RefreshButton() { +function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) { const qc = useQueryClient(); const [spinning, setSpinning] = useState(false); async function refresh() { setSpinning(true); - await qc.invalidateQueries({ queryKey: ["tweets"] }); + await qc.invalidateQueries({ queryKey }); setTimeout(() => setSpinning(false), 600); } return ( @@ -1173,6 +1274,7 @@ function MobileNav({ onCompose: () => void; }) { const onFeedPage = page === "feed" || page === "tweet"; + const onFollowingPage = page === "following"; const onUsersPage = page === "users" || page === "user-detail"; return ( @@ -1187,6 +1289,16 @@ function MobileNav({ Feed + + + + + Following + +