useBestFriends();

This commit is contained in:
2025-07-24 19:50:54 +03:00
parent 980b27bf84
commit c7d3cd85be
24 changed files with 925 additions and 249 deletions

View File

@@ -0,0 +1,198 @@
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 LazyLoadedImage from "../util/LazyLoadedImage";
import { StupidHoverThing } from "../util/MiscStuff";
import { VerifiedIcon } from "./RobloxIcons";
export function FriendCarousel({
friends: friendsUnsorted,
title,
dontSortByActivity,
...props
}: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
title: string;
dontSortByActivity?: boolean;
friends:
| {
hasVerifiedBadge: boolean;
id: number;
name: string;
displayName: string;
}[]
| null
| false;
}) {
const acct = useCurrentAccount();
const presence = useFriendsPresence(
(!!friendsUnsorted ? friendsUnsorted : []).map((f) => f.id)
);
const [friendsLabel, setFriendsLabel] = useState<string>("");
const [friends, setFriends] = useState<
{
hasVerifiedBadge: boolean;
id: number;
name: string;
displayName: string;
}[]
>([]);
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}`,
numGame === 0 ? null : `${numGame} in-game`,
numStudio === 0 ? null : `${numStudio} studio`
]
.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)
);
})
);
}, [friendsUnsorted, presence, dontSortByActivity]);
if (!friends || friends.length === 0) {
return <></>;
}
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>
<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"
style={{
scrollSnapType: "x mandatory",
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none"
}}
>
{/* <div className="w-8" /> */}
{friends.map((a) => {
const userStatus = presence.find(
(b) => b.userId === a.id
);
const userPresence = userStatus?.userPresenceType || 0;
const borderColor =
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";
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 (
<StupidHoverThing
key={a.id}
delayDuration={0}
text={
<div className="text-center items-center justify-center content-center">
<span className="space-x-1 flex items-center">
<p>{a.displayName || a.name}</p>
{!a.hasVerifiedBadge ? (
<VerifiedIcon
useDefault
className={`w-4 h-4 shrink-0`}
/>
) : null}
{ userPresence >= 2 ? <p>{userStatus?.lastLocation}</p> : <></>}
</span>
</div>
}
>
<div
key={a.id}
className="flex flex-col min-w-[6.5rem]"
>
<LazyLoadedImage
imgId={`AvatarHeadShot_${a.id}`}
alt={a.name}
className={`w-24 h-24 rounded-full border-2 ${borderColor} object-cover shadow-xl`}
/>
<span
className={`text-xs ${textColor} mt-1 text-center flex items-center justify-center gap-1 max-w-[6.5rem] overflow-hidden line-clamp-2`}
>
<span className="line-clamp-1 overflow-hidden text-ellipsis">
{a.displayName || a.name}
</span>
{!a.hasVerifiedBadge ? (
<VerifiedIcon
className={`text-base ${fillColor} w-3 h-3 shrink-0`}
/>
) : null}
</span>
</div>
</StupidHoverThing>
);
})}
{/* <div className="w-8" /> */}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { useFriendsHome } from "@/hooks/roblox/useFriends";
import React from "react";
import { FriendCarousel } from "./FriendCarousel";
import { useBestFriends } from "@/hooks/roblox/useBestFriends";
export function FriendsHomeSect(
props: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>
) {
const friends = useFriendsHome();
return <FriendCarousel {...props} title="Friends" friends={friends} />;
}
export function BestFriendsHomeSect(
props: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>
) {
const friends = useBestFriends();
return <FriendCarousel {...props} title="Best Friends" dontSortByActivity friends={friends} />;
}

View File

@@ -0,0 +1,101 @@
"use client";
import { ContentMetadata } from "@/lib/omniRecommendation";
import LazyLoadedImage from "../util/LazyLoadedImage";
import {
ContextMenu,
ContextMenuContent,
ContextMenuSeparator,
ContextMenuTrigger
} from "../ui/context-menu";
import { ContextMenuItem } from "@radix-ui/react-context-menu";
import React from "react";
interface GameCardProps {
game: ContentMetadata;
}
export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className="overflow-hidden aspect-video relative bg-muted rounded-2xl">
<div className="overflow-hidden">
{game.primaryMediaAsset ? (
<LazyLoadedImage
imgId={
"GameThumbnail_" +
game.rootPlaceId.toString()
}
alt={game.name}
className="object-fill w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<span className="text-muted-foreground">
{":("}
</span>
</div>
)}
</div>
<div className="text-blue bg-base font-mono flex right-2 bottom-2 absolute rounded-lg px-2 py-1">
{game.playerCount.toLocaleString()}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="max-w-[512px] p-2 space-y-1">
<ContextMenuItem
disabled
className="text-s font-bold text-muted-foreground"
>
{game.name}
</ContextMenuItem>
<ContextMenuItem
disabled
className="text-xs text-subtext0 text-muted-foreground"
>
{Math.round(
(game.totalUpVotes /
(game.totalUpVotes + game.totalDownVotes)) *
100
)}
% rating - {game.playerCount.toLocaleString()} playing
</ContextMenuItem>
<ContextMenuItem
disabled
className="pb-1 text-xs text-subtext0 text-muted-foreground"
>
{game.ageRecommendationDisplayName || ""}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<a href={`https://roblox.com/games/${game.rootPlaceId}`}>
Open URL
</a>
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
window.location.href = `roblox://placeId=${game.rootPlaceId}`;
}}
>
Play
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(`${game.rootPlaceId}`);
}}
>
Copy rootPlaceId
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(`${game.universeId}`);
}}
>
Copy universeId
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
});

View File

@@ -0,0 +1,97 @@
import React from "react";
export const PremiumIconSmall = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 16 16"
{...props}
>
<defs>
<clipPath id="clip-path">
<path d="M14,14V2H2V16a2,2,0,0,1-2-2V2A2,2,0,0,1,2,0H14a2,2,0,0,1,2,2V14a2,2,0,0,1-2,2H8V14ZM12,6v6H8V10h2V6H6V16H4V4h8Z" />
</clipPath>
</defs>
<g id="premium">
<g clipPath="url(#clip-path)">
<rect
x="-5"
y="-5"
width="26"
height="26"
fill="currentColor"
/>
</g>
</g>
</svg>
);
export const VerifiedIcon = ({
useDefault,
...props
}: React.SVGProps<SVGSVGElement> & { useDefault?: boolean }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
{...props}
>
<g clipPath="url(#clip0_8_46)">
<rect
x="5.88818"
width="22.89"
height="22.89"
transform="rotate(15 5.88818 0)"
fill={useDefault ? "#0066FF" : "currentFill"} /* #0066FF */
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.543 8.7508L20.549 8.7568C21.15 9.3578 21.15 10.3318 20.549 10.9328L11.817 19.6648L7.45 15.2968C6.85 14.6958 6.85 13.7218 7.45 13.1218L7.457 13.1148C8.058 12.5138 9.031 12.5138 9.633 13.1148L11.817 15.2998L18.367 8.7508C18.968 8.1498 19.942 8.1498 20.543 8.7508Z"
fill={useDefault ? "white" : "currentColor"} /* white */
/>
</g>
<defs>
<clipPath id="clip0_8_46">
<rect width="28" height="28" fill="white" />
</clipPath>
</defs>
</svg>
);
export const RobuxIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="robux_28_dark">
<path
d="M23.402,5.571 C25.009,6.499 26,8.215 26,10.071 L26,17.927 C26,19.784 25.009,21.499 23.402,22.427 L16.597,26.356 C14.99,27.284 13.009,27.284 11.402,26.356 L4.597,22.427 C2.99,21.499 2,19.784 2,17.927 L2,10.071 C2,8.215 2.99,6.499 4.597,5.571 L11.402,1.643 C13.009,0.715 14.99,0.715 16.597,1.643 L23.402,5.571 Z M12.313,3.426 L5.686,7.252 C4.642,7.855 4,8.968 4,10.174 L4,17.825 C4,19.03 4.642,20.144 5.686,20.747 L12.313,24.572 C13.357,25.175 14.642,25.175 15.686,24.572 L22.313,20.747 C23.357,20.144 24,19.03 24,17.825 L24,10.174 C24,8.968 23.357,7.855 22.313,7.252 L15.686,3.426 C14.642,2.823 13.357,2.823 12.313,3.426 L12.313,3.426 Z M15.385,5.564 L20.614,8.582 C21.471,9.077 22,9.992 22,10.983 L22,17.02 C22,18.01 21.471,18.925 20.614,19.42 L15.385,22.439 C14.528,22.934 13.471,22.934 12.614,22.439 L7.385,19.42 C6.528,18.925 6,18.01 6,17.02 L6,10.983 C6,9.992 6.528,9.077 7.385,8.582 L12.614,5.564 C13.471,5.069 14.528,5.069 15.385,5.564 L15.385,5.564 Z M11,17.001 L17,17.001 L17,11.001 L11,11.001 L11,17.001 Z"
id="Fill-1"
fill="currentColor"
></path>
<path
d="M51.402,5.571 C53.009,6.499 54,8.215 54,10.071 L54,17.927 C54,19.784 53.009,21.499 51.402,22.427 L44.597,26.356 C42.99,27.284 41.009,27.284 39.402,26.356 L32.597,22.427 C30.99,21.499 30,19.784 30,17.927 L30,10.071 C30,8.215 30.99,6.499 32.597,5.571 L39.402,1.643 C41.009,0.715 42.99,0.715 44.597,1.643 L51.402,5.571 Z M40.313,3.426 L33.686,7.252 C32.642,7.855 32,8.968 32,10.174 L32,17.825 C32,19.03 32.642,20.144 33.686,20.747 L40.313,24.572 C41.357,25.175 42.642,25.175 43.686,24.572 L50.313,20.747 C51.357,20.144 52,19.03 52,17.825 L52,10.174 C52,8.968 51.357,7.855 50.313,7.252 L43.686,3.426 C42.642,2.823 41.357,2.823 40.313,3.426 L40.313,3.426 Z M43.385,5.564 L48.614,8.582 C49.471,9.077 50,9.992 50,10.983 L50,17.02 C50,18.01 49.471,18.925 48.614,19.42 L43.385,22.439 C42.528,22.934 41.471,22.934 40.614,22.439 L35.385,19.42 C34.528,18.925 34,18.01 34,17.02 L34,10.983 C34,9.992 34.528,9.077 35.385,8.582 L40.614,5.564 C41.471,5.069 42.528,5.069 43.385,5.564 L43.385,5.564 Z M39,17.001 L45,17.001 L45,11.001 L39,11.001 L39,17.001 Z"
id="Fill-1"
fillOpacity="0.7"
fill="currentColor"
></path>
<g id="Fill-1-Copy-2" transform="matrix(1, 0, 0, 1, 0, -0.5)">
<use fill="black" fillOpacity="1" filter="url(#filter-3)"></use>
<use fill="url(#linearGradient-1)" fillRule="evenodd"></use>
<use fill="black" fillOpacity="1" filter="url(#filter-4)"></use>
</g>
<path
d="M23.402,61.574 C25.009,62.502 26,64.218 26,66.074 L26,73.93 C26,75.787 25.009,77.502 23.402,78.43 L16.597,82.359 C14.99,83.287 13.009,83.287 11.402,82.359 L4.597,78.43 C2.99,77.502 2,75.787 2,73.93 L2,66.074 C2,64.218 2.99,62.502 4.597,61.574 L11.402,57.646 C13.009,56.718 14.99,56.718 16.597,57.646 L23.402,61.574 Z M12.313,59.429 L5.686,63.255 C4.642,63.858 4,64.971 4,66.177 L4,73.828 C4,75.033 4.642,76.147 5.686,76.75 L12.313,80.575 C13.357,81.178 14.642,81.178 15.686,80.575 L22.313,76.75 C23.357,76.147 24,75.033 24,73.828 L24,66.177 C24,64.971 23.357,63.858 22.313,63.255 L15.686,59.429 C14.642,58.826 13.357,58.826 12.313,59.429 L12.313,59.429 Z M15.385,61.567 L20.614,64.585 C21.471,65.08 22,65.995 22,66.986 L22,73.023 C22,74.013 21.471,74.928 20.614,75.423 L15.385,78.442 C14.528,78.937 13.471,78.937 12.614,78.442 L7.385,75.423 C6.528,74.928 6,74.013 6,73.023 L6,66.986 C6,65.995 6.528,65.08 7.385,64.585 L12.614,61.567 C13.471,61.072 14.528,61.072 15.385,61.567 L15.385,61.567 Z M11,73.004 L17,73.004 L17,67.004 L11,67.004 L11,73.004 Z"
id="Fill-1-Copy-2"
fill="currentColor"
></path>
</g>
</svg>
);

View File

@@ -0,0 +1,34 @@
import { PremiumIconSmall, VerifiedIcon } from "./RobloxIcons";
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "../ui/tooltip";
export function RobloxPremiumSmall(props: React.SVGProps<SVGSVGElement>) {
return (
<Tooltip>
<TooltipTrigger asChild>
<PremiumIconSmall {...props} />
</TooltipTrigger>
<TooltipContent className="bg-surface0 text-text m-2">
<p className="text-sm">Roblox Premium</p>
</TooltipContent>
</Tooltip>
);
}
export function RobloxVerifiedSmall(
props: React.SVGProps<SVGSVGElement> & { useDefault?: boolean }
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<VerifiedIcon {...props} />
</TooltipTrigger>
<TooltipContent className="bg-surface0 text-text m-2">
<p className="text-sm">Verified</p>
</TooltipContent>
</Tooltip>
);
}