diff --git a/apps/api/lexicons/graph/getFollows.json b/apps/api/lexicons/graph/getFollows.json index 3fd6313b..7fe4cdef 100644 --- a/apps/api/lexicons/graph/getFollows.json +++ b/apps/api/lexicons/graph/getFollows.json @@ -21,6 +21,14 @@ "minimum": 1, "default": 50 }, + "dids": { + "type": "array", + "description": "If provided, filters the follows to only include those with DIDs in this list.", + "items": { + "type": "string", + "format": "did" + } + }, "cursor": { "type": "string" } diff --git a/apps/api/pkl/defs/graph/getFollows.pkl b/apps/api/pkl/defs/graph/getFollows.pkl index bfcef26c..b3a501e2 100644 --- a/apps/api/pkl/defs/graph/getFollows.pkl +++ b/apps/api/pkl/defs/graph/getFollows.pkl @@ -19,6 +19,15 @@ defs = new Mapping { minimum = 1 default = 50 } + ["dids"] = new Array { + type = "array" + items = new StringType { + type = "string" + format = "did" + } + description = + "If provided, filters the follows to only include those with DIDs in this list." + } ["cursor"] = new StringType { type = "string" } diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index b0470276..aa3a54bb 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -2943,6 +2943,15 @@ export const schemaDict = { minimum: 1, default: 50, }, + dids: { + type: "array", + description: + "If provided, filters the follows to only include those with DIDs in this list.", + items: { + type: "string", + format: "did", + }, + }, cursor: { type: "string", }, diff --git a/apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts b/apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts index e7c75bb0..2dd251a2 100644 --- a/apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts +++ b/apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts @@ -12,6 +12,8 @@ import type * as AppRockskyActorDefs from "../actor/defs"; export interface QueryParams { actor: string; limit: number; + /** If provided, filters the follows to only include those with DIDs in this list. */ + dids?: string[]; cursor?: string; } diff --git a/apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts b/apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts index 11d33e6d..f946f686 100644 --- a/apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts +++ b/apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts @@ -60,17 +60,22 @@ const retrieve = ({ params.cursor ? and( ...[ - lt(tables.follows.createdAt, new Date(params.cursor)), + lt( + tables.follows.createdAt, + new Date(Number(params.cursor)), + ), eq(tables.follows.subject_did, params.actor), - (params.dids || params.dids.length > 0) && - inArray(tables.follows.follower_did, params.dids), + params.dids && params.dids?.length > 0 + ? inArray(tables.follows.follower_did, params.dids) + : undefined, ], ) : and( ...[ eq(tables.follows.subject_did, params.actor), - (params.dids || params.dids.length > 0) && - inArray(tables.follows.follower_did, params.dids), + params.dids && params.dids?.length > 0 + ? inArray(tables.follows.follower_did, params.dids) + : undefined, ], ), ) @@ -89,17 +94,22 @@ const retrieve = ({ params.cursor ? and( ...[ - lt(tables.follows.createdAt, new Date(params.cursor)), + lt( + tables.follows.createdAt, + new Date(Number(params.cursor)), + ), eq(tables.follows.subject_did, params.actor), - (params.dids || params.dids.length > 0) && + params.dids && + params.dids?.length > 0 && inArray(tables.follows.follower_did, params.dids), ], ) : and( ...[ eq(tables.follows.subject_did, params.actor), - (params.dids || params.dids.length > 0) && - inArray(tables.follows.follower_did, params.dids), + params.dids && params.dids?.length > 0 + ? inArray(tables.follows.follower_did, params.dids) + : undefined, ], ), ) diff --git a/apps/api/src/xrpc/app/rocksky/graph/getFollows.ts b/apps/api/src/xrpc/app/rocksky/graph/getFollows.ts index 821bfb09..43683b24 100644 --- a/apps/api/src/xrpc/app/rocksky/graph/getFollows.ts +++ b/apps/api/src/xrpc/app/rocksky/graph/getFollows.ts @@ -1,5 +1,5 @@ import type { Context } from "context"; -import { eq, desc, and, lt } from "drizzle-orm"; +import { eq, desc, and, lt, inArray } from "drizzle-orm"; import { Effect, pipe } from "effect"; import type { Server } from "lexicon"; import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; @@ -46,6 +46,25 @@ const retrieve = ({ [SelectUser | undefined, SelectUser[], string | undefined], Error > => { + // Build where conditions dynamically + const buildWhereConditions = () => { + const conditions = [eq(tables.follows.follower_did, params.actor)]; + + if (params.cursor) { + conditions.push( + lt(tables.follows.createdAt, new Date(Number(params.cursor))), + ); + } + + if (params.dids && params.dids.length > 0) { + conditions.push(inArray(tables.follows.subject_did, params.dids)); + } + + return conditions.length > 1 ? and(...conditions) : conditions[0]; + }; + + const whereConditions = buildWhereConditions(); + return Effect.tryPromise({ try: () => Promise.all([ @@ -58,17 +77,10 @@ const retrieve = ({ ctx.db .select() .from(tables.follows) - .where( - params.cursor - ? and( - lt(tables.follows.createdAt, new Date(params.cursor)), - eq(tables.follows.follower_did, params.actor), - ) - : eq(tables.follows.follower_did, params.actor), - ) + .where(whereConditions) .leftJoin( tables.users, - eq(tables.users.did, tables.follows.follower_did), + eq(tables.users.did, tables.follows.subject_did), ) .orderBy(desc(tables.follows.createdAt)) .limit(params.limit ?? 50) @@ -77,19 +89,12 @@ const retrieve = ({ ctx.db .select() .from(tables.follows) - .where( - params.cursor - ? and( - lt(tables.follows.createdAt, new Date(params.cursor)), - eq(tables.follows.follower_did, params.actor), - ) - : eq(tables.follows.follower_did, params.actor), - ) + .where(whereConditions) .orderBy(desc(tables.follows.createdAt)) .limit(params.limit ?? 50) .execute() .then((rows) => - rows.length > 0 + rows?.length > 0 ? rows[rows.length - 1]?.createdAt.getTime().toString(10) : undefined, ), diff --git a/apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts b/apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts index 0959cb38..9f387f68 100644 --- a/apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts +++ b/apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts @@ -71,7 +71,7 @@ const retrieve = ({ .where( params.cursor ? and( - lt(tables.follows.createdAt, new Date(params.cursor)), + lt(tables.follows.createdAt, new Date(Number(params.cursor))), eq(tables.follows.subject_did, params.actor), sql`EXISTS ( SELECT 1 FROM ${tables.follows} f2 @@ -92,7 +92,7 @@ const retrieve = ({ .limit(params.limit ?? 50) .execute(); const cursor = - knownFollowers.length > 0 + knownFollowers?.length > 0 ? knownFollowers[knownFollowers.length - 1].follows.createdAt .getTime() .toString(10) diff --git a/apps/web/src/api/graph.ts b/apps/web/src/api/graph.ts index 0ca875f6..b71de02c 100644 --- a/apps/web/src/api/graph.ts +++ b/apps/web/src/api/graph.ts @@ -3,10 +3,11 @@ import { client } from "."; export const getFollows = async ( actor: string, limit: number, + dids?: string[], cursor?: string, ) => { const response = await client.get("/xrpc/app.rocksky.graph.getFollows", { - params: { actor, limit, cursor }, + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, }); return response.data; @@ -20,7 +21,7 @@ export const getKnownFollowers = async ( const response = await client.get( "/xrpc/app.rocksky.graph.getKnownFollowers", { - params: { actor, limit, cursor }, + params: { actor, limit: limit > 0 ? limit : 1, cursor }, }, ); @@ -30,22 +31,27 @@ export const getKnownFollowers = async ( export const getFollowers = async ( actor: string, limit: number, + dids?: string[], cursor?: string, ) => { const response = await client.get("/xrpc/app.rocksky.graph.getFollowers", { - params: { actor, limit, cursor }, + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, }); return response.data; }; export const followAccount = async (account: string) => { - const response = await client.post("/xrpc/app.rocksky.graph.followAccount", { - params: { account }, - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, + const response = await client.post( + "/xrpc/app.rocksky.graph.followAccount", + undefined, + { + params: { account }, + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, }, - }); + ); return response.data; }; @@ -53,6 +59,7 @@ export const followAccount = async (account: string) => { export const unfollowAccount = async (account: string) => { const response = await client.post( "/xrpc/app.rocksky.graph.unfollowAccount", + undefined, { params: { account }, headers: { diff --git a/apps/web/src/atoms/follows.ts b/apps/web/src/atoms/follows.ts index 425fc1fa..5e3002f2 100644 --- a/apps/web/src/atoms/follows.ts +++ b/apps/web/src/atoms/follows.ts @@ -1,5 +1,3 @@ import { atom } from "jotai"; -export const followsAtom = atom<{ - [key: string]: string[] | null; -}>({}); +export const followsAtom = atom>(new Set()); diff --git a/apps/web/src/atoms/tab.ts b/apps/web/src/atoms/tab.ts new file mode 100644 index 00000000..c893034d --- /dev/null +++ b/apps/web/src/atoms/tab.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import { Key } from "react"; + +export const activeTabAtom = atom("0"); diff --git a/apps/web/src/components/Handle/Handle.tsx b/apps/web/src/components/Handle/Handle.tsx index ca2e8a01..37080eb5 100644 --- a/apps/web/src/components/Handle/Handle.tsx +++ b/apps/web/src/components/Handle/Handle.tsx @@ -4,7 +4,7 @@ import { Block } from "baseui/block"; import { StatefulPopover, TRIGGER_TYPE } from "baseui/popover"; import { LabelMedium, LabelSmall } from "baseui/typography"; import { useAtom } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { profilesAtom } from "../../atoms/profiles"; import { statsAtom } from "../../atoms/stats"; import { @@ -13,6 +13,15 @@ import { } from "../../hooks/useProfile"; import Stats from "../Stats"; import NowPlaying from "./NowPlaying"; +import { followsAtom } from "../../atoms/follows"; +import { IconCheck, IconPlus } from "@tabler/icons-react"; +import { Button } from "baseui/button"; +import SignInModal from "../SignInModal"; +import { + useFollowAccountMutation, + useFollowersQuery, + useUnfollowAccountMutation, +} from "../../hooks/useGraph"; export type HandleProps = { link: string; @@ -20,11 +29,62 @@ export type HandleProps = { }; function Handle(props: HandleProps) { + const [follows, setFollows] = useAtom(followsAtom); + const [isSignInOpen, setIsSignInOpen] = useState(false); const { link, did } = props; const [profiles, setProfiles] = useAtom(profilesAtom); const profile = useProfileByDidQuery(did); const profileStats = useProfileStatsByDidQuery(did); const [stats, setStats] = useAtom(statsAtom); + const { mutate: followAccount } = useFollowAccountMutation(); + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); + const currentDid = localStorage.getItem("did"); + const { data, isLoading } = useFollowersQuery( + profile.data?.did, + 1, + currentDid ? [currentDid] : undefined, + ); + + const onFollow = () => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => new Set(prev).add(profile.data?.did)); + followAccount(profile.data?.did); + }; + + const onUnfollow = () => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + newSet.delete(profile.data?.did); + return newSet; + }); + unfollowAccount(profile.data?.did); + }; + + useEffect(() => { + if (!data || isLoading) { + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + if ( + data.followers.some( + (follower: { did: string }) => follower.did === currentDid, + ) + ) { + newSet.add(profile.data?.did); + } else { + newSet.delete(profile.data?.did); + } + return newSet; + }); + }, [data, isLoading]); useEffect(() => { if (profile.isLoading || profile.isError) { @@ -73,52 +133,115 @@ function Handle(props: HandleProps) { }, [profileStats.data, profileStats.isLoading, profileStats.isError, did]); return ( - ( - -
- - - -
- - - {profiles[did]?.displayName} - - - - - @{did} - - + <> + ( + +
+
+ + + +
+ + + {profiles[did]?.displayName} + + + + + @{did} + + +
+
+ {(profile.data?.did !== localStorage.getItem("did") || + !localStorage.getItem("did")) && ( +
+ {!follows.has(profile.data?.did) && !isLoading && ( + + )} + {follows.has(profile.data?.did) && !isLoading && ( + + )} +
+ )}
-
- - {stats[did] && } - - - - )} - triggerType={TRIGGER_TYPE.hover} - autoFocus={false} - focusLock={false} - > - - - @{did} - - - + + {stats[did] && } + + + + )} + triggerType={TRIGGER_TYPE.hover} + autoFocus={false} + focusLock={false} + > + + + @{did} + + + + setIsSignInOpen(false)} + follow + /> + ); } diff --git a/apps/web/src/components/SignInModal/SignInModal.tsx b/apps/web/src/components/SignInModal/SignInModal.tsx index 0c75cf17..82d2d73d 100644 --- a/apps/web/src/components/SignInModal/SignInModal.tsx +++ b/apps/web/src/components/SignInModal/SignInModal.tsx @@ -8,10 +8,11 @@ interface SignInModalProps { isOpen: boolean; onClose: () => void; like?: boolean; + follow?: boolean; } function SignInModal(props: SignInModalProps) { - const { isOpen, onClose, like } = props; + const { isOpen, onClose, like, follow } = props; const [handle, setHandle] = useState(""); const onLogin = async () => { @@ -57,7 +58,9 @@ function SignInModal(props: SignInModalProps) {

Rocksky

{!like - ? "Sign in or create your account to join the conversation!" + ? !follow + ? "Sign in or create your account to join the conversation!" + : "Sign in or create your account to follow users!" : "Sign in or create your account to like songs!"}

diff --git a/apps/web/src/hooks/useGraph.tsx b/apps/web/src/hooks/useGraph.tsx index 5a9b505d..e57b9eba 100644 --- a/apps/web/src/hooks/useGraph.tsx +++ b/apps/web/src/hooks/useGraph.tsx @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { getFollowers, getFollows, @@ -9,21 +14,47 @@ import { export const useFollowsQuery = ( actor: string, limit: number, + dids?: string[], cursor?: string, ) => useQuery({ - queryKey: ["follows", actor, limit, cursor], - queryFn: () => getFollows(actor, limit, cursor), + queryKey: ["follows", actor, limit, dids, cursor], + queryFn: () => getFollows(actor, limit, dids, cursor), + }); + +export const useFollowsInfiniteQuery = ( + actor: string, + limit: number, + dids?: string[], +) => + useInfiniteQuery({ + queryKey: ["follows-infinite", actor, dids, limit], + queryFn: ({ pageParam }) => getFollows(actor, limit, dids, pageParam), + getNextPageParam: (lastPage) => lastPage.cursor, + initialPageParam: undefined as string | undefined, }); export const useFollowersQuery = ( actor: string, limit: number, + dids?: string[], cursor?: string, ) => useQuery({ - queryKey: ["followers", actor, limit, cursor], - queryFn: () => getFollowers(actor, limit, cursor), + queryKey: ["followers", actor, limit, dids, cursor], + queryFn: () => getFollowers(actor, limit, dids, cursor), + }); + +export const useFollowersInfiniteQuery = ( + actor: string, + limit: number, + dids?: string[], +) => + useInfiniteQuery({ + queryKey: ["followers-infinite", actor, limit, dids], + queryFn: ({ pageParam }) => getFollowers(actor, limit, dids, pageParam), + getNextPageParam: (lastPage) => lastPage.cursor, + initialPageParam: undefined as string | undefined, }); export const useFollowAccountMutation = () => { @@ -33,6 +64,12 @@ export const useFollowAccountMutation = () => { onSuccess: (_data, account) => { queryClient.invalidateQueries({ queryKey: ["follows"] }); queryClient.invalidateQueries({ queryKey: ["followers", account] }); + queryClient.invalidateQueries({ + queryKey: ["follows-infinite"], + }); + queryClient.invalidateQueries({ + queryKey: ["followers-infinite"], + }); }, }); }; @@ -44,6 +81,12 @@ export const useUnfollowAccountMutation = () => { onSuccess: (_data, account) => { queryClient.invalidateQueries({ queryKey: ["follows"] }); queryClient.invalidateQueries({ queryKey: ["followers", account] }); + queryClient.invalidateQueries({ + queryKey: ["follows-infinite"], + }); + queryClient.invalidateQueries({ + queryKey: ["followers-infinite"], + }); }, }); }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index dc54f1dd..8149789f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -103,7 +103,7 @@ body { --color-placeholder: #ffffffb4; --color-clear-input: #ffffff; --color-menu-hover: #1f0d3c; - --color-default-button: rgba(255, 255, 255, 0.05); + --color-default-button: rgba(255, 255, 255, 0.06); --color-purple: oklch(0.5478 0.201 296.07); --color-blue: oklch(0.789 0.154 211.53); --color-pink: oklch(0.6372 0.229 346.83); diff --git a/apps/web/src/pages/profile/Profile.tsx b/apps/web/src/pages/profile/Profile.tsx index bf2e80ed..8005032d 100644 --- a/apps/web/src/pages/profile/Profile.tsx +++ b/apps/web/src/pages/profile/Profile.tsx @@ -18,7 +18,17 @@ import LovedTracks from "./lovedtracks"; import Overview from "./overview"; import Playlists from "./playlists"; import { Button } from "baseui/button"; -import { IconPlus } from "@tabler/icons-react"; +import { IconPlus, IconCheck } from "@tabler/icons-react"; +import { followsAtom } from "../../atoms/follows"; +import SignInModal from "../../components/SignInModal"; +import { + useFollowAccountMutation, + useFollowersQuery, + useUnfollowAccountMutation, +} from "../../hooks/useGraph"; +import Follows from "./follows"; +import Followers from "./followers"; +import { activeTabAtom } from "../../atoms/tab"; const Group = styled.div` display: flex; @@ -40,14 +50,70 @@ export type ProfileProps = { }; function Profile(props: ProfileProps) { + const [follows, setFollows] = useAtom(followsAtom); + const [isSignInOpen, setIsSignInOpen] = useState(false); const [profiles, setProfiles] = useAtom(profilesAtom); - const [activeKey, setActiveKey] = useState( - _.get(props, "activeKey", "0").split("/")[0], - ); + const [activeKey, setActiveKey] = useAtom(activeTabAtom); const { did } = useParams({ strict: false }); const profile = useProfileByDidQuery(did!); const setUser = useSetAtom(userAtom); const { tab } = useSearch({ strict: false }); + const { mutate: followAccount } = useFollowAccountMutation(); + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); + const currentDid = localStorage.getItem("did"); + const { data, isLoading } = useFollowersQuery( + profile.data?.did, + 1, + currentDid ? [currentDid] : undefined, + ); + + const onFollow = () => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => new Set(prev).add(profile.data?.did)); + followAccount(profile.data?.did); + }; + + const onUnfollow = () => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + newSet.delete(profile.data?.did); + return newSet; + }); + unfollowAccount(profile.data?.did); + }; + + useEffect(() => { + if (!props.activeKey) { + return; + } + setActiveKey(_.get(props, "activeKey", "0").split("/")[0]); + }, [props.activeKey]); + + useEffect(() => { + if (!data || isLoading) { + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + if ( + data.followers.some( + (follower: { did: string }) => follower.did === currentDid, + ) + ) { + newSet.add(profile.data?.did); + } else { + newSet.delete(profile.data?.did); + } + return newSet; + }); + }, [data, isLoading]); useEffect(() => { if (tab === undefined) { @@ -96,13 +162,6 @@ function Profile(props: ProfileProps) { return; } - console.log( - ">>", - profile.data?.did !== localStorage.getItem("did"), - profile.data?.did, - localStorage.getItem("did"), - ); - return (
@@ -127,6 +186,7 @@ function Profile(props: ProfileProps) { @{profiles[did]?.handle} @@ -150,27 +210,59 @@ function Profile(props: ProfileProps) { {(profile.data?.did !== localStorage.getItem("did") || !localStorage.getItem("did")) && ( - + )} + {follows.has(profile.data?.did) && !isLoading && ( + + }} + > + Following + + )} + )} @@ -222,7 +314,7 @@ function Profile(props: ProfileProps) { /> - + + + + + > + +
+ setIsSignInOpen(false)} + follow + />
); } diff --git a/apps/web/src/pages/profile/followers/Followers.tsx b/apps/web/src/pages/profile/followers/Followers.tsx new file mode 100644 index 00000000..91891c70 --- /dev/null +++ b/apps/web/src/pages/profile/followers/Followers.tsx @@ -0,0 +1,227 @@ +import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; +import { + useFollowAccountMutation, + useFollowersInfiniteQuery, + useFollowsQuery, + useUnfollowAccountMutation, +} from "../../../hooks/useGraph"; +import { useProfileByDidQuery } from "../../../hooks/useProfile"; +import { Link, useParams } from "@tanstack/react-router"; +import { Avatar } from "baseui/avatar"; +import { useAtom } from "jotai"; +import { activeTabAtom } from "../../../atoms/tab"; +import { followsAtom } from "../../../atoms/follows"; +import { Button } from "baseui/button"; +import { IconCheck, IconPlus } from "@tabler/icons-react"; +import SignInModal from "../../../components/SignInModal"; +import { useState, useEffect, useRef } from "react"; + +function Followers() { + const [, setActiveKey] = useAtom(activeTabAtom); + const [follows, setFollows] = useAtom(followsAtom); + const [isSignInOpen, setIsSignInOpen] = useState(false); + const { did } = useParams({ strict: false }); + const profile = useProfileByDidQuery(did!); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useFollowersInfiniteQuery(profile.data?.did!, 20); + const { mutate: followAccount } = useFollowAccountMutation(); + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); + + const loadMoreRef = useRef(null); + + // Intersection Observer for infinite scroll + useEffect(() => { + if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(loadMoreRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const allFollowers = data?.pages.flatMap((page) => page.followers) ?? []; + + const { data: followsData } = useFollowsQuery( + localStorage.getItem("did")!, + allFollowers.length, + allFollowers + .map((follower) => follower.did) + .filter((x) => x !== localStorage.getItem("did")), + ); + + useEffect(() => { + if (!followsData) return; + setFollows((prev) => { + const newSet = new Set(prev); + followsData.follows.forEach((follow: { did: string }) => { + newSet.add(follow.did); + }); + return newSet; + }); + }, [followsData, setFollows]); + + const onFollow = (followerDid: string) => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => new Set(prev).add(followerDid)); + followAccount(followerDid); + }; + + const onUnfollow = (followerDid: string) => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + newSet.delete(followerDid); + return newSet; + }); + unfollowAccount(followerDid); + }; + + return ( + <> + + Followers + + + {allFollowers.length === 0 && data && ( +
+ + No followers yet + +
+ )} + + {allFollowers.length > 0 && ( +
+ {allFollowers.map((follower: any) => ( +
+
+ setActiveKey(0)} + > + + +
+ setActiveKey(0)} + > + + {follower.displayName} + + + + + @{follower.handle} + + +
+
+ {(follower.did !== localStorage.getItem("did") || + !localStorage.getItem("did")) && ( +
+ {!follows.has(follower.did) && ( + + )} + {follows.has(follower.did) && ( + + )} +
+ )} +
+ ))} + + {/* Infinite scroll trigger */} +
+ + {isFetchingNextPage && ( +
+ + Loading more... + +
+ )} +
+ )} + setIsSignInOpen(false)} + follow + /> + + ); +} + +export default Followers; diff --git a/apps/web/src/pages/profile/followers/index.tsx b/apps/web/src/pages/profile/followers/index.tsx new file mode 100644 index 00000000..422781dd --- /dev/null +++ b/apps/web/src/pages/profile/followers/index.tsx @@ -0,0 +1,3 @@ +import Followers from "./Followers"; + +export default Followers; diff --git a/apps/web/src/pages/profile/follows/Follows.tsx b/apps/web/src/pages/profile/follows/Follows.tsx new file mode 100644 index 00000000..c4702397 --- /dev/null +++ b/apps/web/src/pages/profile/follows/Follows.tsx @@ -0,0 +1,228 @@ +import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; +import { useProfileByDidQuery } from "../../../hooks/useProfile"; +import { Link, useParams } from "@tanstack/react-router"; +import { + useFollowAccountMutation, + useFollowsInfiniteQuery, + useFollowsQuery, + useUnfollowAccountMutation, +} from "../../../hooks/useGraph"; +import { Avatar } from "baseui/avatar"; +import { useAtom } from "jotai"; +import { activeTabAtom } from "../../../atoms/tab"; +import { followsAtom } from "../../../atoms/follows"; +import { Button } from "baseui/button"; +import { IconCheck, IconPlus } from "@tabler/icons-react"; +import SignInModal from "../../../components/SignInModal"; +import { useState, useEffect, useRef } from "react"; + +function Follows() { + const [, setActiveKey] = useAtom(activeTabAtom); + const [follows, setFollows] = useAtom(followsAtom); + const [isSignInOpen, setIsSignInOpen] = useState(false); + const { did } = useParams({ strict: false }); + const profile = useProfileByDidQuery(did!); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useFollowsInfiniteQuery(profile.data?.did!, 20); + const { mutate: followAccount } = useFollowAccountMutation(); + const { mutate: unfollowAccount } = useUnfollowAccountMutation(); + + const loadMoreRef = useRef(null); + + // Intersection Observer for infinite scroll + useEffect(() => { + if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(loadMoreRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Flatten all pages into a single array + const allFollows = data?.pages.flatMap((page) => page.follows) ?? []; + + const { data: followsData } = useFollowsQuery( + localStorage.getItem("did")!, + allFollows.length, + allFollows + .map((follow) => follow.did) + .filter((x) => x !== localStorage.getItem("did")), + ); + + useEffect(() => { + if (!followsData) return; + setFollows((prev) => { + const newSet = new Set(prev); + followsData.follows.forEach((follow: { did: string }) => { + newSet.add(follow.did); + }); + return newSet; + }); + }, [followsData, setFollows]); + + const onFollow = (followDid: string) => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => new Set(prev).add(followDid)); + followAccount(followDid); + }; + + const onUnfollow = (followDid: string) => { + if (!localStorage.getItem("token")) { + setIsSignInOpen(true); + return; + } + setFollows((prev) => { + const newSet = new Set(prev); + newSet.delete(followDid); + return newSet; + }); + unfollowAccount(followDid); + }; + + return ( + <> + + Following + + + {allFollows.length === 0 && data && ( +
+ + Not following anyone yet + +
+ )} + + {allFollows.length > 0 && ( +
+ {allFollows.map((follow: any) => ( +
+
+ setActiveKey(0)} + > + + +
+ setActiveKey(0)} + > + + {follow.displayName} + + + + + @{follow.handle} + + +
+
+ {(follow.did !== localStorage.getItem("did") || + !localStorage.getItem("did")) && ( +
+ {!follows.has(follow.did) && ( + + )} + {follows.has(follow.did) && ( + + )} +
+ )} +
+ ))} + + {/* Infinite scroll trigger */} +
+ + {isFetchingNextPage && ( +
+ + Loading more... + +
+ )} +
+ )} + setIsSignInOpen(false)} + follow + /> + + ); +} + +export default Follows; diff --git a/apps/web/src/pages/profile/follows/index.tsx b/apps/web/src/pages/profile/follows/index.tsx new file mode 100644 index 00000000..ba0ba9bb --- /dev/null +++ b/apps/web/src/pages/profile/follows/index.tsx @@ -0,0 +1,3 @@ +import Follows from "./Follows"; + +export default Follows;