mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Unseen friend request indicators
This commit is contained in:
@@ -353,6 +353,11 @@
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
|
||||
&.tabBadgeLeft {
|
||||
right: auto;
|
||||
left: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
.noAnimation {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -155,6 +155,8 @@
|
||||
}
|
||||
|
||||
.sideNavHeaderAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-inline-start: auto;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-normal);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
75
app/hooks/useUnseenFriendRequests.ts
Normal file
75
app/hooks/useUnseenFriendRequests.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,7 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_two": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,9 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_few": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
@@ -15,5 +15,9 @@
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
"teamMembers.empty": "",
|
||||
"unseenRequests_one": "",
|
||||
"unseenRequests_few": "",
|
||||
"unseenRequests_many": "",
|
||||
"unseenRequests_other": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user