Unseen friend request indicators

This commit is contained in:
Kalle
2026-06-21 17:29:11 +03:00
parent 231965b4d5
commit 170eadcbac
22 changed files with 270 additions and 27 deletions

View File

@@ -353,6 +353,11 @@
place-items: center;
pointer-events: none;
line-height: 1;
&.tabBadgeLeft {
right: auto;
left: -8px;
}
}
.noAnimation {

View File

@@ -21,6 +21,7 @@ import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,
@@ -60,6 +61,9 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const hasFriendInSendouQ =
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
false;
const unseenFriendRequests = useUnseenFriendRequests(
sidebarData?.incomingFriendRequestIds ?? [],
);
const skipAnimation = previousPanelRef.current !== "closed";
@@ -152,6 +156,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
isLoggedIn={Boolean(user)}
hasUnseenNotifications={hasUnseenNotifications}
hasFriendInSendouQ={hasFriendInSendouQ}
unseenFriendRequests={unseenFriendRequests}
/>
</div>
);
@@ -163,12 +168,14 @@ function MobileTabBar({
isLoggedIn,
hasUnseenNotifications,
hasFriendInSendouQ,
unseenFriendRequests,
}: {
activePanel: PanelType;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
hasUnseenNotifications: boolean;
hasFriendInSendouQ: boolean;
unseenFriendRequests: number;
}) {
const { t } = useTranslation(["front", "common"]);
const chatContext = useChatContext();
@@ -190,6 +197,8 @@ function MobileTabBar({
isActive={activePanel === "friends"}
onPress={() => onTabPress("friends")}
showNotificationDot={hasFriendInSendouQ}
badgeCount={unseenFriendRequests}
badgeLeft={hasFriendInSendouQ}
/>
<MobileTab
icon={<Calendar />}
@@ -233,6 +242,8 @@ function MobileTab({
onPress,
showNotificationDot,
unreadCount,
badgeCount,
badgeLeft,
}: {
icon: React.ReactNode;
label: string;
@@ -240,7 +251,11 @@ function MobileTab({
onPress: () => void;
showNotificationDot?: boolean;
unreadCount?: number;
badgeCount?: number;
badgeLeft?: boolean;
}) {
const count = unreadCount ?? badgeCount;
return (
<button
type="button"
@@ -251,8 +266,14 @@ function MobileTab({
<span className={styles.tabIcon}>
{icon}
{showNotificationDot ? <NotificationDot /> : null}
{unreadCount ? (
<span className={styles.tabBadge}>{unreadCount}</span>
{count ? (
<span
className={clsx(styles.tabBadge, {
[styles.tabBadgeLeft]: badgeLeft,
})}
>
{count}
</span>
) : null}
</span>
<span>{label}</span>

View File

@@ -155,6 +155,8 @@
}
.sideNavHeaderAction {
display: flex;
align-items: center;
margin-inline-start: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-normal);

View File

@@ -165,6 +165,44 @@
}
}
.friendRequestsBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
margin-inline-end: var(--s-1-5);
pointer-events: none;
}
.sideNavCollapseBadge {
position: absolute;
top: -5px;
inset-inline-end: -5px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);
border-radius: 8px;
display: grid;
place-items: center;
line-height: 1;
pointer-events: none;
&.sideNavCollapseBadgeLeft {
inset-inline-end: auto;
inset-inline-start: -5px;
}
}
.sideNavCollapseButtonContainer {
display: none;
position: relative;

View File

@@ -29,6 +29,7 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
@@ -235,7 +236,7 @@ export function Layout({
setChatSidebarOpen(open);
};
const { t } = useTranslation(["front", "common"]);
const { t } = useTranslation(["front", "common", "friends"]);
const { formatRelativeDate } = useRelativeDayFormat();
const isHydrated = useHydrated();
const location = useLocation();
@@ -265,6 +266,9 @@ export function Layout({
const sidebarData = data?.sidebar;
const events = sidebarData?.events ?? [];
const friends = sidebarData?.friends ?? [];
const unseenFriendRequests = useUnseenFriendRequests(
sidebarData?.incomingFriendRequestIds ?? [],
);
const streams = sidebarData?.streams ?? [];
const isFrontPage = location.pathname === "/";
@@ -326,10 +330,23 @@ export function Layout({
icon={<Users />}
action={
user ? (
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
<>
{unseenFriendRequests > 0 ? (
<span
className={styles.friendRequestsBadge}
role="status"
aria-label={t("friends:unseenRequests", {
count: unseenFriendRequests,
})}
>
{unseenFriendRequests}
</span>
) : null}
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</>
) : null
}
>
@@ -391,6 +408,7 @@ export function Layout({
<SideNavCollapseButton
className={styles.sideNavModalTrigger}
showNotificationDot={!sideNavModalOpen && unseenIds.length > 0}
badgeCount={!sideNavModalOpen ? unseenFriendRequests : 0}
testId="sidenav-modal-trigger"
/>
<ModalOverlay className={styles.sideNavModalOverlay} isDismissable>
@@ -427,6 +445,7 @@ export function Layout({
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
showNotificationDot={sideNavCollapsed && unseenIds.length > 0}
badgeCount={sideNavCollapsed ? unseenFriendRequests : 0}
testId="sidenav-collapse-button"
/>
<TopNavMenus />
@@ -545,13 +564,17 @@ function SideNavCollapseButton({
onToggle,
className,
showNotificationDot,
badgeCount,
testId,
}: {
onToggle?: () => void;
className?: string;
showNotificationDot?: boolean;
badgeCount?: number;
testId?: string;
}) {
const { t } = useTranslation(["friends"]);
return (
<div className={styles.sideNavCollapseButtonContainer} data-testid={testId}>
<SendouButton
@@ -563,6 +586,17 @@ function SideNavCollapseButton({
onPress={onToggle}
/>
{showNotificationDot ? <NotificationDot /> : null}
{badgeCount ? (
<span
className={clsx(styles.sideNavCollapseBadge, {
[styles.sideNavCollapseBadgeLeft]: showNotificationDot,
})}
role="status"
aria-label={t("friends:unseenRequests", { count: badgeCount })}
>
{badgeCount}
</span>
) : null}
</div>
);
}

View File

@@ -236,6 +236,18 @@ export async function findPendingReceivedRequests(receiverId: number) {
.execute();
}
export async function findPendingReceivedRequestIds(
receiverId: number,
): Promise<number[]> {
const rows = await db
.selectFrom("FriendRequest")
.select("FriendRequest.id")
.where("FriendRequest.receiverId", "=", receiverId)
.execute();
return rows.map((row) => row.id);
}
export async function insertFriendship({
userOneId,
userTwoId,

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, Link, useLoaderData, useSearchParams } from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -6,6 +7,7 @@ import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { SendouForm } from "~/form/SendouForm";
import { markFriendRequestsSeen } from "~/hooks/useUnseenFriendRequests";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FriendMenu } from "../components/FriendMenu";
import { sendFriendRequestBaseSchema } from "../friends-schemas";
@@ -25,6 +27,15 @@ type ViewFilter = (typeof VIEW_FILTERS)[number];
export default function FriendsPage() {
const data = useLoaderData<FriendsLoaderData>();
const incomingRequestIds = data.incomingRequests
.map((request) => request.id)
.join(",");
// biome-ignore lint/correctness/useExhaustiveDependencies: stabilized on the joined ids to avoid refiring on every loader-data reference change
React.useEffect(() => {
markFriendRequestsSeen(data.incomingRequests.map((request) => request.id));
}, [incomingRequestIds]);
return (
<Main halfWidth>
<div className="stack lg">

View File

@@ -68,16 +68,23 @@ export async function resolveSidebarData(userId: number | null) {
friends: [] as SidebarFriend[],
streams: await combinedStreamsCached(),
savedTournamentIds: [] as number[],
incomingFriendRequestIds: [] as number[],
};
}
const [tournamentsData, scrimsData, friendsWithActivity, savedTournaments] =
await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(userId),
ScrimPostRepository.findUserScrims(userId),
FriendRepository.findByUserIdWithActivity(userId),
SavedCalendarEventRepository.upcoming(userId),
]);
const [
tournamentsData,
scrimsData,
friendsWithActivity,
savedTournaments,
incomingFriendRequestIds,
] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(userId),
ScrimPostRepository.findUserScrims(userId),
FriendRepository.findByUserIdWithActivity(userId),
SavedCalendarEventRepository.upcoming(userId),
FriendRepository.findPendingReceivedRequestIds(userId),
]);
const seenTournamentIds = new Set<number>();
const tournamentEvents: SidebarEvent[] = [
@@ -113,6 +120,7 @@ export async function resolveSidebarData(userId: number | null) {
friends,
streams: await combinedStreamsCached(),
savedTournamentIds,
incomingFriendRequestIds,
};
}

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import * as R from "remeda";
const LOCAL_STORAGE_KEY = "seen-friend-requests";
const MAX_STORED_IDS = 200;
const listeners = new Set<() => void>();
/**
* Returns the count of incoming friend requests that the user has not yet seen.
*
* "Seen" requests are tracked per-device in local storage (the dedicated /friends
* page remains the authoritative view). The count updates when requests are marked
* as seen via {@link markFriendRequestsSeen} in the same tab, or when local storage
* changes in another tab.
*/
export function useUnseenFriendRequests(incomingRequestIds: number[]): number {
const raw = React.useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const seenIds = new Set(parseSeenIds(raw));
return incomingRequestIds.filter((id) => !seenIds.has(id)).length;
}
/**
* Records the given incoming friend request ids as seen, clearing the unseen badge.
*
* Ids are merged into the existing seen set rather than replacing it, so that
* acting on a request (e.g. declining it) doesn't make an already-seen request
* count as unseen again while other consumers still have a stale list. Only the
* most recent ids are kept to keep the stored set from growing without bound.
*/
export function markFriendRequestsSeen(ids: number[]) {
try {
const merged = R.unique([...parseSeenIds(getSnapshot()), ...ids])
.sort((a, b) => b - a)
.slice(0, MAX_STORED_IDS);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(merged));
for (const listener of listeners) {
listener();
}
} catch {
// local storage may be unavailable
}
}
function subscribe(listener: () => void) {
listeners.add(listener);
window.addEventListener("storage", listener);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", listener);
};
}
function getSnapshot() {
return localStorage.getItem(LOCAL_STORAGE_KEY) ?? "[]";
}
function getServerSnapshot() {
return "[]";
}
function parseSeenIds(raw: string): number[] {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}

View File

@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,7 @@
"view.friends": "Friends",
"view.teamMembers": "Team members",
"view.all": "All",
"teamMembers.empty": "No team members yet"
"teamMembers.empty": "No team members yet",
"unseenRequests_one": "{{count}} unseen friend request",
"unseenRequests_other": "{{count}} unseen friend requests"
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_two": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,9 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_few": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}

View File

@@ -15,5 +15,9 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
"teamMembers.empty": "",
"unseenRequests_one": "",
"unseenRequests_few": "",
"unseenRequests_many": "",
"unseenRequests_other": ""
}