mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 23:26:15 -05:00
SendouQ live websocket updates replacing old polling method
This commit is contained in:
@@ -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 }));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,6 @@ export const loader = async () => {
|
||||
});
|
||||
|
||||
return {
|
||||
lastUpdated: Date.now(),
|
||||
group: ownGroup!,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user