diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts
index 2009d7f01..2ba33f142 100644
--- a/app/features/sidebar/core/sidebar.server.ts
+++ b/app/features/sidebar/core/sidebar.server.ts
@@ -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();
const tournamentEvents: SidebarEvent[] = [
@@ -113,6 +120,7 @@ export async function resolveSidebarData(userId: number | null) {
friends,
streams: await combinedStreamsCached(),
savedTournamentIds,
+ incomingFriendRequestIds,
};
}
diff --git a/app/hooks/useUnseenFriendRequests.ts b/app/hooks/useUnseenFriendRequests.ts
new file mode 100644
index 000000000..89dfe78fd
--- /dev/null
+++ b/app/hooks/useUnseenFriendRequests.ts
@@ -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 [];
+ }
+}
diff --git a/locales/da/friends.json b/locales/da/friends.json
index 8a17841ab..c0a1fa21c 100644
--- a/locales/da/friends.json
+++ b/locales/da/friends.json
@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/de/friends.json b/locales/de/friends.json
index 8a17841ab..c0a1fa21c 100644
--- a/locales/de/friends.json
+++ b/locales/de/friends.json
@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/en/friends.json b/locales/en/friends.json
index 4d7f4aaad..e3e98de12 100644
--- a/locales/en/friends.json
+++ b/locales/en/friends.json
@@ -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"
}
diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/es-ES/friends.json
+++ b/locales/es-ES/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/es-US/friends.json
+++ b/locales/es-US/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/fr-CA/friends.json
+++ b/locales/fr-CA/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/fr-EU/friends.json
+++ b/locales/fr-EU/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/he/friends.json b/locales/he/friends.json
index 8a17841ab..c946bdfe8 100644
--- a/locales/he/friends.json
+++ b/locales/he/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_two": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/it/friends.json b/locales/it/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/it/friends.json
+++ b/locales/it/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/nl/friends.json b/locales/nl/friends.json
index 8a17841ab..c0a1fa21c 100644
--- a/locales/nl/friends.json
+++ b/locales/nl/friends.json
@@ -15,5 +15,7 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/pl/friends.json b/locales/pl/friends.json
index 8a17841ab..3c4372853 100644
--- a/locales/pl/friends.json
+++ b/locales/pl/friends.json
@@ -15,5 +15,9 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_few": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json
index 8a17841ab..4e1a0b924 100644
--- a/locales/pt-BR/friends.json
+++ b/locales/pt-BR/friends.json
@@ -15,5 +15,8 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}
diff --git a/locales/ru/friends.json b/locales/ru/friends.json
index 8a17841ab..3c4372853 100644
--- a/locales/ru/friends.json
+++ b/locales/ru/friends.json
@@ -15,5 +15,9 @@
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
- "teamMembers.empty": ""
+ "teamMembers.empty": "",
+ "unseenRequests_one": "",
+ "unseenRequests_few": "",
+ "unseenRequests_many": "",
+ "unseenRequests_other": ""
}