Keep SQ likes indicator in sync with the looking page

This commit is contained in:
Kalle
2026-10-03 19:51:16 +03:00
parent 8a9c0c27bf
commit a4cd1b1e8e
8 changed files with 151 additions and 18 deletions

View File

@@ -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(
<>
<MarkSeen groupId={GROUP_ID} receivedLikesCount={1} status={status} />
<UnseenLikes status={status} />
</>,
);
expect(unseenStatus(screen)).toBe("seen");
});
});

View File

@@ -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]);
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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<typeof loader>();
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);

View File

@@ -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

View File

@@ -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}"],