mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-26 04:22:08 -05:00
Keep SQ likes indicator in sync with the looking page
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
5
changelog/2026-10-03-sq-likes-indicator-stays-seen.md
Normal file
5
changelog/2026-10-03-sq-likes-indicator-stays-seen.md
Normal 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
|
||||
@@ -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}"],
|
||||
|
||||
Reference in New Issue
Block a user