sendou.ink/app/features/sendouq/actions/q.looking.server.ts
Kalle a4dfd82141
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
User card (#3213)
2026-07-05 08:28:42 +03:00

350 lines
9.4 KiB
TypeScript

import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
createMatchMemento,
matchMapList,
} from "~/features/sendouq-match/core/match.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.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";
// this function doesn't throw normally because we are assuming
// if there is a validation error the user saw stale data
// and when we return null we just force a refresh
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: lookingSchema,
});
const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;
const broadcastLookingUpdate = () =>
ChatSystemMessage.send({
room: SENDOUQ_LOOKING_ROOM,
revalidateOnly: true,
});
const revalidateGroupTopic = (groupId: number) =>
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(groupId),
revalidateOnly: true,
});
const notifyLikeReceived = (groupId: number) =>
ChatSystemMessage.send({
room: sqGroupWebsocketRoom(groupId),
type: "LIKE_RECEIVED",
revalidateOnly: true,
});
try {
// this throws because there should normally be no way user loses ownership by the action of some other user
const validateIsGroupOwner = () =>
errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
const isGroupManager = () =>
currentGroup.usersRole === "MANAGER" ||
currentGroup.usersRole === "OWNER";
switch (data._action) {
case "LIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.addLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
case "RECHALLENGE": {
if (!isGroupManager()) return null;
await SQGroupRepository.rechallenge({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
notifyLikeReceived(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
case "UNLIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.deleteLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
revalidateGroupTopic(data.targetGroupId);
revalidateGroupTopic(currentGroup.id);
break;
}
case "GROUP_UP": {
if (!isGroupManager()) return null;
const allLikes = await SQGroupRepository.allLikesByGroupId(
data.targetGroupId,
);
if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) {
return null;
}
const ourGroup = SendouQ.findOwnGroup(user.id);
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ourGroup || !theirGroup) return null;
const { id: survivingGroupId } = groupAfterMorph({
liker: "THEM",
ourGroup,
theirGroup,
});
const otherGroup =
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
await SQGroupRepository.morphGroups({
survivingGroupId,
otherGroupId: otherGroup.id,
});
await refreshSendouQInstance();
if (ourGroup.chatCode) {
ChatSystemMessage.removeRoom(ourGroup.chatCode);
}
if (theirGroup.chatCode) {
ChatSystemMessage.removeRoom(theirGroup.chatCode);
}
const survivingGroup =
SendouQ.findUncensoredGroupById(survivingGroupId);
if (survivingGroup?.chatCode) {
setGroupChatMetadata({
chatCode: survivingGroup.chatCode,
members: survivingGroup.members,
});
}
broadcastLookingUpdate();
break;
}
case "MATCH_UP": {
if (!isGroupManager()) return null;
const ownGroup = SendouQ.findOwnGroup(user.id);
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ownGroup || !theirGroup) return null;
const ownGroupPreferences =
await SQGroupRepository.mapModePreferencesByGroupId(ownGroup.id);
const theirGroupPreferences =
await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id);
const modesIncluded = resolveFutureMatchModes(ownGroup, theirGroup);
const mapList = await matchMapList(
{
id: ownGroup.id,
preferences: ownGroupPreferences,
},
{
id: theirGroup.id,
preferences: theirGroupPreferences,
},
modesIncluded,
);
const createdMatch = await SQMatchRepository.create({
alphaGroupId: ownGroup.id,
bravoGroupId: theirGroup.id,
mapList,
memento: createMatchMemento({
own: { group: ownGroup, preferences: ownGroupPreferences },
their: { group: theirGroup, preferences: theirGroupPreferences },
mapList,
}),
});
await refreshSendouQInstance();
refreshStreamsCache();
if (createdMatch.chatCode) {
ChatSystemMessage.setMetadata({
chatCode: createdMatch.chatCode,
header: `Match #${createdMatch.id}`,
subtitle: "SendouQ",
url: sendouQMatchPage(createdMatch.id),
imageUrl: `${navIconUrl("sendouq")}.avif`,
participantUserIds: [
...ownGroup.members.map((m) => m.id),
...theirGroup.members.map((m) => m.id),
],
expiresAfter: { hours: 2 },
});
}
// 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,
},
{
room: sqGroupWebsocketRoom(theirGroup.id),
type: "MATCH_STARTED",
revalidateOnly: true,
},
]);
notify({
userIds: [
...ownGroup.members.map((m) => m.id),
...theirGroup.members.map((m) => m.id),
],
defaultSeenUserIds: [user.id],
notification: {
type: "SQ_NEW_MATCH",
meta: {
matchId: createdMatch.id,
},
},
});
broadcastLookingUpdate();
throw redirect(sendouQMatchPage(createdMatch.id));
}
case "GIVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "MANAGER",
});
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "REMOVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "REGULAR",
});
await refreshSendouQInstance();
revalidateGroupTopic(currentGroup.id);
break;
}
case "LEAVE_GROUP": {
await SQGroupRepository.leaveGroup(user.id);
await refreshSendouQInstance();
const remainingGroup = SendouQ.findUncensoredGroupById(currentGroup.id);
if (remainingGroup?.chatCode) {
ChatSystemMessage.send({
room: remainingGroup.chatCode,
type: "USER_LEFT",
context: { name: user.username },
});
setGroupChatMetadata({
chatCode: remainingGroup.chatCode,
members: remainingGroup.members,
});
}
broadcastLookingUpdate();
throw redirect(SENDOUQ_PAGE);
}
case "KICK_FROM_GROUP": {
validateIsGroupOwner();
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
await SQGroupRepository.leaveGroup(data.userId);
await refreshSendouQInstance();
const remainingGroup = SendouQ.findUncensoredGroupById(currentGroup.id);
if (remainingGroup?.chatCode) {
setGroupChatMetadata({
chatCode: remainingGroup.chatCode,
members: remainingGroup.members,
});
}
broadcastLookingUpdate();
break;
}
case "REFRESH_GROUP": {
await SQGroupRepository.refreshGroup(currentGroup.id);
await refreshSendouQInstance();
broadcastLookingUpdate();
break;
}
case "UPDATE_NOTE": {
await SQGroupRepository.updateOwnMemberNote({
groupId: currentGroup.id,
value: data.value,
});
await refreshSendouQInstance();
broadcastLookingUpdate();
break;
}
default: {
assertUnreachable(data);
}
}
return null;
} catch (error) {
// some errors are expected to happen, for example they might request two groups at the same time
// then after morphing one group the other request fails because the group no longer exists
// return null causes loaders to run and they see the fresh state again instead of error page
if (error instanceof SendouQError) {
return null;
}
throw error;
}
};