diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index a5da910ff..5eef624b9 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import * as React from "react"; import type { Tables } from "~/db/tables"; +import { useIsMounted } from "~/hooks/useIsMounted"; import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls"; import styles from "./Avatar.module.css"; @@ -17,6 +18,88 @@ const dimensions = { lg: 125, } as const; +const identiconCache = new Map(); + +function hashString(str: string) { + let hash = 5381; + + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0; + } + + return hash; +} + +function generateColors(hash: number) { + const hue = hash % 360; + const saturation = 65 + ((hash >>> 8) % 20); + const lightness = 50 + ((hash >>> 16) % 20); + + return { + background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`, + foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`, + }; +} + +function generateIdenticon(input: string, size = 128, gridSize = 5) { + const cacheKey = `${input}-${size}-${gridSize}`; + const cached = identiconCache.get(cacheKey); + if (cached) return cached; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + const dpr = window.devicePixelRatio || 1; + canvas.width = size * dpr; + canvas.height = size * dpr; + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + + const insetRatio = 1 / Math.sqrt(2); + const cellSize = Math.floor((size * insetRatio) / gridSize); + const actualSize = cellSize * gridSize; + const offset = Math.floor((size - actualSize) / 2); + const halfGrid = Math.ceil(gridSize / 2); + + const patternHash = hashString(input); + const colorHash = hashString(input.split("").reverse().join("")); + + const colors = generateColors(colorHash); + ctx.fillStyle = colors.background; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = colors.foreground; + + const path = new Path2D(); + + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < halfGrid; col++) { + const bitIndex = row * halfGrid + col; + const shouldFill = (patternHash >>> bitIndex) & 1; + + if (shouldFill) { + const x = offset + col * cellSize; + const y = offset + row * cellSize; + + path.rect(x, y, cellSize, cellSize); + + const mirrorCol = gridSize - 1 - col; + if (col !== mirrorCol) { + const mirrorX = offset + mirrorCol * cellSize; + path.rect(mirrorX, y, cellSize, cellSize); + } + } + } + } + + ctx.fill(path); + + const dataUrl = canvas.toDataURL(); + identiconCache.set(cacheKey, dataUrl); + return dataUrl; +} + export function Avatar({ user, url, @@ -35,94 +118,12 @@ export function Avatar({ } & React.ButtonHTMLAttributes) { const [isErrored, setIsErrored] = React.useState(false); const [loaded, setLoaded] = React.useState(false); - const [isClient, setIsClient] = React.useState(false); - - React.useEffect(() => { - setIsClient(true); - }, []); - - function hashString(str: string) { - let hash = 5381; - - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0; - } - - return hash; - } - - function generateColors(hash: number) { - const hue = hash % 360; - const saturation = 65 + ((hash >>> 8) % 20); - const lightness = 50 + ((hash >>> 16) % 20); - - return { - background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`, - foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`, - }; - } - - function generateIdenticon(input: string, size = 128, gridSize = 5) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d")!; - - const dpr = window.devicePixelRatio || 1; - canvas.width = size * dpr; - canvas.height = size * dpr; - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - - const insetRatio = 1 / Math.sqrt(2); - const cellSize = Math.floor((size * insetRatio) / gridSize); - const actualSize = cellSize * gridSize; - const offset = Math.floor((size - actualSize) / 2); - const halfGrid = Math.ceil(gridSize / 2); - - const patternHash = hashString(input); - const colorHash = hashString(input.split("").reverse().join("")); - - const colors = generateColors(colorHash); - ctx.fillStyle = colors.background; - ctx.fillRect(0, 0, size, size); - ctx.fillStyle = colors.foreground; - - const path = new Path2D(); - - for (let row = 0; row < gridSize; row++) { - for (let col = 0; col < halfGrid; col++) { - const bitIndex = row * halfGrid + col; - const shouldFill = (patternHash >>> bitIndex) & 1; - - if (shouldFill) { - const x = offset + col * cellSize; - const y = offset + row * cellSize; - - path.rect(x, y, cellSize, cellSize); - - const mirrorCol = gridSize - 1 - col; - if (col !== mirrorCol) { - const mirrorX = offset + mirrorCol * cellSize; - path.rect(mirrorX, y, cellSize, cellSize); - } - } - } - } - - ctx.fill(path); - - return canvas.toDataURL(); - } + const isClient = useIsMounted(); const isIdenticon = !url && (!user?.discordAvatar || isErrored || identiconInput); - const identiconSource = () => { - if (identiconInput) return identiconInput; - if (user) return user.discordId; - return "unknown"; - }; + const identiconSource = identiconInput ?? user?.discordId ?? "unknown"; const src = url ? url @@ -133,7 +134,7 @@ export function Avatar({ size: size === "lg" || size === "xmd" ? "lg" : "sm", }) : isClient - ? generateIdenticon(identiconSource(), dimensions[size], 7) + ? generateIdenticon(identiconSource, dimensions[size], 7) : BLANK_IMAGE_URL; return ( diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 1faf27b67..974c40ab9 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; +import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import type { RootLoaderData } from "~/root"; import { EVENTS_PAGE, @@ -50,7 +51,8 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { const hasUnseenNotifications = unseenIds.length > 0; const hasFriendInSendouQ = - sidebarData?.friends.some((f) => f.subtitle === "SendouQ") ?? false; + sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ?? + false; const closePanel = () => setActivePanel("closed"); diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 7f19edb1d..ba79f2f7c 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -72,6 +72,71 @@ export function SideNavHeader({ ); } +function ListItemContent({ + children, + user, + imageUrl, + overlayIconUrl, + subtitle, + badge, + badgeVariant, + suppressSubtitleHydrationWarning, +}: { + children: React.ReactNode; + user?: Pick; + imageUrl?: string; + overlayIconUrl?: string; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + badgeVariant?: "default" | "warning"; + suppressSubtitleHydrationWarning?: boolean; +}) { + return ( + <> + {user ? ( + + ) : imageUrl ? ( +
+ + {overlayIconUrl ? ( + + ) : null} +
+ ) : null} +
+ {children} + {subtitle || badge ? ( +
+ {subtitle ? ( + + {subtitle} + + ) : null} + {typeof badge === "string" ? ( + + {badge} + + ) : ( + badge + )} +
+ ) : null} +
+ + ); +} + export function ListLink({ children, to, @@ -102,46 +167,17 @@ export function ListLink({ onClick={onClick} aria-current={isActive ? "page" : undefined} > - {user ? ( - - ) : imageUrl ? ( -
- - {overlayIconUrl ? ( - - ) : null} -
- ) : null} -
- {children} - {subtitle || badge ? ( -
- {subtitle ? ( - - {subtitle} - - ) : null} - {typeof badge === "string" ? ( - - {badge} - - ) : ( - badge - )} -
- ) : null} -
+ + {children} + ); } @@ -161,26 +197,14 @@ export function ListButton({ }) { return ( ); } diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx index 20d58ff3c..5de8280eb 100644 --- a/app/features/friends/components/FriendMenu.tsx +++ b/app/features/friends/components/FriendMenu.tsx @@ -10,6 +10,7 @@ import { SendouMenuSection, } from "~/components/elements/Menu"; import { ListButton } from "~/components/SideNav"; +import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import { databaseTimestampToDate } from "~/utils/dates"; import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls"; @@ -128,7 +129,7 @@ function resolveActivityHref(friend: { }) { if (!friend.subtitle) return null; - if (friend.subtitle === "SendouQ") { + if (friend.subtitle === SENDOUQ_ACTIVITY_LABEL) { return { url: SENDOUQ_LOOKING_PAGE, isSendouQ: true }; } diff --git a/app/features/friends/friends-constants.ts b/app/features/friends/friends-constants.ts index 10ec3530d..f4731c63c 100644 --- a/app/features/friends/friends-constants.ts +++ b/app/features/friends/friends-constants.ts @@ -1,3 +1,5 @@ export const FRIEND = { MAX_PENDING_REQUESTS: 20, } as const; + +export const SENDOUQ_ACTIVITY_LABEL = "SendouQ"; diff --git a/app/features/friends/friends-utils.server.ts b/app/features/friends/friends-utils.server.ts index 79141cd82..7f882ffd9 100644 --- a/app/features/friends/friends-utils.server.ts +++ b/app/features/friends/friends-utils.server.ts @@ -1,5 +1,6 @@ import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { SENDOUQ_ACTIVITY_LABEL } from "./friends-constants"; export function resolveFriendActivity( friendId: number, @@ -9,7 +10,7 @@ export function resolveFriendActivity( if (ownGroup && ownGroup.members.length < FULL_GROUP_SIZE) { return { - subtitle: "SendouQ", + subtitle: SENDOUQ_ACTIVITY_LABEL, badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`, }; } diff --git a/app/features/front-page/components/SplatoonRotations.tsx b/app/features/front-page/components/SplatoonRotations.tsx index abe7dbed1..f47fa5777 100644 --- a/app/features/front-page/components/SplatoonRotations.tsx +++ b/app/features/front-page/components/SplatoonRotations.tsx @@ -31,7 +31,14 @@ export function SplatoonRotations() { const [activeFilter, setActiveFilter] = React.useState("ALL"); - if (data.rotations.length === 0) return null; + const nowUnixLive = useNowUnix(); + + const allInThePast = data.rotations.every( + (rotation) => rotation.endTime <= nowUnixLive, + ); + if (allInThePast) return null; + + if (allInThePast || data.rotations.length === 0) return null; const nowUnix = databaseTimestampNow(); @@ -71,10 +78,6 @@ export function SplatoonRotations() { rotationsByType.set(rotation.type, existing); } - const handleFilterChange = (filter: RotationModeFilter) => { - setActiveFilter(filter); - }; - const sortedEntries = Array.from(rotationsByType.entries()).sort( (a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]), ); @@ -90,6 +93,7 @@ export function SplatoonRotations() { current={current} next={next} nextAfter={nextAfter} + now={nowUnixLive} /> ) : null, )} @@ -107,7 +111,7 @@ export function SplatoonRotations() { ? styles.rotationsModeFilterButtonActive : null, )} - onClick={() => handleFilterChange(filter)} + onClick={() => setActiveFilter(filter)} > {filter === "ALL" ? t("front:rotations.filter.all") : filter} @@ -123,7 +127,7 @@ export function SplatoonRotations() { ); } -function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) { +function useNowUnix() { const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); React.useEffect(() => { @@ -133,6 +137,15 @@ function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) { return () => clearInterval(interval); }, []); + return now; +} + +// xxx: can we use date-fns? +function timeRemaining( + now: number, + startTimeUnix: number, + endTimeUnix: number, +) { const remaining = endTimeUnix - now; if (remaining <= 0) return null; @@ -145,16 +158,8 @@ function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) { return { hours, minutes, progress }; } -function useTimeUntil(startTimeUnix: number) { - const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); - - React.useEffect(() => { - const interval = setInterval(() => { - setNow(Math.floor(Date.now() / 1000)); - }, 60_000); - return () => clearInterval(interval); - }, []); - +// xxx: can we use date-fns? +function timeUntil(now: number, startTimeUnix: number) { const diff = startTimeUnix - now; if (diff <= 0) return null; @@ -168,20 +173,23 @@ function RotationCard({ current, next, nextAfter, + now, }: { type: string; current: RotationFromLoader | undefined; next: RotationFromLoader | undefined; nextAfter: RotationFromLoader | undefined; + now: number; }) { const { t } = useTranslation(["front", "game-misc"]); - const remaining = useTimeRemaining( + const remaining = timeRemaining( + now, current?.startTime ?? 0, current?.endTime ?? 0, ); const displayRotation = current ?? next; - const nextStartsIn = useTimeUntil(next?.startTime ?? 0); - const nextAfterStartsIn = useTimeUntil(nextAfter?.startTime ?? 0); + const nextStartsIn = timeUntil(now, next?.startTime ?? 0); + const nextAfterStartsIn = timeUntil(now, nextAfter?.startTime ?? 0); const shownNext = current ? next : nextAfter; const shownNextStartsIn = current ? nextStartsIn : nextAfterStartsIn; diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 3b3f10397..54eb2572f 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -9,6 +9,7 @@ import { type SidebarStream, } from "~/features/core/streams/streams.server"; import * as FriendRepository from "~/features/friends/FriendRepository.server"; +import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import { resolveFriendActivity } from "~/features/friends/friends-utils.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server"; @@ -253,7 +254,7 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) { activity.badge ?? "", ); - if (activity.subtitle === "SendouQ") { + if (activity.subtitle === SENDOUQ_ACTIVITY_LABEL) { sendouqFriends.push(sidebarFriend); } else { // this is temporary, will be replaced with "SQified tournament team creator"