From a4cd1b1e8ec10dba81eb16c6668a4babd9f65f65 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 3 Oct 2026 19:51:16 +0300
Subject: [PATCH] Keep SQ likes indicator in sync with the looking page
---
.../global-status-likes-seen.browser.test.tsx | 16 ++++-
.../global-status/global-status-likes-seen.ts | 31 +++++----
.../sendouq/actions/q.looking.server.ts | 3 -
.../sendouq/core/SendouQ.server.test.ts | 66 ++++++++++++++++++-
app/features/sendouq/core/SendouQ.server.ts | 36 +++++++++-
app/features/sendouq/routes/q.looking.tsx | 9 ++-
...026-10-03-sq-likes-indicator-stays-seen.md | 5 ++
vitest.unit.config.ts | 3 +
8 files changed, 151 insertions(+), 18 deletions(-)
create mode 100644 changelog/2026-10-03-sq-likes-indicator-stays-seen.md
diff --git a/app/features/global-status/global-status-likes-seen.browser.test.tsx b/app/features/global-status/global-status-likes-seen.browser.test.tsx
index c322c7706..4a581dbc7 100644
--- a/app/features/global-status/global-status-likes-seen.browser.test.tsx
+++ b/app/features/global-status/global-status-likes-seen.browser.test.tsx
@@ -19,11 +19,13 @@ function UnseenLikes({ status }: { status: GlobalStatus | null }) {
function MarkSeen({
groupId,
receivedLikesCount,
+ status = null,
}: {
groupId: number | undefined;
receivedLikesCount: number;
+ status?: GlobalStatus | null;
}) {
- useMarkSqLikesSeen(groupId, receivedLikesCount);
+ useMarkSqLikesSeen({ groupId, receivedLikesCount, status });
return null;
}
@@ -117,4 +119,16 @@ describe("useMarkSqLikesSeen", () => {
expect(unseenStatus(screen)).toBe("seen");
});
+
+ test("clears the highlight while the header's count is ahead of the likes shown", async () => {
+ const status = queuedStatus(2);
+ const screen = await render(
+ <>
+
+
+ >,
+ );
+
+ expect(unseenStatus(screen)).toBe("seen");
+ });
});
diff --git a/app/features/global-status/global-status-likes-seen.ts b/app/features/global-status/global-status-likes-seen.ts
index a3ced71d3..ff38c54c5 100644
--- a/app/features/global-status/global-status-likes-seen.ts
+++ b/app/features/global-status/global-status-likes-seen.ts
@@ -32,19 +32,28 @@ export function useHasUnseenSqLikes(status: GlobalStatus | null): boolean {
}
/**
- * Records the group's currently received likes as seen while the user is on
- * the page showing them, clearing the header badge highlight.
+ * Records the group's likes as seen while the user is on the page showing
+ * them, clearing the header badge highlight. The header's count is seen too as
+ * it can run ahead of the likes shown, e.g. a liker left the looking pool.
*/
-export function useMarkSqLikesSeen(
- groupId: number | undefined,
- receivedLikesCount: number,
-) {
+export function useMarkSqLikesSeen({
+ groupId,
+ receivedLikesCount,
+ status,
+}: {
+ groupId: number | undefined;
+ receivedLikesCount: number;
+ status: GlobalStatus | null;
+}) {
+ const headerCount =
+ status?.state === "SQ_QUEUED" && status.groupId === groupId
+ ? (status.count ?? 0)
+ : 0;
+ const seenCount = Math.max(receivedLikesCount, headerCount);
+
React.useEffect(() => {
if (typeof groupId !== "number") return;
- PersistedState.write(seenSqLikesPersisted, {
- groupId,
- seenCount: receivedLikesCount,
- });
- }, [groupId, receivedLikesCount]);
+ PersistedState.write(seenSqLikesPersisted, { groupId, seenCount });
+ }, [groupId, seenCount]);
}
diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts
index 382736ad0..37360c0f2 100644
--- a/app/features/sendouq/actions/q.looking.server.ts
+++ b/app/features/sendouq/actions/q.looking.server.ts
@@ -73,7 +73,6 @@ export const action: ActionFunction = async ({ request }) => {
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
- notifyGroupStatusChanged(data.targetGroupId);
notifyGroupStatusChanged(currentGroup.id);
break;
@@ -115,7 +114,6 @@ export const action: ActionFunction = async ({ request }) => {
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
- notifyGroupStatusChanged(data.targetGroupId);
break;
}
case "UNLIKE": {
@@ -128,7 +126,6 @@ export const action: ActionFunction = async ({ request }) => {
revalidateGroupTopic(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
- notifyGroupStatusChanged(data.targetGroupId);
notifyGroupStatusChanged(currentGroup.id);
break;
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
index fcc3c2e69..a85760309 100644
--- a/app/features/sendouq/core/SendouQ.server.test.ts
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -1,4 +1,4 @@
-import { subSeconds } from "date-fns";
+import { subMinutes, subSeconds } from "date-fns";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { backdate } from "~/db/seed/core/backdate";
import * as SkillFactory from "~/db/seed/factories/SkillFactory";
@@ -7,6 +7,7 @@ import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { UserMapModePreferences } from "~/db/tables-json";
+import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import {
freshUserSkills,
@@ -30,6 +31,10 @@ vi.mock("~/features/mmr/core/Seasons", () => ({
currentOrPrevious: mockSeasonCurrentOrPrevious,
}));
+vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
+ notifyStatusChanged: vi.fn(),
+}));
+
/** Users are interchangeable here, so tests name them by 1-based position. */
const users = UserFactory.pool();
@@ -134,6 +139,65 @@ describe("SendouQ", () => {
});
});
+ describe("refreshSendouQInstance", () => {
+ const notifyStatusChanged = () =>
+ vi.mocked(ChatSystemMessage.notifyStatusChanged);
+
+ beforeEach(async () => {
+ await users.create(8);
+ await refreshSendouQInstance();
+ notifyStatusChanged().mockClear();
+ });
+
+ test("notifies the members of a group that received a like", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5]),
+ });
+ await SQGroupFactory.create(
+ { memberUserIds: userIds([1, 2]) },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+
+ expect(notifyStatusChanged()).toHaveBeenLastCalledWith(userIds([1, 2]));
+ });
+
+ test("notifies the members of a group whose liker went stale", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5]),
+ });
+ await SQGroupFactory.create(
+ { memberUserIds: userIds([1, 2]) },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+ notifyStatusChanged().mockClear();
+
+ await backdate("Group", likerGroup.id, {
+ latestActionAt: subMinutes(new Date(), 45),
+ });
+ await refreshSendouQInstance();
+
+ expect(notifyStatusChanged()).toHaveBeenLastCalledWith(userIds([1, 2]));
+ });
+
+ test("notifies nobody when no group's received likes changed", async () => {
+ const likerGroup = await SQGroupFactory.create({
+ memberUserIds: userIds([5]),
+ });
+ await SQGroupFactory.create(
+ { memberUserIds: userIds([1, 2]) },
+ { likedByGroupIds: [likerGroup.id] },
+ );
+ await refreshSendouQInstance();
+ notifyStatusChanged().mockClear();
+
+ await refreshSendouQInstance();
+
+ expect(notifyStatusChanged()).toHaveBeenLastCalledWith([]);
+ });
+ });
+
describe("currentViewByUserId", () => {
beforeEach(async () => {
await users.create(8);
diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts
index 7ba88256f..429606357 100644
--- a/app/features/sendouq/core/SendouQ.server.ts
+++ b/app/features/sendouq/core/SendouQ.server.ts
@@ -3,6 +3,7 @@ import { redirect } from "react-router";
import * as R from "remeda";
import type { DBBoolean } from "~/db/tables";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
+import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
@@ -145,6 +146,16 @@ class SendouQClass {
).length;
}
+ /** {@link likesReceivedCount} of every group that has received any likes, keyed by group id. */
+ likesReceivedCounts() {
+ return new Map(
+ Array.from(this.#receivedLikerGroupIds.keys(), (groupId) => [
+ groupId,
+ this.likesReceivedCount(groupId),
+ ]),
+ );
+ }
+
/** A group by id without censoring sensitive data. */
findUncensoredGroupById(groupId: number) {
return this.groups.find((group) => group.id === groupId);
@@ -592,9 +603,32 @@ function matchMapPools(match: DBMatch) {
/** Global SendouQ manager: all active groups and matchmaking state. */
export let SendouQ = await freshSendouQInstance();
-/** Reloads the global SendouQ instance from the database; call after any change to groups or matches. */
+let likesReceivedCounts = SendouQ.likesReceivedCounts();
+
+/** Reloads the global SendouQ instance from the database; call after any change to groups or matches. Notifies the groups whose received likes count changed, e.g. a liker left the looking pool or went stale since the last reload. */
export async function refreshSendouQInstance() {
SendouQ = await freshSendouQInstance();
+
+ const previousLikesReceivedCounts = likesReceivedCounts;
+ likesReceivedCounts = SendouQ.likesReceivedCounts();
+
+ const changedGroupIds = R.unique([
+ ...previousLikesReceivedCounts.keys(),
+ ...likesReceivedCounts.keys(),
+ ]).filter(
+ (groupId) =>
+ (previousLikesReceivedCounts.get(groupId) ?? 0) !==
+ (likesReceivedCounts.get(groupId) ?? 0),
+ );
+
+ ChatSystemMessage.notifyStatusChanged(
+ changedGroupIds.flatMap(
+ (groupId) =>
+ SendouQ.findUncensoredGroupById(groupId)?.members.map(
+ (member) => member.id,
+ ) ?? [],
+ ),
+ );
}
async function freshSendouQInstance() {
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index a430d689b..50f0112d5 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -18,6 +18,7 @@ import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
+import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
import { useMarkSqLikesSeen } from "~/features/global-status/global-status-likes-seen";
import type { UserCardData } from "~/features/user-card/user-card-types";
import { privateNoteSentimentScore } from "~/features/user-card/user-card-utils";
@@ -88,7 +89,13 @@ function QLookingPage() {
const data = useLoaderData();
const [joining] = useSearchParam(qLookingSearchParams, "joining");
- useMarkSqLikesSeen(data.ownGroup?.id, data.likes.received.length);
+ const { status } = useGlobalStatus();
+
+ useMarkSqLikesSeen({
+ groupId: data.ownGroup?.id,
+ receivedLikesCount: data.likes.received.length,
+ status,
+ });
// pool-shape changes (a group joining/leaving, a morph, a match starting)
useTopicRevalidation(SENDOUQ_LOOKING_CHANNEL);
diff --git a/changelog/2026-10-03-sq-likes-indicator-stays-seen.md b/changelog/2026-10-03-sq-likes-indicator-stays-seen.md
new file mode 100644
index 000000000..4e600ff5b
--- /dev/null
+++ b/changelog/2026-10-03-sq-likes-indicator-stays-seen.md
@@ -0,0 +1,5 @@
+---
+navItem: sendouq
+type: bug
+---
+SendouQ likes count in the header now updates when a group that liked you leaves the queue, and no longer shows likes as new while you are viewing them
diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts
index b8f861ee4..8fbcddb4a 100644
--- a/vitest.unit.config.ts
+++ b/vitest.unit.config.ts
@@ -1,6 +1,9 @@
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
+ define: {
+ __GIT_COMMIT__: JSON.stringify(""),
+ },
test: {
name: "unit",
include: ["**/*.test.{ts,tsx}"],