diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts index 42ea64b..d704e6f 100644 --- a/app/api/proxy/route.ts +++ b/app/api/proxy/route.ts @@ -1,7 +1,7 @@ // chatgpt function rewriteCookieDomain(rawCookie: string): string { return rawCookie - .replace(/;?\s*Domain=[^;]+/i, '') + .replace(/;?\s*Domain=[^;]+/i, "") .concat(`; Domain=localhost:3000`); } diff --git a/app/layout.tsx b/app/layout.tsx index 1382b1c..7f59030 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/toaster"; import Image from "next/image"; import { QuickTopUI, QuickTopUILogoPart } from "@/components/site/QuickTopUI"; +import { ReactQueryProvider } from "@/components/providers/ReactQueryProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -31,24 +32,26 @@ export default function RootLayout({ - -
- -
- - - {children} -
-
- -
+ + +
+ +
+ + + {children} +
+
+ +
+
); diff --git a/app/page.tsx b/app/page.tsx index cfaee16..c97b18c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,17 +13,17 @@ import { OmniRecommendation } from "@/lib/omniRecommendation"; import { loadThumbnails } from "@/lib/thumbnailLoader"; +import { useQuery } from "@tanstack/react-query"; import { AlertTriangleIcon } from "lucide-react"; -import { useEffect, useState } from "react"; export default function Home() { const SORTS_ALLOWED_IDS = [100000003, 100000001]; - const [rec, setRec] = useState(null); - useEffect(() => { - setTimeout(async () => { + + const { data: rec } = useQuery({ + queryKey: ["omni-recommendations"], + queryFn: async () => { const r = await getOmniRecommendationsHome(); if (r) { - setRec(r); loadThumbnails( Object.entries(r.contentMetadata.Game).map((a) => ({ type: "GameThumbnail", @@ -33,8 +33,11 @@ export default function Home() { })) ).catch((a) => {}); } - }, 1000); - }, []); + return r; + }, + staleTime: 300000, // 5 minutes + refetchOnWindowFocus: false + }); return ( <> diff --git a/app/test/page.tsx b/app/test/page.tsx index 3889dce..927f9ff 100644 --- a/app/test/page.tsx +++ b/app/test/page.tsx @@ -1,7 +1,5 @@ "use client"; export default function Page() { - return <> - hi - + return <>hi; } diff --git a/bun.lock b/bun.lock index e89df99..d6022ef 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,8 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/line-clamp": "^0.4.4", + "@tanstack/react-query": "^5.85.3", + "@tanstack/react-query-devtools": "^5.85.3", "@types/bun": "^1.2.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -285,6 +287,14 @@ "@tailwindcss/line-clamp": ["@tailwindcss/line-clamp@0.4.4", "", { "peerDependencies": { "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" } }, "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g=="], + "@tanstack/query-core": ["@tanstack/query-core@5.85.3", "", {}, "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.85.3", "", { "dependencies": { "@tanstack/query-core": "5.85.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.85.3", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.85.3", "react": "^18 || ^19" } }, "sha512-WSVweCE1Kh1BVvPDHAmLgGT+GGTJQ9+a7bVqzD+zUiUTht+salJjYm5nikpMNaHFPJV102TCYdvgHgBXtURRNg=="], + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], diff --git a/components/providers/ReactQueryProvider.tsx b/components/providers/ReactQueryProvider.tsx new file mode 100644 index 0000000..9148113 --- /dev/null +++ b/components/providers/ReactQueryProvider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState } from "react"; + +export function ReactQueryProvider({ + children +}: { + children: React.ReactNode; +}) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + {children} + {process.env.NODE_ENV === "development" && ( + + )} + + ); +} diff --git a/components/roblox/FriendCarousel.tsx b/components/roblox/FriendCarousel.tsx index 39ac6fc..e72221b 100644 --- a/components/roblox/FriendCarousel.tsx +++ b/components/roblox/FriendCarousel.tsx @@ -1,7 +1,6 @@ import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount"; -import { useFriendsHome } from "@/hooks/roblox/useFriends"; import { useFriendsPresence } from "@/hooks/roblox/usePresence"; -import React, { useEffect, useState } from "react"; +import React, { useMemo } from "react"; import LazyLoadedImage from "../util/LazyLoadedImage"; import { StupidHoverThing } from "../util/MiscStuff"; import { VerifiedIcon } from "./RobloxIcons"; @@ -29,78 +28,32 @@ export function FriendCarousel({ }) { const acct = useCurrentAccount(); const presence = useFriendsPresence( - (!!friendsUnsorted ? friendsUnsorted : []).map((f) => f.id) + (friendsUnsorted || []).map((f) => f.id) ); - const [friendsLabel, setFriendsLabel] = useState(""); + const friends = useMemo(() => { + if (!friendsUnsorted) return []; - const [friends, setFriends] = useState< - { - hasVerifiedBadge: boolean; - id: number; - name: string; - displayName: string; - }[] - >([]); + return [...friendsUnsorted].sort((a, b) => { + if (dontSortByActivity) return -10; - useEffect(() => { - let numStudio = 0; - let numGame = 0; - let numOnline = 0; - for (const friend of friendsUnsorted || []) { - const st = presence.find((c) => c.userId === friend.id); - switch (st?.userPresenceType || 0) { - case 1: - numOnline += 1; - break; - case 2: - numGame += 1; - break; - case 3: - numStudio += 1; - break; - } - } - setFriendsLabel( - [ - // `${friends.length}`, - (numOnline+numGame+numStudio === 0 || numOnline === 0) ? null : `${numOnline+numGame+numStudio} online`, - numGame === 0 ? null : `${numGame} in-game`, + const userStatusA = presence.find((c) => c.userId === a.id); + const userStatusB = presence.find((c) => c.userId === b.id); - ] - .filter((a) => !!a) - .join(" | ") - ); - - if (!friendsUnsorted) { - setFriends([]); - return; - } - setFriends( - friendsUnsorted.sort((a, b) => { - if (!!dontSortByActivity) return -10; - const userStatusA = presence.find((c) => c.userId === a.id); - const userStatusB = presence.find((c) => c.userId === b.id); - - return ( - (userStatusB?.userPresenceType || 0) - - (userStatusA?.userPresenceType || 0) - ); - }) - ); + return ( + (userStatusB?.userPresenceType || 0) - + (userStatusA?.userPresenceType || 0) + ); + }); }, [friendsUnsorted, presence, dontSortByActivity]); - if (!friends || friends.length === 0) { - return <>; + if (friends.length === 0) { + return null; } return (
- {/* */} -

- {title}{" "} - {friendsLabel} -

+

{title}

- {/*
*/} {friends.map((a) => { const userStatus = presence.find( (b) => b.userId === a.id @@ -161,15 +113,16 @@ export function FriendCarousel({ className={`w-4 h-4 shrink-0`} /> ) : null} - { userPresence >= 2 ?

{userStatus?.lastLocation}

: <>} + {userPresence >= 2 ? ( +

+ {userStatus?.lastLocation} +

+ ) : null}
} > -
+
); })} - {/*
*/}
diff --git a/components/roblox/FriendsOnline.tsx b/components/roblox/FriendsOnline.tsx index 165bd64..f6523fc 100644 --- a/components/roblox/FriendsOnline.tsx +++ b/components/roblox/FriendsOnline.tsx @@ -22,5 +22,12 @@ export function BestFriendsHomeSect( ) { const friends = useBestFriends(); - return ; + return ( + + ); } diff --git a/components/roblox/RobloxTooltips.tsx b/components/roblox/RobloxTooltips.tsx index dbbab40..7717161 100644 --- a/components/roblox/RobloxTooltips.tsx +++ b/components/roblox/RobloxTooltips.tsx @@ -1,9 +1,5 @@ import { PremiumIconSmall, VerifiedIcon } from "./RobloxIcons"; -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from "../ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function RobloxPremiumSmall(props: React.SVGProps) { return ( diff --git a/components/site/HomeUserHeader.tsx b/components/site/HomeUserHeader.tsx index aa5392d..1a32dd3 100644 --- a/components/site/HomeUserHeader.tsx +++ b/components/site/HomeUserHeader.tsx @@ -17,9 +17,7 @@ import { toast } from "sonner"; // chatgpt + human function randomGreeting(name: string): string { - const greetings = [ - `Howdy, ${name}` - ]; + const greetings = [`Howdy, ${name}`]; const index = Math.floor(Math.random() * greetings.length); return greetings[index]; @@ -52,12 +50,12 @@ export function HomeLoggedInHeader() { userPresence === 1 ? "border-blue/25 bg-blue/25" : userPresence === 2 - ? "border-green/25 bg-green/25" - : userPresence === 3 - ? "border-yellow/25 bg-yellow/25" - : userPresence === 0 - ? "border-surface2/25 bg-surface2/25" - : "border-red/25 bg-red/25"; + ? "border-green/25 bg-green/25" + : userPresence === 3 + ? "border-yellow/25 bg-yellow/25" + : userPresence === 0 + ? "border-surface2/25 bg-surface2/25" + : "border-red/25 bg-red/25"; const isLoaded = !!profile && !!accountSettings; @@ -92,7 +90,13 @@ export function HomeLoggedInHeader() { )}
- {isLoaded ? randomGreeting(window.localStorage.UserPreferredName || profile.displayName || "Robloxian!") : ( + {isLoaded ? ( + randomGreeting( + window.localStorage.UserPreferredName || + profile.displayName || + "Robloxian!" + ) + ) : ( <> diff --git a/components/site/OutfitQuickChooser.tsx b/components/site/OutfitQuickChooser.tsx index c688d19..9115d47 100644 --- a/components/site/OutfitQuickChooser.tsx +++ b/components/site/OutfitQuickChooser.tsx @@ -10,10 +10,16 @@ import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount"; type OutfitSelectorProps = { setVisible: (visible: boolean) => void; - updateOutfit: (outfit: { id: number }, acc: {id: number}) => Promise; + updateOutfit: ( + outfit: { id: number }, + acc: { id: number } + ) => Promise; }; -export function OutfitSelector({ setVisible, updateOutfit }: OutfitSelectorProps) { +export function OutfitSelector({ + setVisible, + updateOutfit +}: OutfitSelectorProps) { const outfits = useAvatarOutfits(); const acc = useCurrentAccount(); @@ -33,7 +39,7 @@ export function OutfitSelector({ setVisible, updateOutfit }: OutfitSelectorProps key={outfit.id} className="hover:bg-base/50 rounded-lg" onClick={async () => { - updateOutfit(outfit,acc); + updateOutfit(outfit, acc); setVisible(false); }} > diff --git a/components/site/QuickTopUI.tsx b/components/site/QuickTopUI.tsx index e878b40..36c0538 100644 --- a/components/site/QuickTopUI.tsx +++ b/components/site/QuickTopUI.tsx @@ -79,7 +79,6 @@ async function updateOutfit(outfit: { id: number }, acc: { id: number }) { } ); - loadThumbnails([ { type: "AvatarHeadShot", @@ -98,7 +97,7 @@ export const QuickTopUI = React.memo(function () { const bf = useBestFriends(); useCurrentAccount(); - useFriendsPresence([...(f ? f : []), ...(bf ? bf : [])].map(a=>a.id)) + useFriendsPresence([...(f ? f : []), ...(bf ? bf : [])].map((a) => a.id)); const robux = useRobuxBalance(); const [isOutfitSelectorVisible, setIsOutfitSelectorVisible] = @@ -154,9 +153,12 @@ export const QuickTopUILogoPart = React.memo(function () { - +

{"ocbwoy3-chan's roblox"}

-

{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD} {process.env.NEXT_PUBLIC_ARGV0}

+ {/*

+ {process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD}{" "} + {process.env.NEXT_PUBLIC_ARGV0} +

*/}
); diff --git a/components/util/MiscStuff.tsx b/components/util/MiscStuff.tsx index 8d727bb..d9aecc4 100644 --- a/components/util/MiscStuff.tsx +++ b/components/util/MiscStuff.tsx @@ -2,15 +2,18 @@ import { TooltipProps } from "@radix-ui/react-tooltip"; import { VerifiedIcon } from "../roblox/RobloxIcons"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -export function StupidHoverThing({ children, text, ...props }: React.PropsWithChildren & TooltipProps & { text: string | React.ReactNode }) { +export function StupidHoverThing({ + children, + text, + ...props +}: React.PropsWithChildren & + TooltipProps & { text: string | React.ReactNode }) { return ( - - {children} - + {children} {text} - ) + ); } diff --git a/hooks/roblox/useAccountSettings.ts b/hooks/roblox/useAccountSettings.ts index 1e1217e..16af6ac 100644 --- a/hooks/roblox/useAccountSettings.ts +++ b/hooks/roblox/useAccountSettings.ts @@ -1,67 +1,84 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; +import { useEffect } from "react"; type AccountSettings = { - ChangeUsernameEnabled: boolean + ChangeUsernameEnabled: boolean; /* determines if the account owner is a roblox admin */ - IsAdmin: boolean, + IsAdmin: boolean; - PreviousUserNames: string, + PreviousUserNames: string; /* censored out email */ - UserEmail: string, + UserEmail: string; - UserAbove13: boolean, + UserAbove13: boolean; /* does the user have roblox premium */ - IsPremium: boolean, + IsPremium: boolean; /* ingame chat */ - IsGameChatSettingEnabled: boolean -} + IsGameChatSettingEnabled: boolean; +}; export function useAccountSettings() { const acct = useCurrentAccount(); - const [accountSettings, setAccountSettings] = useState(null); + const queryClient = useQueryClient(); - useEffect(() => { - if (!acct) return; - - let cancelled = false; - - const fetchSetttings = async () => { - if (!acct || cancelled) return; + const { data: accountSettings } = useQuery({ + queryKey: ["account-settings", acct ? acct.id : "acctId"], + queryFn: async () => { + if (!acct) return null; try { const res = await proxyFetch( `https://www.roblox.com/my/settings/json` ); + if (!res.ok) { + console.error( + `[useAccountSettings] API Error ${res.status} ${res.statusText}` + ); + return false; + } const data = await res.json(); - if (!cancelled) setAccountSettings(data); - } catch { - if (!cancelled) setAccountSettings(false); + return data; + } catch (error) { + console.error( + "[useAccountSettings] Failed to fetch settings", + error + ); + return false; } - }; - - fetchSetttings(); + }, + enabled: !!acct, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false + }); + useEffect(() => { const handleTransaction = () => { - fetchSetttings(); + queryClient.invalidateQueries({ + queryKey: ["account-settings", acct ? acct.id : "acctId"] + }); }; - window.addEventListener("settingTransactionCompletedEvent", handleTransaction); + window.addEventListener( + "settingTransactionCompletedEvent", + handleTransaction + ); return () => { - cancelled = true; window.removeEventListener( "settingTransactionCompletedEvent", handleTransaction ); }; - }, [acct]); + }, [acct ? acct.id : "acctId", queryClient]); return accountSettings; } diff --git a/hooks/roblox/useAvatarOutfits.ts b/hooks/roblox/useAvatarOutfits.ts index e7a82e1..6ff01e8 100644 --- a/hooks/roblox/useAvatarOutfits.ts +++ b/hooks/roblox/useAvatarOutfits.ts @@ -1,63 +1,64 @@ -// https://avatar.roblox.com/v2/avatar/users/1083030325/outfits?isEditable=true&itemsPerPage=50&outfitType=Avatar - "use client"; -import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; import { loadThumbnails } from "@/lib/thumbnailLoader"; type Outfit = { - name: string, - id: number -} + name: string; + id: number; +}; -export function useAvatarOutfits() { +export function useAvatarOutfits(): Outfit[] | false | null { const acct = useCurrentAccount(); - const [outfits, setOutfits] = useState(null); + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: ["avatarOutfits", acct ? acct.id : "acctId"], + enabled: !!acct, + queryFn: async () => { + if (!acct) return false; + + const res = await proxyFetch( + `https://avatar.roblox.com/v2/avatar/users/${acct.id}/outfits?page=1&itemsPerPage=25&isEditable=true` + ); + const data = (await res.json()) as { data: Outfit[] }; + + loadThumbnails( + data.data.map((a) => ({ + type: "Outfit", + targetId: a.id, + format: "webp", + size: "420x420" + })) + ).catch(() => {}); + + return data.data; + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); useEffect(() => { - if (!acct) return; - - let cancelled = false; - - const fetchSetttings = async () => { - if (!acct || cancelled) return; - try { - const res = await proxyFetch( - `https://avatar.roblox.com/v2/avatar/users/${acct.id}/outfits?page=1&itemsPerPage=25&isEditable=true` - ); - const data = await res.json() as {data: Outfit[]}; - if (!cancelled) { - setOutfits(data.data); - loadThumbnails(data.data.map(a=>({ - type: "Outfit", - targetId: a.id, - format: "webp", - size: "420x420" - }))).catch(a=>{}) - } - } catch { - if (!cancelled) setOutfits(false); - } - }; - - fetchSetttings(); - const handleTransaction = () => { - fetchSetttings(); + queryClient.invalidateQueries({ + queryKey: ["avatarOutfits", acct ? acct.id : "acctId"] + }); }; - window.addEventListener("avatarTransactionCompletedEvent", handleTransaction); - + window.addEventListener( + "avatarTransactionCompletedEvent", + handleTransaction + ); return () => { - cancelled = true; window.removeEventListener( "avatarTransactionCompletedEvent", handleTransaction ); }; - }, [acct]); + }, [acct ? acct.id : "acctId", queryClient]); - return outfits; + return query.data ?? null; } diff --git a/hooks/roblox/useBestFriends.ts b/hooks/roblox/useBestFriends.ts index 9b6feeb..a68d5de 100644 --- a/hooks/roblox/useBestFriends.ts +++ b/hooks/roblox/useBestFriends.ts @@ -1,46 +1,41 @@ "use client"; -// https://friends.roblox.com/v1/users/1083030325/friends/find?userSort=1 - -import { useEffect, useState } from "react"; -import { useCurrentAccount } from "./useCurrentAccount"; +import { useQuery } from "@tanstack/react-query"; import { proxyFetch } from "@/lib/utils"; import { loadThumbnails } from "@/lib/thumbnailLoader"; +import { useCurrentAccount } from "./useCurrentAccount"; -let isFetching = false; -let cachedData: any = null; - -export function useBestFriends() { +export function useBestFriends(): + | { + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; + }[] + | null + | false { const acct = useCurrentAccount(); - const [friends, setFriends] = useState< + + const query = useQuery< | { hasVerifiedBadge: boolean; id: number; name: string; displayName: string; }[] - | null | false - >(cachedData); + >({ + queryKey: ["bestFriends", acct ? acct.id : "acctId"], + enabled: !!acct, + queryFn: async () => { + if (!acct) return false; + + const BestFriendIDs = JSON.parse( + window.localStorage.getItem("BestFriendsStore") || "[]" + ) as number[]; + + if (BestFriendIDs.length === 0) return []; - useEffect(() => { - let cancelled = false; - if (!acct) return; - if (isFetching) { - const IN = setInterval(() => { - if (cachedData !== null) { - if (!cancelled) setFriends(cachedData); - clearInterval(IN); - } - }, 50); - return () => { - clearInterval(IN); - cancelled = true; - }; - } - isFetching = true; - (async () => { - const BestFriendIDs = JSON.parse(window.localStorage.getItem("BestFriendsStore") || "[]") as number[] const friendsAPICall2 = await proxyFetch( `https://users.roblox.com/v1/users`, { @@ -51,6 +46,7 @@ export function useBestFriends() { }) } ); + const J2 = (await friendsAPICall2.json()) as { data: { hasVerifiedBadge: boolean; @@ -59,6 +55,7 @@ export function useBestFriends() { displayName: string; }[]; }; + loadThumbnails( J2.data.map((a) => ({ type: "AvatarHeadShot", @@ -67,7 +64,8 @@ export function useBestFriends() { format: "webp" })) ).catch(() => {}); - const friendsList = BestFriendIDs.map((a) => { + + return BestFriendIDs.map((a) => { const x = J2.data.find((b) => b.id === a); return { id: a, @@ -76,14 +74,10 @@ export function useBestFriends() { displayName: x?.displayName || "?" }; }); - if (!cancelled) setFriends(friendsList); - cachedData = friendsList; - isFetching = false; - })(); - return () => { - cancelled = true; - }; - }, [acct]); + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); - return friends; + return query.data ?? null; } diff --git a/hooks/roblox/useCurrentAccount.ts b/hooks/roblox/useCurrentAccount.ts index b57ac39..14260b9 100644 --- a/hooks/roblox/useCurrentAccount.ts +++ b/hooks/roblox/useCurrentAccount.ts @@ -1,61 +1,36 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { getLoggedInUser, getUserByUserId, UserProfileDetails } from "@/lib/profile"; import { loadThumbnails } from "@/lib/thumbnailLoader"; -import { useEffect, useState } from "react"; -let isFetching = false; -let cachedData: UserProfileDetails | null | false = null; - -export function useCurrentAccount() { - const [profileDetails, setProfileDetails] = useState< - UserProfileDetails | null | false - >(cachedData); - - useEffect(() => { - let cancelled = false; - if (profileDetails !== null && profileDetails !== undefined) return; - if (isFetching) { - const IN = setInterval(() => { - if (cachedData !== null) { - if (!cancelled) setProfileDetails(cachedData); - clearInterval(IN); - } - }, 50); - return () => { - clearInterval(IN); - cancelled = true; - }; - } - isFetching = true; - (async () => { +export function useCurrentAccount(): UserProfileDetails | null | false { + const query = useQuery({ + queryKey: ["currentAccount"], + queryFn: async () => { const authed = await getLoggedInUser(); - if (authed) { - const user = await getUserByUserId(authed.id.toString()); - if (!cancelled) setProfileDetails(user); - cachedData = user; - loadThumbnails([ - { - type: "AvatarHeadShot", - targetId: authed.id, - format: "webp", - size: "720x720" - } - ]).catch(() => {}); - } else { - if (!cancelled) setProfileDetails(false); - cachedData = false; - } - isFetching = false; - })(); - return () => { - cancelled = true; - }; - }, [profileDetails]); + if (!authed) return false; - return profileDetails; + const user = await getUserByUserId(authed.id.toString()); + + loadThumbnails([ + { + type: "AvatarHeadShot", + targetId: authed.id, + format: "webp", + size: "720x720" + } + ]).catch(() => {}); + + return user; + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); + + return query.data ?? null; } diff --git a/hooks/roblox/useFriends.ts b/hooks/roblox/useFriends.ts index eda1fd5..f0a1f5a 100644 --- a/hooks/roblox/useFriends.ts +++ b/hooks/roblox/useFriends.ts @@ -1,64 +1,34 @@ "use client"; -// https://friends.roblox.com/v1/users/1083030325/friends/find?userSort=1 - -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; import { loadThumbnails } from "@/lib/thumbnailLoader"; - -let isFetching = false; -let cachedData: any = null; +import { UserProfileDetails } from "@/lib/profile"; export function useFriendsHome() { const acct = useCurrentAccount(); - const [friends, setFriends] = useState< - | { - hasVerifiedBadge: boolean; - id: number; - name: string; - displayName: string; - }[] - | null - | false - >(cachedData); - - useEffect(() => { - let cancelled = false; - if (!acct) return; - if (isFetching) { - const IN = setInterval(() => { - if (cachedData !== null) { - if (!cancelled) setFriends(cachedData); - clearInterval(IN); - } - }, 50); - return () => { - clearInterval(IN); - cancelled = true; - }; - } - isFetching = true; - (async () => { + const { data: friends } = useQuery({ + queryKey: ["friends", acct ? acct.id : "acctId"], + queryFn: async () => { + if (!acct) return null; const friendsAPICall = await proxyFetch( - `https://friends.roblox.com/v1/users/${acct.id}/friends` // /find?userSort=1 + `https://friends.roblox.com/v1/users/${acct.id}/friends` ); - const J = (await friendsAPICall.json()) as { + const j = (await friendsAPICall.json()) as { data: { id: number }[]; - // PageItems: { id: number }[]; // /find }; const friendsAPICall2 = await proxyFetch( `https://users.roblox.com/v1/users`, { method: "POST", body: JSON.stringify({ - userIds: J.data.map((a) => a.id), - // userIds: J.PageItems.map((a) => a.id), + userIds: j.data.map((a) => a.id), excludeBannedUsers: false }) } ); - const J2 = (await friendsAPICall2.json()) as { + const j2 = (await friendsAPICall2.json()) as { data: { hasVerifiedBadge: boolean; id: number; @@ -67,15 +37,15 @@ export function useFriendsHome() { }[]; }; loadThumbnails( - J2.data.map((a) => ({ + j2.data.map((a) => ({ type: "AvatarHeadShot", size: "420x420", targetId: a.id, format: "webp" })) ).catch(() => {}); - const friendsList = J.data.map((a) => { // J.PageItems /find - const x = J2.data.find((b) => b.id === a.id); + const friendsList = j.data.map((a) => { + const x = j2.data.find((b) => b.id === a.id); return { id: a.id, hasVerifiedBadge: x?.hasVerifiedBadge || false, @@ -83,14 +53,14 @@ export function useFriendsHome() { displayName: x?.displayName || "?" }; }); - if (!cancelled) setFriends(friendsList); - cachedData = friendsList; - isFetching = false; - })(); - return () => { - cancelled = true; - }; - }, [acct]); + return friendsList; + }, + enabled: !!acct, + staleTime: 300000, // 5 minutes + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false + }); return friends; } diff --git a/hooks/roblox/usePresence.ts b/hooks/roblox/usePresence.ts index 6c5fd09..9961fd5 100644 --- a/hooks/roblox/usePresence.ts +++ b/hooks/roblox/usePresence.ts @@ -1,8 +1,6 @@ "use client"; -// smartass method by google gemini - -import { useEffect, useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; @@ -16,114 +14,53 @@ type PresenceData = { userId: number; }; -// --- Internal Shared State --- - -/** - * A Map to track subscribers. - * Key: The component's update callback function. - * Value: The array of user IDs that component is interested in. - * This allows multiple components to subscribe with their own lists of IDs. - */ -let subscribers = new Map<(data: PresenceData[]) => void, number[]>(); - -let interval: ReturnType | null = null; - -let latestData: PresenceData[] = []; - -/** - * Fetches presence for all unique user IDs requested by all subscribed components. - * @param acctId - The ID of the currently logged-in user. - */ -async function fetchPresence(acctId: number) { - const allIdArrays = [...subscribers.values()]; - const uniqueUserIds = [...new Set(allIdArrays.flat())]; - - if (!acctId || uniqueUserIds.length === 0) { - return; - } - - try { - const res = await proxyFetch( - "https://presence.roblox.com/v1/presence/users", - { - method: "POST", - body: JSON.stringify({ - userIds: [...new Set([acctId, ...uniqueUserIds])] - }), - headers: { - "Content-Type": "application/json" - } - } - ); - - if (!res.ok) { - console.log(`[usePresence] API Error ${res.status} ${res.statusText}`) - return - // throw new Error(`API request failed with status ${res.status}`); - } - - const json = await res.json(); - latestData = json.userPresences || []; - - subscribers.forEach((_requestedIds, callback) => callback(latestData)); - } catch (error) { - console.error("Failed to fetch presence:", error); - latestData = []; - subscribers.forEach((_requestedIds, callback) => callback([])); - } -} - /** * A React hook to get the real-time presence of a list of Roblox users. - * This hook can be used by multiple components simultaneously without conflict. + * This hook uses @tanstack/react-query to handle caching and periodic refetching. * * @param userIds - An array of user IDs to track. * @returns An array of PresenceData objects for the requested user IDs. */ export function useFriendsPresence(userIds: number[]) { const acct = useCurrentAccount(); - const [data, setData] = useState([]); - const userIdsKey = useMemo( - () => JSON.stringify([...userIds].sort()), - [userIds] - ); + // Sort userIds to ensure the query key is stable, regardless of the order of IDs. + const sortedUserIds = [...(userIds || [])].sort(); - useEffect(() => { - if (!acct || !userIds || userIds.length === 0) { - setData([]); - return; - } - - const updateCallback = (globalData: PresenceData[]) => { - const filteredData = globalData.filter((presence) => - userIds.includes(presence.userId) - ); - setData(filteredData); - }; - - updateCallback(latestData); - - subscribers.set(updateCallback, userIds); - - if (!interval) { - fetchPresence(acct.id); - interval = setInterval(() => fetchPresence(acct.id), 5000); - } else { - fetchPresence(acct.id); - } - - // The cleanup function runs when the component unmounts. - return () => { - subscribers.delete(updateCallback); - - if (subscribers.size === 0 && interval) { - clearInterval(interval); - interval = null; - latestData = []; + const { data: presences = [] } = useQuery({ + queryKey: ["presence", ...sortedUserIds], + queryFn: async () => { + if (!acct || sortedUserIds.length === 0) { + return []; } - }; - }, [acct, userIdsKey]); - return data; + const res = await proxyFetch( + "https://presence.roblox.com/v1/presence/users", + { + method: "POST", + body: JSON.stringify({ + userIds: sortedUserIds + }), + headers: { + "Content-Type": "application/json" + } + } + ); + + if (!res.ok) { + console.error( + `[usePresence] API Error ${res.status} ${res.statusText}` + ); + throw new Error(`API request failed with status ${res.status}`); + } + + const json = await res.json(); + return (json.userPresences || []) as PresenceData[]; + }, + enabled: !!acct && sortedUserIds.length > 0, + refetchInterval: 5000, + refetchOnWindowFocus: false + }); + + return presences; } diff --git a/hooks/roblox/useRobuxBalance.ts b/hooks/roblox/useRobuxBalance.ts index 1388d25..e1e4420 100644 --- a/hooks/roblox/useRobuxBalance.ts +++ b/hooks/roblox/useRobuxBalance.ts @@ -1,49 +1,49 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; +import { useEffect } from "react"; export function useRobuxBalance() { const acct = useCurrentAccount(); - const [robux, setRobux] = useState(null); + const queryClient = useQueryClient(); - useEffect(() => { - if (!acct) return; - - let cancelled = false; - - const fetchBalance = async () => { - if (!acct || cancelled) return; + const { data: robux } = useQuery({ + queryKey: ["robux-balance", acct ? acct.id : "acctId"], + queryFn: async () => { + if (!acct) return null; try { const res = await proxyFetch( `https://economy.roblox.com/v1/users/${acct.id}/currency` ); const data = await res.json(); - if (!cancelled) setRobux(data.robux); + return data.robux; } catch { - if (!cancelled) setRobux(false); + return false; } - }; - - fetchBalance(); - const interval = setInterval(fetchBalance, 10000); + }, + enabled: !!acct, + refetchInterval: 10000, + staleTime: 10000 + }); + useEffect(() => { const handleTransaction = () => { - fetchBalance(); + queryClient.invalidateQueries({ + queryKey: ["robux-balance", acct ? acct.id : "acctId"] + }); }; window.addEventListener("transactionCompletedEvent", handleTransaction); return () => { - cancelled = true; - clearInterval(interval); window.removeEventListener( "transactionCompletedEvent", handleTransaction ); }; - }, [acct]); + }, [acct ? acct.id : "acctId", queryClient]); return robux; } diff --git a/lib/robloxEngine/BrickColorIds.ts b/lib/robloxEngine/BrickColorIds.ts index 742d552..0244b33 100644 --- a/lib/robloxEngine/BrickColorIds.ts +++ b/lib/robloxEngine/BrickColorIds.ts @@ -242,7 +242,7 @@ export function findClosestBrickColor(hex: string): { col: [number, number, number]; } { const target = hexToRgb(hex); - console.log(hex,target) + console.log(hex, target); if (!target) throw new Error("Invalid hex"); let bestDist = Infinity; diff --git a/lib/utils.ts b/lib/utils.ts index 17c4501..de9357c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -28,7 +28,7 @@ export async function proxyFetchRaw( ...init, method: init?.method || "GET", headers, - body: init?.body, + body: init?.body }; return window.fetch(proxyUrl, fetchInit); @@ -57,7 +57,7 @@ export async function proxyFetch( response = await proxyFetchRaw(input, { ...init, - headers: newHeaders, + headers: newHeaders }); } diff --git a/next.config.ts b/next.config.ts index 71359bd..acfe795 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,13 +1,14 @@ import type { NextConfig } from "next"; if (!process.isBun) { - console.error(`You are running this with node. Rerun the process: bun --bun run dev`) - process.exit(1) + console.error( + `You are running this with node. Rerun the process: bun --bun run dev` + ); + process.exit(1); } -process.env.NEXT_PUBLIC_CWD = __dirname || "~" -process.env.NEXT_PUBLIC_ARGV0 = process.argv0 || "node" - +process.env.NEXT_PUBLIC_CWD = __dirname || "~"; +process.env.NEXT_PUBLIC_ARGV0 = process.argv0 || "node"; const nextConfig: NextConfig = { /* config options here */ diff --git a/package.json b/package.json index c2d552e..ac15c84 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/line-clamp": "^0.4.4", + "@tanstack/react-query": "^5.85.3", + "@tanstack/react-query-devtools": "^5.85.3", "@types/bun": "^1.2.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",