diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index a50a934c2..56e43aa9d 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -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 })); }, []); diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 58e1cf2eb..7eaf01472 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -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) { diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 25590e236..996b9d693 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -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": { diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index b7590c90c..f2818d9ba 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -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: { diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 91281757b..aa39de5a2 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -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 diff --git a/app/features/sendouq/loaders/q.preparing.server.ts b/app/features/sendouq/loaders/q.preparing.server.ts index f50043d2f..bdd70f1a9 100644 --- a/app/features/sendouq/loaders/q.preparing.server.ts +++ b/app/features/sendouq/loaders/q.preparing.server.ts @@ -13,7 +13,6 @@ export const loader = async () => { }); return { - lastUpdated: Date.now(), group: ownGroup!, }; }; diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts index da39334e0..862707d5a 100644 --- a/app/features/sendouq/q-constants.ts +++ b/app/features/sendouq/q-constants.ts @@ -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 */ diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 72b208898..a7ab4e48d 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -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(); 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"; diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx index 910dbe5ca..a3b069b30 100644 --- a/app/features/sendouq/routes/q.preparing.tsx +++ b/app/features/sendouq/routes/q.preparing.tsx @@ -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(); const joinQFetcher = useFetcher(); - useAutoRefresh(data.lastUpdated); + + useWebsocketRevalidation(sqGroupWebsocketRoom(data.group.id)); return (
diff --git a/app/hooks/useAutoRefresh.ts b/app/hooks/useAutoRefresh.ts deleted file mode 100644 index 72f2b52c6..000000000 --- a/app/hooks/useAutoRefresh.ts +++ /dev/null @@ -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]); -}