Mutual friends

This commit is contained in:
Kalle 2026-03-08 19:44:19 +02:00
parent b53e68d49a
commit 788061ca48
21 changed files with 310 additions and 56 deletions

View File

@ -255,6 +255,72 @@ export async function deleteFriendRequestByReceiver({
.execute();
}
export async function findMutualFriends({
loggedInUserId,
targetUserId,
}: {
loggedInUserId: number;
targetUserId: number;
}) {
return db
.selectFrom("Friendship as f1")
.innerJoin("Friendship as f2", (join) =>
join.on((eb) =>
eb.and([
eb(
eb
.case()
.when("f1.userOneId", "=", loggedInUserId)
.then(eb.ref("f1.userTwoId"))
.else(eb.ref("f1.userOneId"))
.end(),
"=",
eb
.case()
.when("f2.userOneId", "=", targetUserId)
.then(eb.ref("f2.userTwoId"))
.else(eb.ref("f2.userOneId"))
.end(),
),
]),
),
)
.innerJoin("User", (join) =>
join.on((eb) =>
eb(
"User.id",
"=",
eb
.case()
.when("f1.userOneId", "=", loggedInUserId)
.then(eb.ref("f1.userTwoId"))
.else(eb.ref("f1.userOneId"))
.end(),
),
),
)
.where((eb) =>
eb.or([
eb("f1.userOneId", "=", loggedInUserId),
eb("f1.userTwoId", "=", loggedInUserId),
]),
)
.where((eb) =>
eb.or([
eb("f2.userOneId", "=", targetUserId),
eb("f2.userTwoId", "=", targetUserId),
]),
)
.select([
"User.id",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
])
.execute();
}
export async function findFriendship({
userOneId,
userTwoId,

View File

@ -0,0 +1,49 @@
.trigger {
display: flex;
align-items: center;
span {
color: var(--color-text-high);
}
}
.avatarStack {
display: flex;
align-items: center;
}
.stackedAvatar {
border-radius: 50%;
border: 2px solid var(--color-bg);
}
.stackedAvatar:not(:first-child) {
margin-inline-start: -8px;
}
.overflow {
font-size: var(--font-xs);
margin-inline: var(--s-1);
}
.centered {
display: flex;
justify-content: center;
}
.list {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
max-height: 300px;
overflow-y: auto;
}
.friendLink {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-0-5) var(--s-1);
text-decoration: none;
color: var(--color-text);
}

View File

@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { userPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "./MutualFriends.module.css";
const MAX_VISIBLE_AVATARS = 5;
export function MutualFriends({
mutualFriends,
}: {
mutualFriends: UserPageLoaderData["mutualFriends"];
}) {
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;
return (
<div>
<SendouPopover
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>
</div>
</SendouButton>
}
>
<div className={styles.list}>
{mutualFriends.map((friend) => (
<Link
key={friend.id}
to={userPage(friend)}
className={styles.friendLink}
>
<Avatar user={friend} size="xxs" />
{friend.username}
</Link>
))}
</div>
</SendouPopover>
</div>
);
}

View File

@ -1,5 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
@ -20,11 +21,20 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
params.identifier!,
);
const mutualFriends =
loggedInUser && loggedInUser.id !== user.id
? await FriendRepository.findMutualFriends({
loggedInUserId: loggedInUser.id,
targetUserId: user.id,
})
: [];
return {
user: {
...user,
},
customTheme: user.customTheme,
type: widgetsEnabled ? ("new" as const) : ("old" as const),
mutualFriends,
};
};

View File

@ -33,6 +33,7 @@ import {
teamPage,
topSearchPlayerPage,
} from "~/utils/urls";
import { MutualFriends } from "../components/MutualFriends";
import type { UserPageNavItem } from "../components/UserPageIconNav";
import { UserPageIconNav } from "../components/UserPageIconNav";
import { Widget } from "../components/Widget";
@ -85,23 +86,26 @@ function NewUserInfoPage() {
return (
<div className={newStyles.container}>
<div className={newStyles.header}>
<Avatar user={layoutData.user} size="xmd" />
<div className={newStyles.userInfo}>
<div className={newStyles.nameGroup}>
<h1 className={newStyles.username}>{layoutData.user.username}</h1>
<ProfileSubtitle
inGameName={layoutData.user.inGameName}
pronouns={layoutData.user.pronouns}
plusTier={layoutData.user.plusTier}
country={layoutData.user.country}
language={i18n.language}
/>
<div className="stack sm">
<div className={newStyles.header}>
<Avatar user={layoutData.user} size="xmd" />
<div className={newStyles.userInfo}>
<div className={newStyles.nameGroup}>
<h1 className={newStyles.username}>{layoutData.user.username}</h1>
<ProfileSubtitle
inGameName={layoutData.user.inGameName}
pronouns={layoutData.user.pronouns}
plusTier={layoutData.user.plusTier}
country={layoutData.user.country}
language={i18n.language}
/>
</div>
</div>
<div className={newStyles.desktopIconNav}>
<UserPageIconNav items={navItems} />
</div>
</div>
<div className={newStyles.desktopIconNav}>
<UserPageIconNav items={navItems} />
</div>
<MutualFriends mutualFriends={layoutData.mutualFriends} />
</div>
{isOwnPage ? (
<div className={newStyles.editButtons}>
@ -174,32 +178,37 @@ export function OldUserInfoPage() {
return (
<div className={styles.container}>
<div className={styles.avatarContainer}>
<Avatar user={layoutData.user} size="lg" className={styles.avatar} />
<div>
<h2 className={styles.name}>
<div>{layoutData.user.username}</div>
<div>
{data.user.country ? (
<Flag countryCode={data.user.country} tiny />
) : null}
</div>
</h2>
<TeamInfo />
<div className="stack sm">
<div className={styles.avatarContainer}>
<Avatar user={layoutData.user} size="lg" className={styles.avatar} />
<div>
<h2 className={styles.name}>
<div>{layoutData.user.username}</div>
<div>
{data.user.country ? (
<Flag countryCode={data.user.country} tiny />
) : null}
</div>
</h2>
<TeamInfo />
</div>
<div className={styles.socials}>
{data.user.twitch ? (
<SocialLink type="twitch" identifier={data.user.twitch} />
) : null}
{data.user.youtubeId ? (
<SocialLink type="youtube" identifier={data.user.youtubeId} />
) : null}
{data.user.battlefy ? (
<SocialLink type="battlefy" identifier={data.user.battlefy} />
) : null}
{data.user.bsky ? (
<SocialLink type="bsky" identifier={data.user.bsky} />
) : null}
</div>
</div>
<div className={styles.socials}>
{data.user.twitch ? (
<SocialLink type="twitch" identifier={data.user.twitch} />
) : null}
{data.user.youtubeId ? (
<SocialLink type="youtube" identifier={data.user.youtubeId} />
) : null}
{data.user.battlefy ? (
<SocialLink type="battlefy" identifier={data.user.battlefy} />
) : null}
{data.user.bsky ? (
<SocialLink type="bsky" identifier={data.user.bsky} />
) : null}
<div className="stack items-center">
<MutualFriends mutualFriends={layoutData.mutualFriends} />
</div>
</div>
<ExtraInfos />

View File

@ -193,5 +193,8 @@
"builds.sorting.PUBLIC_BUILD": "offentlige sæt",
"builds.sorting.PRIVATE_BUILD": "Private sæt",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,8 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,8 @@
"builds.sorting.PUBLIC_BUILD": "Public build",
"builds.sorting.PRIVATE_BUILD": "Private build",
"commissions.open": "Open",
"commissions.closed": "Closed"
"commissions.closed": "Closed",
"mutualFriends": "Mutual friends",
"mutualFriends.count_one": "mutual friend",
"mutualFriends.count_other": "mutual friends"
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "Build público",
"builds.sorting.PRIVATE_BUILD": "Build privado",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "Build public",
"builds.sorting.PRIVATE_BUILD": "Build privée",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_two": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "Build pubblica",
"builds.sorting.PRIVATE_BUILD": "Build privata",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,6 @@
"builds.sorting.PUBLIC_BUILD": "公開ビルド",
"builds.sorting.PRIVATE_BUILD": "非公開ビルド",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": ""
}

View File

@ -193,5 +193,6 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": ""
}

View File

@ -193,5 +193,8 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,10 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_few": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,9 @@
"builds.sorting.PUBLIC_BUILD": "",
"builds.sorting.PRIVATE_BUILD": "",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,10 @@
"builds.sorting.PUBLIC_BUILD": "Публичные сборки",
"builds.sorting.PRIVATE_BUILD": "Частные сборки",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": "",
"mutualFriends.count_one": "",
"mutualFriends.count_few": "",
"mutualFriends.count_many": "",
"mutualFriends.count_other": ""
}

View File

@ -193,5 +193,6 @@
"builds.sorting.PUBLIC_BUILD": "公开配装",
"builds.sorting.PRIVATE_BUILD": "私人配装",
"commissions.open": "",
"commissions.closed": ""
"commissions.closed": "",
"mutualFriends": ""
}