diff --git a/app/components/NoteAvatar.module.css b/app/components/NoteAvatar.module.css index 27822064a..e423c8d8b 100644 --- a/app/components/NoteAvatar.module.css +++ b/app/components/NoteAvatar.module.css @@ -42,6 +42,17 @@ } } +.badgeXs { + width: 0.65rem; + height: 0.65rem; + border-width: 1.5px; + + & > svg { + width: 0.45rem; + height: 0.45rem; + } +} + .positive { background-color: var(--color-success); } diff --git a/app/components/NoteAvatar.tsx b/app/components/NoteAvatar.tsx index e572c2938..01a099cbf 100644 --- a/app/components/NoteAvatar.tsx +++ b/app/components/NoteAvatar.tsx @@ -19,6 +19,7 @@ const BADGE_ICON: Record = { }; const SIZE_CLASS = { + xs: styles.badgeXs, sm: styles.badgeSm, md: styles.badgeMd, } as const; @@ -27,7 +28,7 @@ const SIZE_CLASS = { * Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when * `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the * children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match - * the wrapped avatar (`sm` for small avatars, `md` for large ones). + * the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones). */ export function NoteAvatar({ sentiment, diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index 60755fd37..b2643955f 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -134,37 +134,6 @@ font-weight: var(--weight-semi); } -.memberMenuTrigger { - background: none; - border: 0; - padding: 0; - color: inherit; - font: inherit; - text-align: inherit; - cursor: pointer; -} - -.friendCodeHeader { - text-align: center; -} - -.memberMenuHeader { - display: flex; - flex-direction: column; - gap: var(--s-0-5); -} - -.memberMenuIgn { - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.memberMenuIgnLabel { - font-weight: var(--weight-bold); - text-transform: uppercase; - font-size: var(--font-3xs); -} - .memberTier { display: flex; justify-content: center; @@ -179,18 +148,6 @@ font-size: var(--font-2xs); } -.plusTier { - display: flex; - align-items: center; - gap: var(--s-0-5); - background-color: var(--color-bg-higher); - border-radius: var(--radius-full); - padding: var(--s-0-5) var(--s-1-5); - padding-inline-start: var(--s-1); - font-weight: var(--weight-semi); - color: var(--color-text); -} - .subbedOutTrigger { display: flex; align-items: center; diff --git a/app/components/match-page/MatchRosterTab.tsx b/app/components/match-page/MatchRosterTab.tsx index ab54b76da..51ae546a5 100644 --- a/app/components/match-page/MatchRosterTab.tsx +++ b/app/components/match-page/MatchRosterTab.tsx @@ -1,23 +1,22 @@ import clsx from "clsx"; -import { Armchair, Edit, User } from "lucide-react"; +import { Armchair, Edit } from "lucide-react"; import { useState } from "react"; -import { Button as ReactAriaButton } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; -import { - SendouMenu, - SendouMenuItem, - SendouMenuSection, -} from "~/components/elements/Menu"; import { SendouPopover } from "~/components/elements/Popover"; import { Image, TierImage } from "~/components/Image"; +import { NoteAvatar } from "~/components/NoteAvatar"; import type { TierName } from "~/features/mmr/mmr-constants"; +import { + UserCard, + useUserCardData, +} from "~/features/user-card/components/UserCard"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; import type { CommonUser } from "~/utils/kysely.server"; -import { navIconUrl, tierImageUrl, userPage } from "~/utils/urls"; +import { tierImageUrl } from "~/utils/urls"; import { SendouTabPanel } from "../elements/Tabs"; import styles from "./MatchRosterTab.module.css"; import { TAB_KEYS } from "./MatchTabs"; @@ -25,9 +24,7 @@ import { WeaponPool } from "./WeaponPool"; type RosterTabMember = CommonUser & { tier?: { name: TierName; isPlus: boolean } | "CALCULATING"; - plusTier?: number | null; weaponPool?: Array; - friendCode?: string | null; inGameName?: string | null; }; @@ -160,10 +157,7 @@ function TeamRoster({
- + ))} @@ -397,27 +391,14 @@ function MemberTierPopoverContent({ ); } -function MemberMeta({ - plusTier, - weaponPool, -}: { - plusTier?: number | null; - weaponPool?: Array; -}) { - const hasPlusTier = typeof plusTier === "number"; +function MemberMeta({ weaponPool }: { weaponPool?: Array }) { const hasWeapons = weaponPool && weaponPool.length > 0; - if (!hasPlusTier && !hasWeapons) return null; + if (!hasWeapons) return null; return (
- {hasPlusTier ? ( -
- - {plusTier} -
- ) : null} - {hasWeapons ? : null} +
); } @@ -459,68 +440,21 @@ function RosterMemberLink({ member: RosterTabMember; className?: string; }) { - const { t } = useTranslation(["friends", "q", "user"]); + const cardData = useUserCardData(member.id); - const hasContentBelowName = !!( - member.tier || - typeof member.plusTier === "number" || - (member.weaponPool && member.weaponPool.length > 0) - ); - const showIgnInMenu = hasContentBelowName && !!member.inGameName; - const showIgnUnderName = !hasContentBelowName && !!member.inGameName; - const useMenu = !!member.friendCode || showIgnInMenu; - - const nameContent = ( -
- {member.username} - {showIgnUnderName ? ( - {member.inGameName} - ) : null} -
- ); - - if (!useMenu) { - return ( - - - {nameContent} - - ); - } - - const headerContent = - member.friendCode || showIgnInMenu ? ( -
- {member.friendCode ? {`SW-${member.friendCode}`} : null} - {showIgnInMenu ? ( - - - {t("user:ign.short")}: - {" "} - {member.inGameName} - - ) : null} -
- ) : undefined; - - // xxx: after usercard everywhere, menu should no longer be necessary return ( - + + + - {nameContent} - - } - > - - }> - {t("friends:friendsList.viewUserPage")} - - - + +
+ {member.username} + {member.inGameName ? ( + {member.inGameName} + ) : null} +
+ + ); } diff --git a/app/db/tables.ts b/app/db/tables.ts index f24dbf0d6..ca82b5ddb 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -288,7 +288,6 @@ export type ParsedMemento = { users: Record< number, { - plusTier?: PlusTier["tier"]; skill?: TieredSkill | "CALCULATING"; skillDifference?: UserSkillDifference; } diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index 7bfc98707..dd4f52350 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -196,7 +196,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: "sendou", tier: { name: "LEVIATHAN", isPlus: true }, - plusTier: 1, weaponPool: [0, 2000, 4000], customAvatarUrl: null, }, @@ -207,7 +206,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: null, tier: { name: "DIAMOND", isPlus: false }, - plusTier: 2, weaponPool: [20, 1100], customAvatarUrl: null, }, @@ -250,7 +248,6 @@ export default function MatchPageTestRoute() { discordAvatar: null, customUrl: null, tier: { name: "PLATINUM", isPlus: false }, - plusTier: 3, weaponPool: [40, 3000], customAvatarUrl: null, }, diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 653f369cf..bf8ad906f 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; import { notFoundIfFalsy } from "../../../utils/remix.server"; @@ -36,6 +37,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const mapByMap = await resolveMapByMap({ post, user }); return { + ...(await UserCardRepository.userCards({ + userIds: participantIds, + viewerId: user.id, + include: { friendCode: true }, + })), post, chatCode: (user.roles.includes("STAFF") || participantIds.includes(user.id)) && diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx index d962ee2a7..a92e8d5cd 100644 --- a/app/features/scrims/routes/scrims.$id.tsx +++ b/app/features/scrims/routes/scrims.$id.tsx @@ -11,7 +11,7 @@ import { loader } from "../loaders/scrims.$id.server"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["scrims", "q"], + i18n: ["scrims", "q", "user"], breadcrumb: () => ({ imgPath: navIconUrl("scrims"), href: scrimsPage(), diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 5d29c7d4b..d1adde32c 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -123,7 +123,6 @@ function groupWithTeamAndMembers( eb .selectFrom("GroupMember") .innerJoin("User", "User.id", "GroupMember.userId") - .leftJoin("PlusTier", "User.id", "PlusTier.userId") .leftJoin("GroupMatchContinueVote", (join) => join .onRef( @@ -148,7 +147,6 @@ function groupWithTeamAndMembers( "User.noScreen", matchProfileWeapons(arrayEb).as("weapons"), "User.mapModePreferences", - "PlusTier.tier as plusTier", "GroupMatchContinueVote.isContinuing", arrayEb .selectFrom("UserFriendCode") diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx index 611e4b3bc..b451bf0b4 100644 --- a/app/features/sendouq-match/components/SendouQMatchTabs.tsx +++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx @@ -164,9 +164,7 @@ function mapRosterMembers(members: MatchData["groupAlpha"]["members"]) { member.skill === "CALCULATING" ? ("CALCULATING" as const) : member.skill?.tier, - plusTier: member.plusTier ?? undefined, weaponPool: member.weapons?.map((w) => w.weaponSplId), - friendCode: member.friendCode, })); } diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index e2e167e66..44d55b913 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -383,7 +383,6 @@ export function createMatchMemento( return [ member.id, { - plusTier: member.plusTier ?? undefined, skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill, }, diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 36b163ada..18b7cad22 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -5,6 +5,7 @@ import * as Seasons from "~/features/mmr/core/Seasons"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; @@ -34,6 +35,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const match = SendouQ.mapMatch(matchUnmapped, user); return { + ...(await UserCardRepository.userCards({ + userIds: matchUsers, + viewerId: user?.id ?? null, + include: { friendCode: true }, + })), match, reportedWeapons, isOffSeason: Seasons.current() === null, diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index ae854a5b6..ea3d4e497 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -30,7 +30,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["q"], + i18n: ["q", "user"], breadcrumb: () => ({ imgPath: navIconUrl("sendouq"), href: SENDOUQ_PAGE, diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 9145397b3..73728b5e4 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -72,14 +72,12 @@ export async function findCurrentGroups() { isTenStar: number; })[] | null; - plusTier: Tables["PlusTier"]["tier"] | null; }; return db .selectFrom("Group") .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") .innerJoin("User", "User.id", "GroupMember.userId") - .leftJoin("PlusTier", "PlusTier.userId", "User.id") .leftJoin("GroupMatch", (join) => join.on((eb) => eb.or([ @@ -112,7 +110,6 @@ export async function findCurrentGroups() { note: eb.ref("GroupMember.note"), weapons: matchProfileWeapons(eb), languages: eb.ref("User.languages"), - plusTier: eb.ref("PlusTier.tier"), vc: eb.ref("User.vc"), }), ]) diff --git a/app/features/sendouq/components/GroupCard.browser.test.tsx b/app/features/sendouq/components/GroupCard.browser.test.tsx index 32464a154..c614aa77a 100644 --- a/app/features/sendouq/components/GroupCard.browser.test.tsx +++ b/app/features/sendouq/components/GroupCard.browser.test.tsx @@ -29,7 +29,6 @@ function createMember(overrides: Partial = {}): SQGroupMember { languages: [], skill: "CALCULATING", weapons: [], - plusTier: null, friendCode: null, inGameName: null, note: null, @@ -82,7 +81,6 @@ function createOwnGroupMember( languages: [], skill: "CALCULATING", weapons: [], - plusTier: null, friendCode: null, inGameName: null, note: null, diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index baf74ac99..88e65c99b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -24,7 +24,6 @@ import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import { - navIconUrl, SENDOUQ_LOOKING_PAGE, specialWeaponImageUrl, TIERS_PAGE, @@ -269,7 +268,7 @@ function GroupMember({
- +
) : null} - {member.plusTier ? ( -
- - {member.plusTier} -
- ) : null} {member.friendCode ? ( { !isLeagueRoundLocked(tournament, match.roundId); return { + ...(await UserCardRepository.userCards({ + userIds: match.players.map((p) => p.id), + viewerId: user?.id ?? null, + include: { friendCode: true }, + })), match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined }, results, reportedWeapons, diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx index 92329cb78..b3ade903b 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -15,7 +15,7 @@ import { tournamentMatchWebsocketRoom } from "../tournament-match-utils"; export { action, loader }; export const handle: SendouRouteHandle = { - i18n: ["q"], + i18n: ["q", "user"], }; export default function TournamentMatchPage() { diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts index 1b0ad1c35..1084b43e6 100644 --- a/app/features/user-card/UserCardRepository.server.ts +++ b/app/features/user-card/UserCardRepository.server.ts @@ -47,7 +47,7 @@ export async function userCards({ includeHiddenStats = false, }: { userIds: Array; - viewerId: number | null; + viewerId: number | null; // xxx: use actorId /** Opt-in fields skipped from the query by default; defaults to `false` each. */ include?: { friendCode?: boolean }; /** diff --git a/app/features/user-card/components/UserCard.module.css b/app/features/user-card/components/UserCard.module.css index de24e5bd0..c6dda5422 100644 --- a/app/features/user-card/components/UserCard.module.css +++ b/app/features/user-card/components/UserCard.module.css @@ -24,7 +24,7 @@ position: relative; display: flex; flex-direction: column; - gap: var(--s-3); + gap: var(--s-5); width: 18rem; max-width: calc(100vw - var(--s-4)); padding: 0 var(--s-4) var(--s-4); diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index 423bf1331..514c82ad8 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -46,8 +46,6 @@ import type { import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog"; import styles from "./UserCard.module.css"; -// xxx: also secondary action? e.g. "View tournament" from sidebar - const TENTATEK_BRAND_ID: BrandId = "B10"; const STAT_ORDER: Record = { @@ -63,16 +61,21 @@ const STAT_ORDER: Record = { * pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists * for the user, the `children` are rendered plain without a trigger. * - * Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the - * `/user-card/:id/friendship` route the first time the card opens. + * Viewer-relative friendship data (`isFriend`) is lazy-loaded from the `/user-card/:id/friendship` + * route the first time the card opens. Mutual friends are only fetched and shown when + * `withMutualFriends` is set (e.g. the SendouQ looking page); other views (e.g. match pages) skip + * both the extra query and the row. */ export function UserCard({ userId, data: dataProp, + withMutualFriends = false, children, }: { userId?: number; data?: UserCardData; + /** Fetch and show the mutual friends row. Off by default. */ + withMutualFriends?: boolean; children: React.ReactNode; }) { const { t } = useTranslation(["common", "q"]); @@ -98,8 +101,8 @@ export function UserCard({ if (typeof data?.id !== "number") return; friendshipLoadedRef.current = true; - fetcher.load(userCardFriendshipPage(data.id)); - }, [isOpen, isOwnCard, data?.id, fetcher.load]); + fetcher.load(userCardFriendshipPage(data.id, { withMutualFriends })); + }, [isOpen, isOwnCard, data?.id, withMutualFriends, fetcher.load]); const friendship = fetcher.data; @@ -126,6 +129,7 @@ export function UserCard({ data={data} friendship={friendship} isOwnCard={isOwnCard} + withMutualFriends={withMutualFriends} onEditNote={openNoteDialog} onDeleteNote={openDeleteConfirm} /> @@ -181,6 +185,7 @@ function CardContent({ data, friendship, isOwnCard, + withMutualFriends, onEditNote, onDeleteNote, }: { @@ -188,6 +193,7 @@ function CardContent({ /** Lazy-loaded; `undefined` while the friendship fetch is in flight. */ friendship: UserCardFriendship | undefined; isOwnCard: boolean; + withMutualFriends: boolean; onEditNote: () => void; onDeleteNote: () => void; }) { @@ -287,7 +293,9 @@ function CardContent({ ))}
) : null} - {isOwnCard ? null : } + {isOwnCard || !withMutualFriends ? null : ( + + )} {data.shortBio ?

{data.shortBio}

: null} ; /** * Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard` * popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card - * query). Resolves to empty values when there is no logged-in viewer. + * query). Resolves to empty values when there is no logged-in viewer. Mutual friends + * are only resolved when the card opts in via the `mutuals=true` query param (some + * views, e.g. match pages, don't surface them), so the extra query is otherwise skipped. */ export const loader = async ({ params, + request, }: LoaderFunctionArgs): Promise => { const viewer = getUser(); const targetUserId = Number(params.id); @@ -25,6 +28,9 @@ export const loader = async ({ }; } + const withMutualFriends = + new URL(request.url).searchParams.get("mutuals") === "true"; + const [friendship, pendingRequest, mutualFriends] = await Promise.all([ FriendRepository.findFriendship({ userOneId: viewer.id, @@ -34,10 +40,12 @@ export const loader = async ({ senderId: viewer.id, receiverId: targetUserId, }), - FriendRepository.findMutualFriends({ - loggedInUserId: viewer.id, - targetUserId, - }), + withMutualFriends + ? FriendRepository.findMutualFriends({ + loggedInUserId: viewer.id, + targetUserId, + }) + : [], ]); return { diff --git a/app/utils/urls.ts b/app/utils/urls.ts index b20942da8..321841248 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -151,8 +151,13 @@ export const PATRONS_LIST_ROUTE = "/patrons-list"; export const NOTIFICATIONS_URL = "/notifications"; export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen"; -export const userCardFriendshipPage = (userId: number) => - `/user-card/${userId}/friendship`; +export const userCardFriendshipPage = ( + userId: number, + args?: { withMutualFriends?: boolean }, +) => + `/user-card/${userId}/friendship${ + args?.withMutualFriends ? "?mutuals=true" : "" + }`; export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;