diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts index baa501dfb..17f812958 100644 --- a/app/features/friends/FriendRepository.server.ts +++ b/app/features/friends/FriendRepository.server.ts @@ -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, diff --git a/app/features/user-page/components/MutualFriends.module.css b/app/features/user-page/components/MutualFriends.module.css new file mode 100644 index 000000000..485c966f0 --- /dev/null +++ b/app/features/user-page/components/MutualFriends.module.css @@ -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); +} diff --git a/app/features/user-page/components/MutualFriends.tsx b/app/features/user-page/components/MutualFriends.tsx new file mode 100644 index 000000000..13baf402d --- /dev/null +++ b/app/features/user-page/components/MutualFriends.tsx @@ -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 ( +
+ +
+
+ {visibleFriends.map((friend) => ( + + ))} +
+ {overflowCount > 0 ? ( + +{overflowCount} + ) : null} + + {t("user:mutualFriends.count", { + count: mutualFriends.length, + })} + +
+ + } + > +
+ {mutualFriends.map((friend) => ( + + + {friend.username} + + ))} +
+
+
+ ); +} diff --git a/app/features/user-page/loaders/u.$identifier.server.ts b/app/features/user-page/loaders/u.$identifier.server.ts index 2f64eabeb..d914b7c03 100644 --- a/app/features/user-page/loaders/u.$identifier.server.ts +++ b/app/features/user-page/loaders/u.$identifier.server.ts @@ -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, }; }; diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 2b96571d7..bfa46dbbc 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -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 (
-
- -
-
-

{layoutData.user.username}

- +
+
+ +
+
+

{layoutData.user.username}

+ +
+
+
+
-
- -
+
{isOwnPage ? (
@@ -174,32 +178,37 @@ export function OldUserInfoPage() { return (
-
- -
-

-
{layoutData.user.username}
-
- {data.user.country ? ( - - ) : null} -
-

- +
+
+ +
+

+
{layoutData.user.username}
+
+ {data.user.country ? ( + + ) : null} +
+

+ +
+
+ {data.user.twitch ? ( + + ) : null} + {data.user.youtubeId ? ( + + ) : null} + {data.user.battlefy ? ( + + ) : null} + {data.user.bsky ? ( + + ) : null} +
-
- {data.user.twitch ? ( - - ) : null} - {data.user.youtubeId ? ( - - ) : null} - {data.user.battlefy ? ( - - ) : null} - {data.user.bsky ? ( - - ) : null} +
+
diff --git a/locales/da/user.json b/locales/da/user.json index a6ffb3a7c..596a72dfa 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -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": "" } diff --git a/locales/de/user.json b/locales/de/user.json index 693be3f54..eac3ebf7d 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -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": "" } diff --git a/locales/en/user.json b/locales/en/user.json index 8beb10fa2..168f55a9c 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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" } diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index de0a99211..c554eadcb 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -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": "" } diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 47711a501..fc29e123b 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -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": "" } diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 7888e5657..89b126ab2 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -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": "" } diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 99c134bf2..36d06a7ef 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -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": "" } diff --git a/locales/he/user.json b/locales/he/user.json index 99abbdfd3..8d1a0cea1 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -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": "" } diff --git a/locales/it/user.json b/locales/it/user.json index 288261762..3914b0ae6 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -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": "" } diff --git a/locales/ja/user.json b/locales/ja/user.json index 14b819e99..9671cba17 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -193,5 +193,6 @@ "builds.sorting.PUBLIC_BUILD": "公開ビルド", "builds.sorting.PRIVATE_BUILD": "非公開ビルド", "commissions.open": "", - "commissions.closed": "" + "commissions.closed": "", + "mutualFriends": "" } diff --git a/locales/ko/user.json b/locales/ko/user.json index 4ecd4a3e6..24373d787 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -193,5 +193,6 @@ "builds.sorting.PUBLIC_BUILD": "", "builds.sorting.PRIVATE_BUILD": "", "commissions.open": "", - "commissions.closed": "" + "commissions.closed": "", + "mutualFriends": "" } diff --git a/locales/nl/user.json b/locales/nl/user.json index 4a5f19ad9..6186bcd84 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -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": "" } diff --git a/locales/pl/user.json b/locales/pl/user.json index a6854b04f..66c414b52 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -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": "" } diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index 37bf11a48..3f0257818 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -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": "" } diff --git a/locales/ru/user.json b/locales/ru/user.json index 189e2dcd9..3d7856c69 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -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": "" } diff --git a/locales/zh/user.json b/locales/zh/user.json index 26012c8d0..a4b2f9518 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -193,5 +193,6 @@ "builds.sorting.PUBLIC_BUILD": "公开配装", "builds.sorting.PRIVATE_BUILD": "私人配装", "commissions.open": "", - "commissions.closed": "" + "commissions.closed": "", + "mutualFriends": "" }