SendouQ live websocket updates replacing old polling method

This commit is contained in:
Kalle
2026-06-20 16:24:11 +03:00
parent 5ac29bb27c
commit c64397c347
10 changed files with 157 additions and 73 deletions

View File

@@ -442,22 +442,27 @@ function ChatProviderInner({
);
const subscribe = React.useCallback((chatCode: string) => {
logger.debug("WS SUBSCRIBE:", chatCode);
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE", chatCode }));
}, []);
const unsubscribe = React.useCallback((chatCode: string) => {
logger.debug("WS UNSUBSCRIBE:", chatCode);
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE", chatCode }));
}, []);
const subscribeTopic = React.useCallback((topic: string) => {
logger.debug("WS SUBSCRIBE_TOPIC:", topic);
ws.current?.send(JSON.stringify({ event: "SUBSCRIBE_TOPIC", topic }));
}, []);
const unsubscribeTopic = React.useCallback((topic: string) => {
logger.debug("WS UNSUBSCRIBE_TOPIC:", topic);
ws.current?.send(JSON.stringify({ event: "UNSUBSCRIBE_TOPIC", topic }));
}, []);
const requestHistory = React.useCallback((chatCode: string) => {
logger.debug("WS CHAT_HISTORY:", chatCode);
ws.current?.send(JSON.stringify({ event: "CHAT_HISTORY", chatCode }));
}, []);

View File

@@ -10,6 +10,7 @@ import {
SendouQ,
} from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import { SENDOUQ_LOOKING_ROOM } from "~/features/sendouq/q-constants";
import { SendouQError } from "~/features/sendouq/q-utils.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server";
@@ -159,6 +160,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
}
// The group re-enters the looking pool, so refresh every looking client.
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
break;
}
case "CAST_CONTINUE_VOTE": {
@@ -223,6 +231,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
await refreshSendouQInstance();
// The continuing group re-enters the looking pool, so refresh
// every looking client.
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
}
if (match.chatCode) {

View File

@@ -16,6 +16,7 @@ import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
import { lookingSchema } from "../q-schemas.server";
import { resolveFutureMatchModes } from "../q-utils";
import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
@@ -32,6 +33,28 @@ export const action: ActionFunction = async ({ request }) => {
const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;
const broadcastLookingUpdate = () =>
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
const revalidateGroupTopic = (groupId: number) =>
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(groupId),
revalidateOnly: true,
authorUserId: user.id,
});
const notifyLikeReceived = (groupId: number) =>
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(groupId),
type: "LIKE_RECEIVED",
revalidateOnly: true,
authorUserId: user.id,
});
try {
// this throws because there should normally be no way user loses ownership by the action of some other user
const validateIsGroupOwner = () =>
@@ -49,17 +72,8 @@ export const action: ActionFunction = async ({ request }) => {
targetGroupId: data.targetGroupId,
});
const targetChatCode = SendouQ.findUncensoredGroupById(
data.targetGroupId,
)?.chatCode;
if (targetChatCode) {
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
authorUserId: user.id,
});
}
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
@@ -71,17 +85,8 @@ export const action: ActionFunction = async ({ request }) => {
targetGroupId: data.targetGroupId,
});
const targetChatCode = SendouQ.findUncensoredGroupById(
data.targetGroupId,
)?.chatCode;
if (targetChatCode) {
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
authorUserId: user.id,
});
}
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
case "UNLIKE": {
@@ -92,6 +97,9 @@ export const action: ActionFunction = async ({ request }) => {
targetGroupId: data.targetGroupId,
});
revalidateGroupTopic(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
case "GROUP_UP": {
@@ -140,6 +148,8 @@ export const action: ActionFunction = async ({ request }) => {
});
}
broadcastLookingUpdate();
break;
}
case "MATCH_UP": {
@@ -197,22 +207,23 @@ export const action: ActionFunction = async ({ request }) => {
});
}
if (ownGroup.chatCode && theirGroup.chatCode) {
ChatSystemMessage.send([
{
room: ownGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
{
room: theirGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
]);
}
// Both groups revalidate (→ redirected to the match by their looking
// loader) and play the match sound. Sent to the groups' topics so it
// reaches every member reliably, not just live chat participants.
ChatSystemMessage.send([
{
room: sqGroupWebsocketRoom(ownGroup.id),
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
{
room: sqGroupWebsocketRoom(theirGroup.id),
type: "MATCH_STARTED",
revalidateOnly: true,
authorUserId: user.id,
},
]);
notify({
userIds: [
@@ -228,6 +239,8 @@ export const action: ActionFunction = async ({ request }) => {
},
});
broadcastLookingUpdate();
throw redirect(sendouQMatchPage(createdMatch.id));
}
case "GIVE_MANAGER": {
@@ -241,6 +254,8 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "REMOVE_MANAGER": {
@@ -254,6 +269,8 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "LEAVE_GROUP": {
@@ -274,6 +291,8 @@ export const action: ActionFunction = async ({ request }) => {
});
}
broadcastLookingUpdate();
throw redirect(SENDOUQ_PAGE);
}
case "KICK_FROM_GROUP": {
@@ -292,6 +311,8 @@ export const action: ActionFunction = async ({ request }) => {
});
}
broadcastLookingUpdate();
break;
}
case "REFRESH_GROUP": {
@@ -299,6 +320,8 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
broadcastLookingUpdate();
break;
}
case "UPDATE_NOTE": {
@@ -309,6 +332,8 @@ export const action: ActionFunction = async ({ request }) => {
await refreshSendouQInstance();
broadcastLookingUpdate();
break;
}
case "DELETE_PRIVATE_USER_NOTE": {

View File

@@ -10,6 +10,7 @@ import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
import { preparingSchema } from "../q-schemas.server";
import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
@@ -40,6 +41,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await refreshSendouQInstance();
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
return redirect(SENDOUQ_LOOKING_PAGE);
}
case "ADD_FRIEND": {
@@ -85,6 +92,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
}
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(ownGroup.id),
revalidateOnly: true,
authorUserId: user.id,
});
notify({
userIds: [data.id],
notification: {

View File

@@ -15,7 +15,11 @@ import {
SUSPENDED_PAGE,
} from "~/utils/urls";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
import {
JOIN_CODE_SEARCH_PARAM_KEY,
SENDOUQ_LOOKING_ROOM,
sqGroupWebsocketRoom,
} from "../q-constants";
import { frontPageSchema } from "../q-schemas.server";
import { userCanJoinQueueAt } from "../q-utils";
import {
@@ -56,6 +60,16 @@ export const action: ActionFunction = async ({ request, url }) => {
await refreshSendouQInstance();
// Joining directly creates an ACTIVE group that enters the pool, so
// refresh every looking client. (A PREPARING group isn't in the pool.)
if (data.direct === "true") {
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
}
return redirect(
data.direct === "true"
? SENDOUQ_LOOKING_PAGE
@@ -100,6 +114,25 @@ export const action: ActionFunction = async ({ request, url }) => {
});
}
if (groupInvitedTo.status === "PREPARING") {
// A preparing group isn't in the pool, so notify just its existing
// members (on the preparing page) via the group topic.
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(groupInvitedTo.id),
revalidateOnly: true,
authorUserId: user.id,
});
} else {
// Joining an active group changes its size/suitability for the whole
// pool, so refresh every looking client — which already includes the
// group's own existing members.
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
authorUserId: user.id,
});
}
return redirect(
groupInvitedTo.status === "PREPARING"
? SENDOUQ_PREPARING_PAGE

View File

@@ -13,7 +13,6 @@ export const loader = async () => {
});
return {
lastUpdated: Date.now(),
group: ownGroup!,
};
};

View File

@@ -18,6 +18,15 @@ export const ACTION_TAB_AFTER_LOCKED_SECONDS = 24 * 60 * 60; // 24 hours
export const JOIN_CODE_SEARCH_PARAM_KEY = "join";
/** Skalop topic that every user on the looking page subscribes to. */
export const SENDOUQ_LOOKING_ROOM = "sq-looking";
/**
* Skalop topic for revalidation messages targeted at a single group (e.g. a
* received like)
* */
export const sqGroupWebsocketRoom = (groupId: number) => `sq-group__${groupId}`;
export const USER_SKILLS_CACHE_KEY = "user-skills";
/** Breakpoint (in px) for the main container after which we switch from 3-column layout to tabs */

View File

@@ -17,8 +17,8 @@ import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { metaTags } from "~/utils/remix";
@@ -39,6 +39,8 @@ import { loader } from "../loaders/q.looking.server";
import {
FULL_GROUP_SIZE,
IS_Q_LOOKING_MOBILE_BREAKPOINT,
SENDOUQ_LOOKING_ROOM,
sqGroupWebsocketRoom,
} from "../q-constants";
export { action, loader };
@@ -79,7 +81,16 @@ function QLookingPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
useAutoRefresh(data.lastUpdated);
// Pool-shape changes (a group joining/leaving, a morph, a match starting) are
// broadcast to this shared room so every looking client revalidates.
useWebsocketRevalidation(SENDOUQ_LOOKING_ROOM);
// Group-specific updates (e.g. a received like) are pushed to the group's own
// dedicated topic.
useWebsocketRevalidation(
data.ownGroup ? sqGroupWebsocketRoom(data.ownGroup.id) : "",
Boolean(data.ownGroup),
);
const wasTryingToJoinAnotherTeam = searchParams.get("joining") === "true";

View File

@@ -3,7 +3,7 @@ import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, SENDOUQ_PREPARING_PAGE } from "~/utils/urls";
@@ -12,7 +12,7 @@ import { GroupCard } from "../components/GroupCard";
import { GroupLeaver } from "../components/GroupLeaver";
import { MemberAdder } from "../components/MemberAdder";
import { loader } from "../loaders/q.preparing.server";
import { FULL_GROUP_SIZE } from "../q-constants";
import { FULL_GROUP_SIZE, sqGroupWebsocketRoom } from "../q-constants";
export { action, loader };
@@ -38,7 +38,8 @@ export default function QPreparingPage() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const joinQFetcher = useFetcher();
useAutoRefresh(data.lastUpdated);
useWebsocketRevalidation(sqGroupWebsocketRoom(data.group.id));
return (
<Main className="stack lg items-center">

View File

@@ -1,28 +0,0 @@
import * as React from "react";
import { useRevalidator } from "react-router";
import { useVisibilityChange } from "./useVisibilityChange";
const UPDATE_EVERY_N_SECONDS = 30;
const wasUpdatedRecently = (lastUpdated: number) =>
Date.now() - lastUpdated < UPDATE_EVERY_N_SECONDS * 1000;
export function useAutoRefresh(lastUpdated: number) {
const { revalidate } = useRevalidator();
const visibility = useVisibilityChange();
React.useEffect(() => {
// when user comes back to this tab
if (visibility === "visible" && !wasUpdatedRecently(lastUpdated)) {
revalidate();
}
const interval = setInterval(() => {
if (visibility === "hidden" || wasUpdatedRecently(lastUpdated)) return;
revalidate();
}, UPDATE_EVERY_N_SECONDS * 1000);
return () => {
clearInterval(interval);
};
}, [visibility, revalidate, lastUpdated]);
}