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