From 0883da1a4296a01fdc1c7cb6415262e311d38029 Mon Sep 17 00:00:00 2001 From: rimar1337 <132627503+rimar1337@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:12:27 +0700 Subject: [PATCH] profile tabs --- src/routes/notifications.tsx | 107 +------ src/routes/profile.$did/index.tsx | 504 +++++++++++++++++++++++++----- src/utils/useQuery.ts | 10 +- 3 files changed, 450 insertions(+), 171 deletions(-) diff --git a/src/routes/notifications.tsx b/src/routes/notifications.tsx index 1a2fbf5..e58b5c9 100644 --- a/src/routes/notifications.tsx +++ b/src/routes/notifications.tsx @@ -1,13 +1,12 @@ import { AtUri } from "@atproto/api"; -import * as TabsPrimitive from "@radix-ui/react-tabs"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useAtom } from "jotai"; import * as React from "react"; -import { useEffect, useLayoutEffect } from "react"; import defaultpfp from "~/../public/favicon.png"; import { Header } from "~/components/Header"; +import { ReusableTabRoute, useReusableTabScrollRestore } from "~/components/ReusableTabRoute"; import { MdiCardsHeartOutline, MdiCommentOutline, @@ -18,8 +17,6 @@ import { useAuth } from "~/providers/UnifiedAuthProvider"; import { constellationURLAtom, imgCDNAtom, - isAtTopAtom, - notificationsScrollAtom, } from "~/utils/atoms"; import { useInfiniteQueryAuthorFeed, @@ -55,79 +52,15 @@ export const Route = createFileRoute("/notifications")({ }); export default function NotificationsTabs() { - const [notifState, setNotifState] = useAtom(notificationsScrollAtom); - const activeTab = notifState.activeTab; - const [isAtTop] = useAtom(isAtTopAtom); - - const handleValueChange = (newTab: string) => { - console.log(newTab); - setNotifState((prev) => { - const wow = { - ...prev, - scrollPositions: { - ...prev.scrollPositions, - [prev.activeTab]: window.scrollY, - }, - activeTab: newTab, - }; - //console.log(wow); - return wow; - }); - }; - - useLayoutEffect(() => { - return () => { - setNotifState((prev) => { - const wow = { - ...prev, - scrollPositions: { - ...prev.scrollPositions, - [activeTab]: window.scrollY, - }, - }; - //console.log(wow); - return wow; - }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( - - - - Mentions - - - Follows - - - Post Interactions - - - - - {activeTab === "mentions" && } - - - - {activeTab === "follows" && } - - - - {activeTab === "postInteractions" && } - - + , + Follows: , + "Post Interactions": , + }} + /> ); } @@ -169,12 +102,8 @@ function MentionsTab() { ); }, [infiniteMentionsData]); - const [notifState] = useAtom(notificationsScrollAtom); - const activeTab = notifState.activeTab; - useEffect(() => { - const savedY = notifState.scrollPositions[activeTab] ?? 0; - window.scrollTo(0, savedY); - }, [activeTab, notifState.scrollPositions]); + + useReusableTabScrollRestore("Notifications"); if (isLoading) return ; if (isError) return ; @@ -238,12 +167,7 @@ function FollowsTab() { ); }, [infiniteFollowsData]); - const [notifState] = useAtom(notificationsScrollAtom); - const activeTab = notifState.activeTab; - useEffect(() => { - const savedY = notifState.scrollPositions[activeTab] ?? 0; - window.scrollTo(0, savedY); - }, [activeTab, notifState.scrollPositions]); + useReusableTabScrollRestore("Notifications"); if (isLoading) return ; if (isError) return ; @@ -298,12 +222,7 @@ function PostInteractionsTab() { [postsData] ); - const [notifState] = useAtom(notificationsScrollAtom); - const activeTab = notifState.activeTab; - useEffect(() => { - const savedY = notifState.scrollPositions[activeTab] ?? 0; - window.scrollTo(0, savedY); - }, [activeTab, notifState.scrollPositions]); + useReusableTabScrollRestore("Notifications"); return ( <> diff --git a/src/routes/profile.$did/index.tsx b/src/routes/profile.$did/index.tsx index c8757aa..e6c2c19 100644 --- a/src/routes/profile.$did/index.tsx +++ b/src/routes/profile.$did/index.tsx @@ -1,10 +1,16 @@ import { RichText } from "@atproto/api"; +import * as ATPAPI from "@atproto/api"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useAtom } from "jotai"; import React, { type ReactNode, useEffect, useState } from "react"; +import defaultpfp from "~/../public/favicon.png"; import { Header } from "~/components/Header"; +import { + ReusableTabRoute, + useReusableTabScrollRestore, +} from "~/components/ReusableTabRoute"; import { renderTextWithFacets, UniversalPostRendererATURILoader, @@ -18,6 +24,7 @@ import { } from "~/utils/followState"; import { useInfiniteQueryAuthorFeed, + useQueryConstellation, useQueryIdentity, useQueryProfile, } from "~/utils/useQuery"; @@ -29,6 +36,7 @@ export const Route = createFileRoute("/profile/$did/")({ function ProfileComponent() { // booo bad this is not always the did it might be a handle, use identity.did instead const { did } = Route.useParams(); + const { agent } = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { @@ -47,31 +55,6 @@ function ProfileComponent() { const { data: profileRecord } = useQueryProfile(profileUri); const profile = profileRecord?.value; - const { - data: postsData, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading: arePostsLoading, - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); - - React.useEffect(() => { - if (postsData) { - postsData.pages.forEach((page) => { - page.records.forEach((record) => { - if (!queryClient.getQueryData(["post", record.uri])) { - queryClient.setQueryData(["post", record.uri], record); - } - }); - }); - } - }, [postsData, queryClient]); - - const posts = React.useMemo( - () => postsData?.pages.flatMap((page) => page.records) ?? [], - [postsData] - ); - const [imgcdn] = useAtom(imgCDNAtom); function getAvatarUrl(p: typeof profile) { @@ -90,28 +73,10 @@ function ProfileComponent() { const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; const description = profile?.description || ""; - if (isIdentityLoading) { - return ( -
Resolving profile...
- ); - } - - if (identityError) { - return ( -
- Error: {identityError.message} -
- ); - } - - if (!resolvedDid) { - return ( -
Profile not found.
- ); - } + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; return ( - <> +
{ @@ -121,6 +86,7 @@ function ProfileComponent() { window.location.assign("/"); } }} + bottomBorderDisabled={true} /> {/*
- {/* Posts Section */} -
-
- Posts + {/* this should not be rendered until its ready (the top profile layout is stable) */} + {isReady ? ( + , + Reposts: , + Feeds: , + Lists: , + ...(identity?.did === agent?.did + ? { Likes: } + : {}), + }} + /> + ) : isIdentityLoading ? ( +
+ Resolving profile... +
+ ) : identityError ? ( +
+ Error: {identityError.message} +
+ ) : !resolvedDid ? ( +
Profile not found.
+ ) : ( +
+ Loading profile content...
-
- {posts.map((post) => ( + )} +
+ ); +} + +function PostsTab({ did }: { did: string }) { + useReusableTabScrollRestore(`Profile` + did); + const queryClient = useQueryClient(); + const { + data: identity, + isLoading: isIdentityLoading, + error: identityError, + } = useQueryIdentity(did); + + const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const { + data: postsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds); + + React.useEffect(() => { + if (postsData) { + postsData.pages.forEach((page) => { + page.records.forEach((record) => { + if (!queryClient.getQueryData(["post", record.uri])) { + queryClient.setQueryData(["post", record.uri], record); + } + }); + }); + } + }, [postsData, queryClient]); + + const posts = React.useMemo( + () => postsData?.pages.flatMap((page) => page.records) ?? [], + [postsData] + ); + + return ( + <> +
+ Posts +
+
+ {posts.map((post) => ( + + ))} +
+ + {/* Loading and "Load More" states */} + {arePostsLoading && posts.length === 0 && ( +
Loading posts...
+ )} + {isFetchingNextPage && ( +
Loading more...
+ )} + {hasNextPage && !isFetchingNextPage && ( + + )} + {posts.length === 0 && !arePostsLoading && ( +
No posts found.
+ )} + + ); +} + +function RepostsTab({ did }: { did: string }) { + useReusableTabScrollRestore(`Profile` + did); + const { + data: identity, + isLoading: isIdentityLoading, + error: identityError, + } = useQueryIdentity(did); + + const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const { + data: repostsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed( + resolvedDid, + identity?.pds, + "app.bsky.feed.repost" + ); + + const reposts = React.useMemo( + () => repostsData?.pages.flatMap((page) => page.records) ?? [], + [repostsData] + ); + + return ( + <> +
+ Reposts +
+
+ {reposts.map((repost) => { + if ( + !repost || + !repost?.value || + !repost?.value?.subject || + // @ts-expect-error blehhhhh + !repost?.value?.subject?.uri + ) + return; + const repostRecord = + repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record; + return ( - ))} + ); + })} +
+ + {/* Loading and "Load More" states */} + {arePostsLoading && reposts.length === 0 && ( +
Loading posts...
+ )} + {isFetchingNextPage && ( +
Loading more...
+ )} + {hasNextPage && !isFetchingNextPage && ( + + )} + {reposts.length === 0 && !arePostsLoading && ( +
No posts found.
+ )} + + ); +} + +function FeedsTab({ did }: { did: string }) { + useReusableTabScrollRestore(`Profile` + did); + const { + data: identity, + isLoading: isIdentityLoading, + error: identityError, + } = useQueryIdentity(did); + + const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const { + data: feedsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed( + resolvedDid, + identity?.pds, + "app.bsky.feed.generator" + ); + + const feeds = React.useMemo( + () => feedsData?.pages.flatMap((page) => page.records) ?? [], + [feedsData] + ); + + return ( + <> +
+ Feeds +
+
+ {feeds.map((feed) => { + if (!feed || !feed?.value) return; + const feedGenRecord = + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; + return ; + })} +
+ + {/* Loading and "Load More" states */} + {arePostsLoading && feeds.length === 0 && ( +
Loading feeds...
+ )} + {isFetchingNextPage && ( +
Loading more...
+ )} + {hasNextPage && !isFetchingNextPage && ( + + )} + {feeds.length === 0 && !arePostsLoading && ( +
No feeds found.
+ )} + + ); +} + +function FeedItemRender({ + feed, + listmode +}: { + feed: { uri: string; cid: string; value: ATPAPI.AppBskyFeedGenerator.Record }; + listmode?: boolean; +}) { + const name = listmode ? feed.value?.name as string : feed.value?.displayName as string; + const aturi = new ATPAPI.AtUri(feed.uri); + const {data: identity} = useQueryIdentity(aturi.host); + const resolvedDid = identity?.did; + const [imgcdn] = useAtom(imgCDNAtom); + + function getAvatarThumbnailUrl(f: typeof feed) { + const link = f?.value.avatar?.ref?.["$link"]; + if (!link || !resolvedDid) return null; + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; + } + + // @ts-expect-error overloads sucks + const {data: likes} = useQueryConstellation(!listmode ? { + target: feed.uri, + method: "/links/count", + collection: "app.bsky.feed.like", + path: ".subject.uri" + } : undefined) + + return ( +
+
+
+ +
+
+ {name} + {feed.value.did || aturi.rkey}
+
+ {/*
*/} +
+ {feed.value?.description} + {!listmode && (Liked by {(likes as unknown as any)?.total as number || 0} users)} +
+ ); +} - {/* Loading and "Load More" states */} - {arePostsLoading && posts.length === 0 && ( -
Loading posts...
- )} - {isFetchingNextPage && ( -
Loading more...
- )} - {hasNextPage && !isFetchingNextPage && ( - - )} - {posts.length === 0 && !arePostsLoading && ( -
No posts found.
- )} + +function ListsTab({ did }: { did: string }) { + useReusableTabScrollRestore(`Profile` + did); + const { + data: identity, + isLoading: isIdentityLoading, + error: identityError, + } = useQueryIdentity(did); + + const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const { + data: feedsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed( + resolvedDid, + identity?.pds, + "app.bsky.graph.list" + ); + + const feeds = React.useMemo( + () => feedsData?.pages.flatMap((page) => page.records) ?? [], + [feedsData] + ); + + return ( + <> +
+ Feeds +
+
+ {feeds.map((feed) => { + if (!feed || !feed?.value) return; + const feedGenRecord = + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; + return ; + })} +
+ + {/* Loading and "Load More" states */} + {arePostsLoading && feeds.length === 0 && ( +
Loading lists...
+ )} + {isFetchingNextPage && ( +
Loading more...
+ )} + {hasNextPage && !isFetchingNextPage && ( + + )} + {feeds.length === 0 && !arePostsLoading && ( +
No lists found.
+ )} + + ); +} + +function SelfLikesTab({ did }: { did: string }) { + useReusableTabScrollRestore(`Profile` + did); + const { + data: identity, + isLoading: isIdentityLoading, + error: identityError, + } = useQueryIdentity(did); + + const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const { + data: repostsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: arePostsLoading, + } = useInfiniteQueryAuthorFeed( + resolvedDid, + identity?.pds, + "app.bsky.feed.like" + ); + + const reposts = React.useMemo( + () => repostsData?.pages.flatMap((page) => page.records) ?? [], + [repostsData] + ); + + return ( + <> +
+ Likes +
+
+ {reposts.map((repost) => { + if ( + !repost || + !repost?.value || + !repost?.value?.subject || + // @ts-expect-error blehhhhh + !repost?.value?.subject?.uri + ) + return; + const repostRecord = + repost.value as unknown as ATPAPI.AppBskyFeedLike.Record; + return ( + + ); + })}
+ + {/* Loading and "Load More" states */} + {arePostsLoading && reposts.length === 0 && ( +
Loading posts...
+ )} + {isFetchingNextPage && ( +
Loading more...
+ )} + {hasNextPage && !isFetchingNextPage && ( + + )} + {reposts.length === 0 && !arePostsLoading && ( +
No posts found.
+ )} ); } diff --git a/src/utils/useQuery.ts b/src/utils/useQuery.ts index 7e2f4f7..eb0836d 100644 --- a/src/utils/useQuery.ts +++ b/src/utils/useQuery.ts @@ -534,16 +534,16 @@ type ListRecordsResponse = { }[]; }; -export function constructAuthorFeedQuery(did: string, pdsUrl: string) { +export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { return queryOptions({ - queryKey: ['authorFeed', did], + queryKey: ['authorFeed', did, collection], queryFn: async ({ pageParam }: QueryFunctionContext) => { const limit = 25; const cursor = pageParam as string | undefined; const cursorParam = cursor ? `&cursor=${cursor}` : ''; - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; const res = await fetch(url); if (!res.ok) throw new Error("Failed to fetch author's posts"); @@ -553,8 +553,8 @@ export function constructAuthorFeedQuery(did: string, pdsUrl: string) { }); } -export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); +export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { + const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); return useInfiniteQuery({ queryKey, -- 2.51.2