react query ftw

This commit is contained in:
2025-08-14 22:46:42 +03:00
parent 78f792578d
commit 502a25fe52
24 changed files with 365 additions and 462 deletions

View File

@@ -1,7 +1,7 @@
// chatgpt
function rewriteCookieDomain(rawCookie: string): string {
return rawCookie
.replace(/;?\s*Domain=[^;]+/i, '')
.replace(/;?\s*Domain=[^;]+/i, "")
.concat(`; Domain=localhost:3000`);
}

View File

@@ -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({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden`}
>
<TooltipProvider>
<main>
<Image
/* window.localStorage.BgImageUrl */
src={"/bg.png"}
width={1920}
height={1080}
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 blur-lg opacity-25"
alt=""
/>
<div className="z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
<QuickTopUI />
<QuickTopUILogoPart />
{children}
</div>
</main>
<Toaster />
</TooltipProvider>
<ReactQueryProvider>
<TooltipProvider>
<main>
<Image
/* window.localStorage.BgImageUrl */
src={"/bg.png"}
width={1920}
height={1080}
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 blur-lg opacity-25"
alt=""
/>
<div className="z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
<QuickTopUI />
<QuickTopUILogoPart />
{children}
</div>
</main>
<Toaster />
</TooltipProvider>
</ReactQueryProvider>
</body>
</html>
);

View File

@@ -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<OmniRecommendation | null>(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 (
<>

View File

@@ -1,7 +1,5 @@
"use client";
export default function Page() {
return <>
hi
</>
return <>hi</>;
}

View File

@@ -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=="],

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}

View File

@@ -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<string>("");
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 (
<div {...props}>
{/* <button onClick={()=>console.log(acct,presence,friends)}>debug</button> */}
<h1 className="text-2xl pt-4 pl-4 -mb-4">
{title}{" "}
<span className="text-overlay1 text-sm pl-2">{friendsLabel}</span>
</h1>
<h1 className="text-2xl pt-4 pl-4 -mb-4">{title}</h1>
<div className="rounded-xl flex flex-col gap-2 px-4 no-scrollbar">
<div
className="flex p-8 items-center gap-4 overflow-x-auto overflow-y-visible no-scrollbar pb-2 -mx-4 w-screen scrollbar-thin scrollbar-thumb-surface2 scrollbar-track-surface0"
@@ -110,7 +63,6 @@ export function FriendCarousel({
scrollbarWidth: "none"
}}
>
{/* <div className="w-8" /> */}
{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 ? <p>{userStatus?.lastLocation}</p> : <></>}
{userPresence >= 2 ? (
<p>
{userStatus?.lastLocation}
</p>
) : null}
</span>
</div>
}
>
<div
key={a.id}
className="flex flex-col min-w-[6.5rem]"
>
<div className="flex flex-col min-w-[6.5rem]">
<LazyLoadedImage
imgId={`AvatarHeadShot_${a.id}`}
alt={a.name}
@@ -191,7 +144,6 @@ export function FriendCarousel({
</StupidHoverThing>
);
})}
{/* <div className="w-8" /> */}
</div>
</div>
</div>

View File

@@ -22,5 +22,12 @@ export function BestFriendsHomeSect(
) {
const friends = useBestFriends();
return <FriendCarousel {...props} title="Best Friends" dontSortByActivity friends={friends} />;
return (
<FriendCarousel
{...props}
title="Best Friends"
dontSortByActivity
friends={friends}
/>
);
}

View File

@@ -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<SVGSVGElement>) {
return (

View File

@@ -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() {
)}
<div className="flex flex-col justify-center">
<span className="text-3xl font-bold text-text flex items-center gap-2">
{isLoaded ? randomGreeting(window.localStorage.UserPreferredName || profile.displayName || "Robloxian!") : (
{isLoaded ? (
randomGreeting(
window.localStorage.UserPreferredName ||
profile.displayName ||
"Robloxian!"
)
) : (
<>
<Skeleton className="w-96 h-8 rounded-lg" />
</>

View File

@@ -10,10 +10,16 @@ import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
type OutfitSelectorProps = {
setVisible: (visible: boolean) => void;
updateOutfit: (outfit: { id: number }, acc: {id: number}) => Promise<void>;
updateOutfit: (
outfit: { id: number },
acc: { id: number }
) => Promise<void>;
};
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);
}}
>

View File

@@ -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 () {
<Link href="/" className="-m-1 w-8 h-8">
<img src="/icon-512.webp" className="w-8 h-8" alt="" />
</Link>
<Link href="/test" className="mt-2 gap-2 flex items-center">
<Link href="/" className="mt-2 gap-2 flex items-center">
<p>{"ocbwoy3-chan's roblox"}</p>
<p className="text-surface2 line-clamp-1">{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD} {process.env.NEXT_PUBLIC_ARGV0}</p>
{/* <p className="text-surface2 line-clamp-1">
{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD}{" "}
{process.env.NEXT_PUBLIC_ARGV0}
</p> */}
</Link>
</div>
);

View File

@@ -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 (
<Tooltip {...props}>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-surface0 text-text m-2">
<span className="text-sm flex items-center">{text}</span>
</TooltipContent>
</Tooltip>
)
);
}

View File

@@ -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<AccountSettings | false | null>(null);
const queryClient = useQueryClient();
useEffect(() => {
if (!acct) return;
let cancelled = false;
const fetchSetttings = async () => {
if (!acct || cancelled) return;
const { data: accountSettings } = useQuery<AccountSettings | false | null>({
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;
}

View File

@@ -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<Outfit[] | false | null>(null);
const queryClient = useQueryClient();
const query = useQuery<Outfit[] | false>({
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;
}

View File

@@ -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;
}

View File

@@ -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<UserProfileDetails | false>({
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;
}

View File

@@ -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;
}

View File

@@ -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<typeof setInterval> | 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<PresenceData[]>([]);
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;
}

View File

@@ -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<number | false | null>(null);
const queryClient = useQueryClient();
useEffect(() => {
if (!acct) return;
let cancelled = false;
const fetchBalance = async () => {
if (!acct || cancelled) return;
const { data: robux } = useQuery<number | false | null>({
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;
}

View File

@@ -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;

View File

@@ -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
});
}

View File

@@ -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 */

View File

@@ -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",