diff --git a/app/layout.tsx b/app/layout.tsx index 837ae90..1cc366b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QuickTopUI } from "@/components/QuickTopUI"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -15,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "OCbwoy3-Chan-blox", + title: "ocbwoy3-chan-blox", description: "roblox meets next.js i think" }; @@ -27,10 +26,9 @@ export default function RootLayout({ return ( - {children} diff --git a/app/page.tsx b/app/page.tsx index 1e0085a..42fa022 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { FriendsHomeSect } from "@/components/FriendsOnlineSection"; import { GameCard } from "@/components/gameCard"; import { HomeLoggedInHeader } from "@/components/loggedInHeader"; +import { QuickTopUI, QuickTopUILogoPart } from "@/components/QuickTopUI"; import { VerifiedIcon } from "@/components/RobloxIcons"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardContent } from "@/components/ui/card"; @@ -35,7 +36,9 @@ export default function Home() { }, []); return ( -
+
+ +
diff --git a/bun.lock b/bun.lock index 80bf52e..5a8e2b4 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@tailwindcss/line-clamp": "^0.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -275,6 +276,8 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@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=="], + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], diff --git a/components/FriendsOnlineSection.tsx b/components/FriendsOnlineSection.tsx index ded4408..957fe59 100644 --- a/components/FriendsOnlineSection.tsx +++ b/components/FriendsOnlineSection.tsx @@ -3,6 +3,8 @@ import { useFriendsHome } from "@/hooks/roblox/useFriendsHome"; import LazyLoadedImage from "./lazyLoadedImage"; import React from "react"; import { VerifiedIcon } from "./RobloxIcons"; +import { useFriendsPresence } from "@/hooks/roblox/usePresence"; +import { StupidHoverThing } from "./MiscStuff"; export function FriendsHomeSect( props: React.DetailedHTMLProps< @@ -12,6 +14,9 @@ export function FriendsHomeSect( ) { const friends = useFriendsHome(); const acct = useCurrentAccount(); + const presence = useFriendsPresence( + (!!friends ? friends : []).map((f) => f.id) + ); if (!friends) { return <>; @@ -19,35 +24,93 @@ export function FriendsHomeSect( return (
+ {/* */}

Friends

-
+
- {friends.map((a) => ( -
- - - {a.displayName || a.name} - {a.hasVerifiedBadge ? null : ( - - )} - -
- ))} + {friends.map((a) => { + const userStatus = presence.find( + (b) => b.userId === a.id + ); + const userPresence = userStatus?.userPresenceType || 0; + const borderColor = + userPresence === 1 + ? "border-blue bg-blue/50" + : userPresence === 2 + ? "border-green bg-green/50" + : userPresence === 3 + ? "border-yellow bg-yellow/50" + : userPresence === 0 + ? "border-surface2 bg-surface2/50" + : "border-red bg-red/50"; + const textColor = + userPresence === 1 + ? "text-blue" + : userPresence === 2 + ? "text-green" + : userPresence === 3 + ? "text-yellow" + : userPresence === 0 + ? "text-surface2" + : "text-red"; + const fillColor = + userPresence === 1 + ? "fill-blue" + : userPresence === 2 + ? "fill-green" + : userPresence === 3 + ? "fill-yellow" + : userPresence === 0 + ? "fill-surface2" + : "fill-red"; + + return ( +
+ + + +

{a.displayName || a.name}

+ {!a.hasVerifiedBadge ? ( + + ) : null} +
+ } + > + + {a.displayName || a.name} + + + {!a.hasVerifiedBadge ? ( + + ) : null} + +
+ ); + })}
diff --git a/components/MiscStuff.tsx b/components/MiscStuff.tsx new file mode 100644 index 0000000..232b370 --- /dev/null +++ b/components/MiscStuff.tsx @@ -0,0 +1,15 @@ +import { VerifiedIcon } from "./RobloxIcons"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +export function StupidHoverThing({ children, text }: React.PropsWithChildren & { text: string | React.ReactNode }) { + return ( + + + {children} + + +

{text}

+
+
+ ) +} diff --git a/components/QuickTopUI.tsx b/components/QuickTopUI.tsx index 2a59707..101543a 100644 --- a/components/QuickTopUI.tsx +++ b/components/QuickTopUI.tsx @@ -2,18 +2,27 @@ import { useRobuxBalance } from "@/hooks/roblox/useRobuxBalance"; import { RobuxIcon } from "./RobloxIcons"; +import React from "react"; -export function QuickTopUI() { +export const QuickTopUI = React.memo(function () { const robux = useRobuxBalance(); return ( - - - - +
+
+
-

{robux}

- - - +

{robux || "???"}

+
+
+
); -} +}); + +export const QuickTopUILogoPart = React.memo(function () { + return ( +
+ +

{"ocbwoy3-chan-blox"}

+
+ ); +}); diff --git a/components/RobloxTooltipStuff.tsx b/components/RobloxTooltipStuff.tsx index 01a0d62..63aef37 100644 --- a/components/RobloxTooltipStuff.tsx +++ b/components/RobloxTooltipStuff.tsx @@ -28,7 +28,7 @@ export function RobloxVerifiedSmall( -

Verified user

+

Verified

); diff --git a/components/loggedInHeader.tsx b/components/loggedInHeader.tsx index 118edd7..feac433 100644 --- a/components/loggedInHeader.tsx +++ b/components/loggedInHeader.tsx @@ -1,12 +1,13 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import LazyLoadedImage from "./lazyLoadedImage"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { OctagonXIcon } from "lucide-react"; import { RobloxPremiumSmall, RobloxVerifiedSmall } from "./RobloxTooltipStuff"; import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount"; import { Skeleton } from "./ui/skeleton"; +import { useFriendsPresence } from "@/hooks/roblox/usePresence"; export function HomeLoggedInHeader() { const profile = useCurrentAccount(); @@ -26,8 +27,24 @@ export function HomeLoggedInHeader() { ); } + const presence = useFriendsPresence(profile ? [profile.id] : []); + + const userActivity = presence.find((b) => b.userId === profile?.id) + const userPresence = userActivity?.userPresenceType; + const borderColor = + userPresence === 1 + ? "border-blue bg-blue/50" + : userPresence === 2 + ? "border-green bg-green/50" + : userPresence === 3 + ? "border-yellow bg-yellow/50" + : userPresence === 0 + ? "border-surface2 bg-surface2/50" + : "border-red bg-red/50"; + return ( <> + {/* */}
{!profile ? ( @@ -35,7 +52,7 @@ export function HomeLoggedInHeader() { )}
@@ -53,7 +70,10 @@ export function HomeLoggedInHeader() { {profile ? ( - <>@{profile.name} + <> + @{profile.name} + {(!!userActivity && userPresence === 2) ? <> - {userActivity.lastLocation} : <> } + ) : ( )} diff --git a/hooks/roblox/useCurrentAccount.ts b/hooks/roblox/useCurrentAccount.ts index bc150bc..b57ac39 100644 --- a/hooks/roblox/useCurrentAccount.ts +++ b/hooks/roblox/useCurrentAccount.ts @@ -9,7 +9,7 @@ import { loadThumbnails } from "@/lib/thumbnailLoader"; import { useEffect, useState } from "react"; let isFetching = false; -let cachedData: any = null; +let cachedData: UserProfileDetails | null | false = null; export function useCurrentAccount() { const [profileDetails, setProfileDetails] = useState< @@ -18,7 +18,7 @@ export function useCurrentAccount() { useEffect(() => { let cancelled = false; - if (profileDetails !== null) return; + if (profileDetails !== null && profileDetails !== undefined) return; if (isFetching) { const IN = setInterval(() => { if (cachedData !== null) { diff --git a/hooks/roblox/usePresence.ts b/hooks/roblox/usePresence.ts new file mode 100644 index 0000000..f9d73bc --- /dev/null +++ b/hooks/roblox/usePresence.ts @@ -0,0 +1,127 @@ +"use client"; + +// smartass method by google gemini + +import { useEffect, useState, useMemo } from "react"; +import { useCurrentAccount } from "./useCurrentAccount"; +import { proxyFetch } from "@/lib/utils"; + +type PresenceData = { + userPresenceType: number; + lastLocation: string; + placeId: number; + rootPlaceId: number; + gameId: string; + universeId: number; + 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) { + 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. + * + * @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] + ); + + 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 = []; + } + }; + }, [acct, userIdsKey]); + + return data; +} diff --git a/hooks/roblox/useRobuxBalance.ts b/hooks/roblox/useRobuxBalance.ts index ad8b921..1388d25 100644 --- a/hooks/roblox/useRobuxBalance.ts +++ b/hooks/roblox/useRobuxBalance.ts @@ -4,46 +4,40 @@ import { useEffect, useState } from "react"; import { useCurrentAccount } from "./useCurrentAccount"; import { proxyFetch } from "@/lib/utils"; -let isFetching = false; -let cachedData: number | false | null = null; - export function useRobuxBalance() { const acct = useCurrentAccount(); - const [robux, setRobux] = useState(cachedData); + const [robux, setRobux] = useState(null); useEffect(() => { - let cancelled = false; if (!acct) return; - async function fetchBalance() { - if (isFetching) return; - if (!acct) return; - isFetching = true; + let cancelled = false; + + const fetchBalance = async () => { + if (!acct || cancelled) return; try { const res = await proxyFetch( `https://economy.roblox.com/v1/users/${acct.id}/currency` ); const data = await res.json(); if (!cancelled) setRobux(data.robux); - cachedData = data.robux; } catch { if (!cancelled) setRobux(false); - cachedData = false; - } finally { - isFetching = false; } - } + }; fetchBalance(); + const interval = setInterval(fetchBalance, 10000); - function handleTransaction() { + const handleTransaction = () => { fetchBalance(); - } + }; window.addEventListener("transactionCompletedEvent", handleTransaction); return () => { cancelled = true; + clearInterval(interval); window.removeEventListener( "transactionCompletedEvent", handleTransaction diff --git a/lib/thumbnailLoader.ts b/lib/thumbnailLoader.ts index fd23a7f..766d19c 100644 --- a/lib/thumbnailLoader.ts +++ b/lib/thumbnailLoader.ts @@ -26,7 +26,7 @@ export type ThumbnailRequest = { export async function getThumbnails( b: ThumbnailRequest[] ): Promise { - const batchSize = 50; + const batchSize = 100; const results: AssetThumbnail[] = []; for (let i = 0; i < b.length; i += batchSize) { const batch = b.slice(i, i + batchSize); diff --git a/package.json b/package.json index 961975d..f347f7f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", + "@tailwindcss/line-clamp": "^0.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/tailwind.config.ts b/tailwind.config.ts index 432598c..856b96e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -115,6 +115,7 @@ export default { }, plugins: [ require("tailwindcss-animate"), + require("@tailwindcss/line-clamp"), require("@catppuccin/tailwindcss")({ prefix: false, defaultFlavour: "mocha"