This commit is contained in:
Kalle
2026-07-01 19:33:14 +03:00
parent 224f0c3d32
commit 37c0201eba
18 changed files with 349 additions and 226 deletions

View File

@@ -7,8 +7,9 @@
.badge {
position: absolute;
bottom: -2px;
left: -2px;
bottom: 15%;
left: 15%;
transform: translate(-50%, 50%);
display: grid;
place-items: center;
border-radius: var(--radius-full);

View File

@@ -29,7 +29,6 @@ const SIZE_CLASS = {
* 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).
*/
// xxx: same position for the icon no matter if big or small
export function NoteAvatar({
sentiment,
size = "md",

View File

@@ -1588,6 +1588,7 @@ const USER_CARD_DATA = {
privateNote: {
text: "Played with them, very friendly",
sentiment: "POSITIVE",
updatedAt: 1704067200,
},
stats: [
{
@@ -1608,7 +1609,6 @@ const USER_CARD_DATA = {
{ type: "DIV", value: "1" },
{ type: "PLUS", value: 1 },
],
hiddenStats: [],
} satisfies UserCardData;
function UserCardSection({ id }: { id: string }) {

View File

@@ -39,8 +39,6 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
import { resolveFutureMatchModes } from "../q-utils";
import styles from "./GroupCard.module.css";
// xxx: red cross to indicate negative note left?
export function GroupCard({
group,
action,

View File

@@ -44,7 +44,6 @@ const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
const SECONDS_TILL_STALE =
process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800;
// xxx: probably just export sort static method that takes in groups and private notes. currently sorting is in several places
class SendouQClass {
groups;
#recentMatches;

View File

@@ -247,6 +247,42 @@ describe("refreshTenStarWeapons", () => {
});
});
describe("verifiedPeakXpByUserId", () => {
beforeEach(() => {
placementCounter = 0;
dbReset();
});
afterEach(() => {
dbReset();
});
test("reports a linked player's peak xp", async () => {
const userId = await createUser("user1");
expect(
await XRankPlacementRepository.verifiedPeakXpByUserId(userId),
).toBeNull();
await db
.insertInto("SplatoonPlayer")
.values({
userId,
splId: "spl-1",
peakXp: JSON.stringify({
overall: 2800,
takoroka: 2800,
tentatek: null,
}),
})
.execute();
expect(await XRankPlacementRepository.verifiedPeakXpByUserId(userId)).toBe(
2800,
);
});
});
describe("refreshTenStarWeapons with userId", () => {
beforeEach(() => {
placementCounter = 0;

View File

@@ -13,6 +13,22 @@ export function unlinkPlayerByUserId(userId: number) {
.execute();
}
/**
* Overall verified peak XP of the user's linked Splatoon player, or `null` when no player is linked.
* Used to bound how high a linked user may self-report their unverified peak XP.
*/
export async function verifiedPeakXpByUserId(
userId: number,
): Promise<number | null> {
const player = await db
.selectFrom("SplatoonPlayer")
.select("SplatoonPlayer.peakXp")
.where("SplatoonPlayer.userId", "=", userId)
.executeTakeFirst();
return player?.peakXp?.overall ?? null;
}
function xRankPlacementsQueryBase() {
return db
.selectFrom("XRankPlacement")

View File

@@ -90,13 +90,37 @@ describe("UserCardRepository.userCards", () => {
expect(card?.shortBio).toBe("hello");
expect(card?.banner).toMatchObject({ type: "COLOR", hexCode: "#ff4655" });
expect(card?.hiddenStats).toEqual(["XP"]);
// xxx: or filter out at query time?
// the hidden stat is still present in `stats` (filtered out at render time)
// the hidden stat is filtered out of `stats` at query time
expect(card?.stats.find((stat) => stat.type === "XP")).toBeUndefined();
expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
type: "PLUS",
value: 2,
});
const extras = await UserCardRepository.cardEditExtras(1);
expect(extras.hiddenCardStats).toEqual(["XP"]);
});
it("keeps hidden stats in `stats` when includeHiddenStats is set", async () => {
await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
await withUserId(1, () =>
UserCardRepository.updateOwnCard({
shortBio: null,
bannerPresetImg: null,
bannerImgId: null,
unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 },
hiddenCardStats: ["XP"],
}),
);
const { userCards } = await UserCardRepository.userCards({
userIds: [1],
viewerId: 1,
includeHiddenStats: true,
});
const card = userCards.get(1);
expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
type: "XP",
values: [{ isVerified: false, region: "WEST", points: 2500 }],
@@ -129,23 +153,4 @@ describe("UserCardRepository.userCards", () => {
expect(banner?.type).toBe("URL");
expect(banner).toHaveProperty("url");
});
it("reports a linked player's peak xp", async () => {
expect(await UserCardRepository.linkedPlayerPeakXp(1)).toBeNull();
await db
.insertInto("SplatoonPlayer")
.values({
userId: 1,
splId: "spl-1",
peakXp: JSON.stringify({
overall: 2800,
takoroka: 2800,
tentatek: null,
}),
})
.execute();
expect(await UserCardRepository.linkedPlayerPeakXp(1)).toBe(2800);
});
});

View File

@@ -16,6 +16,7 @@ import * as Seasons from "~/features/mmr/core/Seasons";
import { TIERS } from "~/features/mmr/mmr-constants";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { userSkills } from "~/features/mmr/tiered.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import type { StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
@@ -43,11 +44,17 @@ export async function userCards({
userIds,
viewerId,
include,
includeHiddenStats = false,
}: {
userIds: Array<number>;
viewerId: number | null;
/** Opt-in fields skipped from the query by default; defaults to `false` each. */
include?: { friendCode?: boolean };
/**
* Keep stats the user has hidden in the resolved `stats` array. Off by default so hidden stat
* values never reach a viewer; the edit page opts in to render (and un-hide) its own toggles.
*/
includeHiddenStats?: boolean;
}): Promise<{ userCards: Map<number, UserCardData> }> {
if (userIds.length === 0) return { userCards: new Map() };
@@ -73,6 +80,7 @@ export async function userCards({
enrichUserCardData(
cardData,
bestSeasonResult(cardData.id, seasonResults),
includeHiddenStats,
),
);
}
@@ -80,27 +88,11 @@ export async function userCards({
return { userCards };
}
/**
* Overall verified peak XP of the user's linked Splatoon player, or `null` when no player is linked.
* Used to bound how high a linked user may self-report their unverified peak XP.
*/
// xxx: different file?
export async function linkedPlayerPeakXp(
userId: number,
): Promise<number | null> {
const player = await db
.selectFrom("SplatoonPlayer")
.select("SplatoonPlayer.peakXp")
.where("SplatoonPlayer.userId", "=", userId)
.executeTakeFirst();
return player?.peakXp?.overall ?? null;
}
/**
* Raw card fields the edit form needs that are not part of {@link UserCardData}: the uploaded banner
* image (id + preview url, for the image field's default value), the self-reported peak XP, and the
* linked player's verified peak XP (to display the XP input's max hint).
* image (id + preview url, for the image field's default value), the self-reported peak XP, the
* hidden stat types (to pre-check the visibility toggles), and the linked player's verified peak XP
* (to display the XP input's max hint).
*/
export async function cardEditExtras(userId: number) {
const row = await db
@@ -108,6 +100,7 @@ export async function cardEditExtras(userId: number) {
.select((eb) => [
"User.bannerImgId",
"User.unverifiedPeakXP",
"User.hiddenCardStats",
bannerImageUrl(eb).as("bannerImageUrl"),
])
.where("User.id", "=", userId)
@@ -117,7 +110,9 @@ export async function cardEditExtras(userId: number) {
bannerImgId: row?.bannerImgId ?? null,
bannerImageUrl: row?.bannerImageUrl ?? null,
unverifiedPeakXP: row?.unverifiedPeakXP ?? null,
linkedPlayerPeakXp: await linkedPlayerPeakXp(userId),
hiddenCardStats: row?.hiddenCardStats ?? [],
linkedPlayerPeakXp:
await XRankPlacementRepository.verifiedPeakXpByUserId(userId),
};
}
@@ -270,14 +265,18 @@ function privateNoteJson(
if (viewerId === null) {
return sql<Pick<
Tables["PrivateUserNote"],
"text" | "sentiment"
"text" | "sentiment" | "updatedAt"
> | null>`null`;
}
return jsonObjectFrom(
eb
.selectFrom("PrivateUserNote")
.select(["PrivateUserNote.text", "PrivateUserNote.sentiment"])
.select([
"PrivateUserNote.text",
"PrivateUserNote.sentiment",
"PrivateUserNote.updatedAt",
])
.where("PrivateUserNote.authorId", "=", viewerId)
.whereRef("PrivateUserNote.targetId", "=", "User.id"),
);
@@ -446,7 +445,20 @@ function enrichUserCardData(
seasonSkill,
seasonTop,
}: { seasonSkill: TieredSkill | undefined; seasonTop: number | null },
includeHiddenStats: boolean,
): UserCardData {
const hiddenStats: Array<UserCardStat["type"]> =
cardData.hiddenCardStats ?? [];
const stats = userCardStats({
div: cardData.div,
plusTier: cardData.plusTier,
xpVerified: cardData.xpVerified,
xpUnverified: cardData.xpUnverified,
seasonSkill,
seasonTop,
});
return {
id: cardData.id,
username: cardData.username,
@@ -460,15 +472,9 @@ function enrichUserCardData(
friendCode: cardData.friendCode,
freeAgentPostId: cardData.freeAgentPostId,
privateNote: cardData.privateNote,
hiddenStats: cardData.hiddenCardStats ?? [],
stats: userCardStats({
div: cardData.div,
plusTier: cardData.plusTier,
xpVerified: cardData.xpVerified,
xpUnverified: cardData.xpUnverified,
seasonSkill,
seasonTop,
}),
stats: includeHiddenStats
? stats
: stats.filter((stat) => !hiddenStats.includes(stat.type)),
};
}

View File

@@ -0,0 +1,106 @@
import { type ActionFunction, redirect } from "react-router";
import type { HideableUserCardStat } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { userPage } from "~/utils/urls";
import * as UserCardRepository from "../UserCardRepository.server";
import { updateUserCardSchema } from "../user-card-schemas";
import { maxUnverifiedXp } from "../user-card-utils";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const returnTo = safeReturnTo(
new URL(request.url).searchParams.get("returnTo"),
);
const result = await parseFormDataWithImages({
request,
schema: updateUserCardSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
if (data.unverifiedXpPoints) {
const linkedPeakXp = await XRankPlacementRepository.verifiedPeakXpByUserId(
user.id,
);
if (data.unverifiedXpPoints > maxUnverifiedXp(linkedPeakXp)) {
return {
fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" },
};
}
}
// xxx: just autovalidate and prevent input from the boundary
const isSupporter = Boolean(user.roles?.includes("SUPPORTER"));
await UserCardRepository.updateOwnCard({
shortBio: data.shortBio || null,
...resolveBanner({ ...data, isSupporter }),
unverifiedPeakXP: data.unverifiedXpPoints
? {
overall: data.unverifiedXpPoints,
tentatek:
data.unverifiedXpDivision === "WEST"
? data.unverifiedXpPoints
: null,
takoroka:
data.unverifiedXpDivision === "JPN"
? data.unverifiedXpPoints
: null,
}
: null,
hiddenCardStats: resolveHiddenStats(data),
});
throw redirect(returnTo ?? userPage(user));
};
function safeReturnTo(value: string | null) {
if (!value) return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
return value;
}
function resolveBanner({
bannerType,
bannerColor,
bannerStageId,
bannerImage,
isSupporter,
}: {
bannerType: "COLOR" | "STAGE" | "URL";
bannerColor: string;
bannerStageId: number;
bannerImage: number | null;
isSupporter: boolean;
}): { bannerPresetImg: string | null; bannerImgId: number | null } {
switch (bannerType) {
case "STAGE":
return { bannerPresetImg: String(bannerStageId), bannerImgId: null };
case "URL":
return {
bannerPresetImg: null,
bannerImgId: isSupporter ? bannerImage : null,
};
default:
return { bannerPresetImg: bannerColor, bannerImgId: null };
}
}
function resolveHiddenStats(data: {
hideXp: boolean;
hideDiv: boolean;
}): Array<HideableUserCardStat> {
return [
data.hideXp ? ("XP" as const) : null,
data.hideDiv ? ("DIV" as const) : null,
].filter((stat) => stat !== null);
}

View File

@@ -203,9 +203,20 @@
gap: var(--s-2);
}
.noteHeaderGroup {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.noteHeader {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
color: var(--color-text);
}
.noteDate {
font-size: var(--font-2xs);
color: var(--color-text-high);
}

View File

@@ -18,6 +18,7 @@ import { LinkButton, SendouButton } from "~/components/elements/Button";
import { toastQueue } from "~/components/elements/Toast";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, TierImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { NoteAvatar } from "~/components/NoteAvatar";
import { Placement } from "~/components/Placement";
import type { XRankPlacementRegion } from "~/db/tables";
@@ -68,6 +69,8 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
* Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the
* `/user-card/:id/friendship` route the first time the card opens.
*/
// xxx: make click to open, arrows to scroll which one is selected (logical order on the page?)
export function UserCard({
userId,
data: dataProp,
@@ -238,7 +241,7 @@ export function UserCard({
if (!open) setOpenedByTouch(false);
}}
isNonModal
placement="bottom"
placement="right"
className={styles.popover}
>
<CardContent
@@ -333,9 +336,9 @@ function CardContent({
const [isNoteOpen, setIsNoteOpen] = React.useState(false);
const stats = data.stats
.filter((stat) => !data.hiddenStats.includes(stat.type))
.toSorted((a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type]);
const stats = data.stats.toSorted(
(a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type],
);
const editPageUrl = userCardEditPage({
returnTo: `${location.pathname}${location.search}`,
@@ -458,7 +461,17 @@ function NoteView({
return (
<div className={styles.noteView}>
<span className={styles.noteHeader}>{t("user:card.privateNote")}</span>
<div className={styles.noteHeaderGroup}>
<span className={styles.noteHeader}>{t("user:card.privateNote")}</span>
{note ? (
<LocaleTime
date={note.updatedAt}
options={{ day: "numeric", month: "numeric", year: "numeric" }}
className={styles.noteDate}
inline
/>
) : null}
</div>
{note?.text ? <p className={styles.noteText}>{note.text}</p> : null}
<div className={styles.noteViewActions}>
<SendouButton
@@ -568,7 +581,10 @@ function CardMutualFriends({
{t("user:card.noMutualFriends")}
</span>
) : (
<MutualFriends mutualFriends={friendship.mutualFriends} />
<MutualFriends
mutualFriends={friendship.mutualFriends}
withoutPopover
/>
)}
</div>
);

View File

@@ -0,0 +1,28 @@
import { requireUser } from "~/features/auth/core/user.server";
import invariant from "~/utils/invariant";
import * as UserCardRepository from "../UserCardRepository.server";
import { maxUnverifiedXp } from "../user-card-utils";
export const loader = async () => {
const user = requireUser();
const [{ userCards }, extras] = await Promise.all([
UserCardRepository.userCards({
userIds: [user.id],
viewerId: user.id,
includeHiddenStats: true,
}),
UserCardRepository.cardEditExtras(user.id),
]);
const card = userCards.get(user.id);
invariant(card, "card data not found for own user");
return {
card,
extras,
isSupporter: Boolean(user.roles?.includes("SUPPORTER")),
maxUnverifiedXp: maxUnverifiedXp(extras.linkedPlayerPeakXp),
presentStats: card.stats.map((stat) => stat.type),
};
};

View File

@@ -1,29 +1,26 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import {
type ActionFunction,
type MetaFunction,
redirect,
useLoaderData,
useSearchParams,
} from "react-router";
import { Main } from "~/components/Main";
import type { HideableUserCardStat, XRankPlacementRegion } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import type { XRankPlacementRegion } from "~/db/tables";
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
import { existingImage } from "~/form/image-field";
import { parseFormDataWithImages } from "~/form/parse.server";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userCardEditPage, userPage } from "~/utils/urls";
import { userCardEditPage } from "~/utils/urls";
import { PRESET_COLORS } from "../../tier-list-maker/tier-list-maker-constants";
import * as UserCardRepository from "../UserCardRepository.server";
import { USER_CARD } from "../user-card-constants";
import { action } from "../actions/user-card.edit.server";
import { loader } from "../loaders/user-card.edit.server";
import { updateUserCardSchema } from "../user-card-schemas";
import styles from "./user-card.edit.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["user"],
};
@@ -35,129 +32,6 @@ export const meta: MetaFunction = (args) => {
});
};
// xxx: loader to different file, project convention
export const loader = async () => {
const user = requireUser();
const [{ userCards }, extras] = await Promise.all([
UserCardRepository.userCards({ userIds: [user.id], viewerId: user.id }),
UserCardRepository.cardEditExtras(user.id),
]);
const card = userCards.get(user.id);
invariant(card, "card data not found for own user");
return {
card,
extras,
isSupporter: Boolean(user.roles?.includes("SUPPORTER")),
maxUnverifiedXp: maxUnverifiedXp(extras.linkedPlayerPeakXp),
presentStats: card.stats.map((stat) => stat.type),
};
};
// xxx: action to different file, project convention
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const returnTo = safeReturnTo(
new URL(request.url).searchParams.get("returnTo"),
);
const result = await parseFormDataWithImages({
request,
schema: updateUserCardSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
if (data.unverifiedXpPoints) {
const linkedPeakXp = await UserCardRepository.linkedPlayerPeakXp(user.id);
if (data.unverifiedXpPoints > maxUnverifiedXp(linkedPeakXp)) {
return {
fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" },
};
}
}
// xxx: just autovalidate and prevent input from the boundary
const isSupporter = Boolean(user.roles?.includes("SUPPORTER"));
await UserCardRepository.updateOwnCard({
shortBio: data.shortBio || null,
...resolveBanner({ ...data, isSupporter }),
unverifiedPeakXP: data.unverifiedXpPoints
? {
overall: data.unverifiedXpPoints,
tentatek:
data.unverifiedXpDivision === "WEST"
? data.unverifiedXpPoints
: null,
takoroka:
data.unverifiedXpDivision === "JPN"
? data.unverifiedXpPoints
: null,
}
: null,
hiddenCardStats: resolveHiddenStats(data),
});
throw redirect(returnTo ?? userPage(user));
};
function safeReturnTo(value: string | null) {
if (!value) return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
return value;
}
function maxUnverifiedXp(linkedPeakXp: number | null) {
return typeof linkedPeakXp === "number"
? linkedPeakXp + USER_CARD.MAX_UNVERIFIED_XP_ABOVE_LINKED_PLAYER
: USER_CARD.MAX_UNVERIFIED_XP_WITHOUT_LINKED_PLAYER;
}
function resolveBanner({
bannerType,
bannerColor,
bannerStageId,
bannerImage,
isSupporter,
}: {
bannerType: "COLOR" | "STAGE" | "URL";
bannerColor: string;
bannerStageId: number;
bannerImage: number | null;
isSupporter: boolean;
}): { bannerPresetImg: string | null; bannerImgId: number | null } {
switch (bannerType) {
case "STAGE":
return { bannerPresetImg: String(bannerStageId), bannerImgId: null };
case "URL":
return {
bannerPresetImg: null,
bannerImgId: isSupporter ? bannerImage : null,
};
default:
return { bannerPresetImg: bannerColor, bannerImgId: null };
}
}
function resolveHiddenStats(data: {
hideXp: boolean;
hideDiv: boolean;
}): Array<HideableUserCardStat> {
return [
data.hideXp ? ("XP" as const) : null,
data.hideDiv ? ("DIV" as const) : null,
].filter((stat) => stat !== null);
}
export default function UserCardEditPage() {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
@@ -196,8 +70,8 @@ function defaultValues(data: Awaited<ReturnType<typeof loader>>) {
unverifiedXpDivision: (typeof peakXp?.takoroka === "number"
? "JPN"
: "WEST") as XRankPlacementRegion,
hideXp: card.hiddenStats.includes("XP"),
hideDiv: card.hiddenStats.includes("DIV"),
hideXp: extras.hiddenCardStats.includes("XP"),
hideDiv: extras.hiddenCardStats.includes("DIV"),
};
}

View File

@@ -11,10 +11,11 @@ export interface UserCardData extends CommonUser {
/** Id of the user's free agent LFG post, or `null` if they have none. */
freeAgentPostId: number | null;
/** The viewer's private note about this user, or `null` when they have none. */
privateNote: Pick<Tables["PrivateUserNote"], "text" | "sentiment"> | null;
privateNote: Pick<
Tables["PrivateUserNote"],
"text" | "sentiment" | "updatedAt"
> | null;
stats: Array<UserCardStat>;
/** Stat types the user has chosen to hide; filtered out of `stats` at render time. */
hiddenStats: Array<UserCardStat["type"]>;
}
/**

View File

@@ -0,0 +1,7 @@
import { USER_CARD } from "./user-card-constants";
export function maxUnverifiedXp(linkedPeakXp: number | null) {
return typeof linkedPeakXp === "number"
? linkedPeakXp + USER_CARD.MAX_UNVERIFIED_XP_ABOVE_LINKED_PLAYER
: USER_CARD.MAX_UNVERIFIED_XP_WITHOUT_LINKED_PLAYER;
}

View File

@@ -1,6 +1,8 @@
.trigger {
display: flex;
align-items: center;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
span {
color: var(--color-text-high);

View File

@@ -9,18 +9,23 @@ import styles from "./MutualFriends.module.css";
const MAX_VISIBLE_AVATARS = 5;
// xxx: for usercard do we even want popover there? or a different component, lighter data and no popover
export function MutualFriends({
mutualFriends,
withoutPopover = false,
}: {
mutualFriends: Array<CommonUser>;
/** When true renders a static avatar stack without the interactive popover, e.g. on the user card. */
withoutPopover?: boolean;
}) {
const { t } = useTranslation(["user"]);
if (mutualFriends.length === 0) return null;
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
if (withoutPopover) {
return (
<div className={styles.trigger}>
<AvatarStack mutualFriends={mutualFriends} />
</div>
);
}
return (
<div>
@@ -28,24 +33,7 @@ export function MutualFriends({
trigger={
<SendouButton variant="minimal" size="small">
<div className={styles.trigger}>
<div className={styles.avatarStack}>
{visibleFriends.map((friend) => (
<Avatar
key={friend.id}
user={friend}
size="xxs"
className={styles.stackedAvatar}
/>
))}
</div>
{overflowCount > 0 ? (
<span className={styles.overflow}>+{overflowCount}</span>
) : null}
<span>
{t("user:mutualFriends.count", {
count: mutualFriends.length,
})}
</span>
<AvatarStack mutualFriends={mutualFriends} />
</div>
</SendouButton>
}
@@ -66,3 +54,33 @@ export function MutualFriends({
</div>
);
}
function AvatarStack({ mutualFriends }: { mutualFriends: Array<CommonUser> }) {
const { t } = useTranslation(["user"]);
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
return (
<>
<div className={styles.avatarStack}>
{visibleFriends.map((friend) => (
<Avatar
key={friend.id}
user={friend}
size="xxs"
className={styles.stackedAvatar}
/>
))}
</div>
{overflowCount > 0 ? (
<span className={styles.overflow}>+{overflowCount}</span>
) : null}
<span>
{t("user:mutualFriends.count", {
count: mutualFriends.length,
})}
</span>
</>
);
}