mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 21:26:08 -05:00
Cards to match pages
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const BADGE_ICON: Record<Sentiment, React.ReactNode> = {
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MainWeaponId>;
|
||||
friendCode?: string | null;
|
||||
inGameName?: string | null;
|
||||
};
|
||||
|
||||
@@ -160,10 +157,7 @@ function TeamRoster({
|
||||
<div className={styles.memberTier}>
|
||||
<MemberTierPopover tier={member.tier} />
|
||||
</div>
|
||||
<MemberMeta
|
||||
plusTier={member.plusTier}
|
||||
weaponPool={member.weaponPool}
|
||||
/>
|
||||
<MemberMeta weaponPool={member.weaponPool} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -397,27 +391,14 @@ function MemberTierPopoverContent({
|
||||
);
|
||||
}
|
||||
|
||||
function MemberMeta({
|
||||
plusTier,
|
||||
weaponPool,
|
||||
}: {
|
||||
plusTier?: number | null;
|
||||
weaponPool?: Array<MainWeaponId>;
|
||||
}) {
|
||||
const hasPlusTier = typeof plusTier === "number";
|
||||
function MemberMeta({ weaponPool }: { weaponPool?: Array<MainWeaponId> }) {
|
||||
const hasWeapons = weaponPool && weaponPool.length > 0;
|
||||
|
||||
if (!hasPlusTier && !hasWeapons) return null;
|
||||
if (!hasWeapons) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.memberMeta}>
|
||||
{hasPlusTier ? (
|
||||
<div className={styles.plusTier}>
|
||||
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
|
||||
<span>{plusTier}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
|
||||
<WeaponPool weapons={weaponPool} size={18} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<div className={styles.memberNameStack}>
|
||||
<span>{member.username}</span>
|
||||
{showIgnUnderName ? (
|
||||
<span className={styles.memberInGameName}>{member.inGameName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!useMenu) {
|
||||
return (
|
||||
<Link to={userPage(member)} className={className}>
|
||||
<Avatar user={member} size="xxs" />
|
||||
{nameContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const headerContent =
|
||||
member.friendCode || showIgnInMenu ? (
|
||||
<div className={styles.memberMenuHeader}>
|
||||
{member.friendCode ? <span>{`SW-${member.friendCode}`}</span> : null}
|
||||
{showIgnInMenu ? (
|
||||
<span className={styles.memberMenuIgn}>
|
||||
<span className={styles.memberMenuIgnLabel}>
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{member.inGameName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// xxx: after usercard everywhere, menu should no longer be necessary
|
||||
return (
|
||||
<SendouMenu
|
||||
trigger={
|
||||
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
|
||||
<UserCard userId={member.id}>
|
||||
<span className={className}>
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="xs">
|
||||
<Avatar user={member} size="xxs" />
|
||||
{nameContent}
|
||||
</ReactAriaButton>
|
||||
}
|
||||
>
|
||||
<SendouMenuSection
|
||||
headerText={headerContent}
|
||||
headerClassName={styles.friendCodeHeader}
|
||||
>
|
||||
<SendouMenuItem href={userPage(member)} icon={<User />}>
|
||||
{t("friends:friendsList.viewUserPage")}
|
||||
</SendouMenuItem>
|
||||
</SendouMenuSection>
|
||||
</SendouMenu>
|
||||
</NoteAvatar>
|
||||
<div className={styles.memberNameStack}>
|
||||
<span>{member.username}</span>
|
||||
{member.inGameName ? (
|
||||
<span className={styles.memberInGameName}>{member.inGameName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
</UserCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,7 +288,6 @@ export type ParsedMemento = {
|
||||
users: Record<
|
||||
number,
|
||||
{
|
||||
plusTier?: PlusTier["tier"];
|
||||
skill?: TieredSkill | "CALCULATING";
|
||||
skillDifference?: UserSkillDifference;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)) &&
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,6 @@ export function createMatchMemento(
|
||||
return [
|
||||
member.id,
|
||||
{
|
||||
plusTier: member.plusTier ?? undefined,
|
||||
skill:
|
||||
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -29,7 +29,6 @@ function createMember(overrides: Partial<SQGroupMember> = {}): 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,
|
||||
|
||||
@@ -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({
|
||||
<div className="stack xxs" data-testid="sendouq-group-card-member">
|
||||
<div className={styles.member}>
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
<UserCard userId={member.id}>
|
||||
<UserCard userId={member.id} withMutualFriends>
|
||||
<span className="stack xs horizontal items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cardData?.privateNote?.sentiment}
|
||||
@@ -315,12 +314,6 @@ function GroupMember({
|
||||
<VoiceChatInfo member={member} />
|
||||
</div>
|
||||
) : null}
|
||||
{member.plusTier ? (
|
||||
<div className={styles.extraInfo}>
|
||||
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
|
||||
{member.plusTier}
|
||||
</div>
|
||||
) : null}
|
||||
{member.friendCode ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
||||
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { Status } from "~/modules/brackets-model";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
@@ -218,6 +219,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
!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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function userCards({
|
||||
includeHiddenStats = false,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
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 };
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<UserCardStat["type"], number> = {
|
||||
@@ -63,16 +61,21 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
|
||||
* 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({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{isOwnCard ? null : <CardMutualFriends friendship={friendship} />}
|
||||
{isOwnCard || !withMutualFriends ? null : (
|
||||
<CardMutualFriends friendship={friendship} />
|
||||
)}
|
||||
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
|
||||
<LinkButton
|
||||
to={userPage(data)}
|
||||
|
||||
@@ -9,10 +9,13 @@ export type UserCardFriendshipLoaderData = SerializeFrom<typeof loader>;
|
||||
/**
|
||||
* 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<UserCardFriendship> => {
|
||||
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 {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user