diff --git a/app/components/SubmitButton.tsx b/app/components/SubmitButton.tsx index 1393571e14..106f565828 100644 --- a/app/components/SubmitButton.tsx +++ b/app/components/SubmitButton.tsx @@ -32,24 +32,16 @@ export function SubmitButton({ return undefined; }; - // render action as hidden input to deal with a bug in older Safari versions - const isActionValue = name() === "_action"; - return ( - <> - {isActionValue ? ( - - ) : null} - - + ); } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 978db63a3a..fa4a3ea7e1 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -115,8 +115,15 @@ export function GroupCard({ {group.tier ? (
- {group.tier.name} - {group.tier.isPlus ? "+" : ""} +
+ {group.tier.name} + {group.tier.isPlus ? "+" : ""}{" "} + {group.isReplay ? ( + <> + / REPLAY + + ) : null} +
) : null} {action && (ownRole === "OWNER" || ownRole === "MANAGER") ? ( diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts index 97047abb20..7ee8389a1b 100644 --- a/app/features/sendouq/core/groups.server.ts +++ b/app/features/sendouq/core/groups.server.ts @@ -12,6 +12,7 @@ import type { SkillTierInterval, TieredSkill, } from "~/features/mmr/tiered.server"; +import type { RecentMatchPlayer } from "../queries/findRecentMatchPlayersByUserId.server"; export function divideGroups({ groups, @@ -97,6 +98,48 @@ export function filterOutGroupsWithIncompatibleMapListPreference( }; } +const MIN_PLAYERS_FOR_REPLAY = 3; +export function addReplayIndicator({ + groups, + recentMatchPlayers, + userId, +}: { + groups: DividedGroupsUncensored; + recentMatchPlayers: RecentMatchPlayer[]; + userId: number; +}): DividedGroupsUncensored { + if (!recentMatchPlayers.length) return groups; + + const ownGroupId = recentMatchPlayers.find( + (u) => u.userId === userId + )?.groupId; + invariant(ownGroupId, "own group not found"); + const otherGroupId = recentMatchPlayers.find( + (u) => u.groupId !== ownGroupId + )?.groupId; + invariant(otherGroupId, "other group not found"); + + const opponentPlayers = recentMatchPlayers + .filter((u) => u.groupId === otherGroupId) + .map((p) => p.userId); + + const addReplayIndicatorIfNeeded = (group: LookingGroupWithInviteCode) => { + const samePlayersCount = group.members.reduce( + (acc, cur) => (opponentPlayers.includes(cur.id) ? acc + 1 : acc), + 0 + ); + + return { ...group, isReplay: samePlayersCount >= MIN_PLAYERS_FOR_REPLAY }; + }; + + return { + own: groups.own, + likesGiven: groups.likesGiven.map(addReplayIndicatorIfNeeded), + likesReceived: groups.likesReceived.map(addReplayIndicatorIfNeeded), + neutral: groups.neutral.map(addReplayIndicatorIfNeeded), + }; +} + const censorGroupFully = ({ inviteCode: _inviteCode, ...group diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts index f5713c5383..e5d275e7a8 100644 --- a/app/features/sendouq/q-schemas.server.ts +++ b/app/features/sendouq/q-schemas.server.ts @@ -22,6 +22,9 @@ export const frontPageSchema = z.union([ z.object({ _action: z.literal("JOIN_TEAM"), }), + z.object({ + _action: z.literal("JOIN_TEAM_WITH_TRUST"), + }), z.object({ _action: z.literal("SET_INITIAL_SP"), tier: z.enum(["higher", "default", "lower"]), diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts index e0e3a0e9f0..3a32318c29 100644 --- a/app/features/sendouq/q-types.ts +++ b/app/features/sendouq/q-types.ts @@ -6,6 +6,7 @@ export type LookingGroup = { id: number; mapListPreference?: Group["mapListPreference"]; tier?: TieredSkill["tier"]; + isReplay?: boolean; members?: { id: number; discordId: string; diff --git a/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts b/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts new file mode 100644 index 0000000000..0897befe2c --- /dev/null +++ b/app/features/sendouq/queries/findRecentMatchPlayersByUserId.server.ts @@ -0,0 +1,32 @@ +import { sql } from "~/db/sql"; +import type { GroupMember } from "~/db/types"; + +const stm = sql.prepare(/* sql*/ ` + with "MostRecentGroupMatch" as ( + select + "GroupMatch".* + from "GroupMember" + left join "Group" on "Group"."id" = "GroupMember"."groupId" + inner join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" + or "GroupMatch"."bravoGroupId" = "Group"."id" + where + "GroupMember"."userId" = @userId + order by "GroupMatch"."createdAt" desc + limit 1 + ) + select + "GroupMember"."groupId", + "GroupMember"."userId" + from "MostRecentGroupMatch" + left join "Group" on "Group"."id" = "MostRecentGroupMatch"."alphaGroupId" + or "Group"."id" = "MostRecentGroupMatch"."bravoGroupId" + left join "GroupMember" on "GroupMember"."groupId" = "Group"."id" + where + "MostRecentGroupMatch"."createdAt" > unixepoch() - 60 * 60 * 2 +`); + +export type RecentMatchPlayer = Pick; + +export function findRecentMatchPlayersByUserId(userId: number) { + return stm.all({ userId }) as Array; +} diff --git a/app/features/sendouq/queries/findTeamByInviteCode.server.ts b/app/features/sendouq/queries/findTeamByInviteCode.server.ts index 87c1370038..27cd3b9d0a 100644 --- a/app/features/sendouq/queries/findTeamByInviteCode.server.ts +++ b/app/features/sendouq/queries/findTeamByInviteCode.server.ts @@ -1,13 +1,17 @@ import { sql } from "~/db/sql"; -import type { Group } from "~/db/types"; -import { parseDBArray } from "~/utils/sql"; +import type { Group, GroupMember } from "~/db/types"; +import { parseDBJsonArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` select "Group"."id", "Group"."status", json_group_array( - "User"."discordName" + json_object( + 'id', "User"."id", + 'discordName', "User"."discordName", + 'role', "GroupMember"."role" + ) ) as "members" from "Group" @@ -19,15 +23,17 @@ const stm = sql.prepare(/* sql */ ` group by "Group"."id" `); -export function findTeamByInviteCode( - inviteCode: string -): { id: number; status: Group["status"]; members: string[] } | null { +export function findTeamByInviteCode(inviteCode: string): { + id: number; + status: Group["status"]; + members: { id: number; discordName: string; role: GroupMember["role"] }[]; +} | null { const row = stm.get({ inviteCode }) as any; if (!row) return null; return { id: row.id, status: row.status, - members: parseDBArray(row.members), + members: parseDBJsonArray(row.members), }; } diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 35771e6b45..e58cf19f4a 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -14,7 +14,7 @@ import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTranslation } from "~/hooks/useTranslation"; -import { getUser, requireUserId } from "~/modules/auth/user.server"; +import { getUserId, requireUserId } from "~/modules/auth/user.server"; import { MapPool } from "~/modules/map-pool-serializer"; import { parseRequestFormData, @@ -31,6 +31,7 @@ import { import { GroupCard } from "../components/GroupCard"; import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups"; import { + addReplayIndicator, addSkillsToGroups, censorGroups, divideGroups, @@ -68,6 +69,7 @@ import { useWindowSize } from "~/hooks/useWindowSize"; import { Tab, Tabs } from "~/components/Tabs"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; import { groupHasMatch } from "../queries/groupHasMatch.server"; +import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -285,7 +287,7 @@ export const action: ActionFunction = async ({ request }) => { }; export const loader = async ({ request }: LoaderArgs) => { - const user = await getUser(request); + const user = await getUserId(request); const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined; const redirectLocation = groupRedirectLocationByCurrentLocation({ @@ -323,8 +325,16 @@ export const loader = async ({ request }: LoaderArgs) => { ? filterOutGroupsWithIncompatibleMapListPreference(groupsWithSkills) : groupsWithSkills; + const groupsWithReplayIndicator = groupIsFull + ? addReplayIndicator({ + groups: compatibleGroups, + recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id), + userId: user!.id, + }) + : compatibleGroups; + const censoredGroups = censorGroups({ - groups: compatibleGroups, + groups: groupsWithReplayIndicator, showMembers: !groupIsFull, showInviteCode: hasGroupManagerPerms(currentGroup.role) && !groupIsFull, }); diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx index 62640ca307..3d40187e65 100644 --- a/app/features/sendouq/routes/q.tsx +++ b/app/features/sendouq/routes/q.tsx @@ -68,6 +68,9 @@ import { DEFAULT_SKILL_LOW, DEFAULT_SKILL_MID, } from "~/features/mmr/mmr-constants"; +import { giveTrust } from "~/features/tournament/queries/giveTrust.server"; +import type { GroupMember } from "~/db/types"; +import invariant from "tiny-invariant"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -120,6 +123,7 @@ export const action: ActionFunction = async ({ request }) => { data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE ); } + case "JOIN_TEAM_WITH_TRUST": case "JOIN_TEAM": { const code = new URL(request.url).searchParams.get( JOIN_CODE_SEARCH_PARAM_KEY @@ -134,6 +138,15 @@ export const action: ActionFunction = async ({ request }) => { groupId: teamInvitedTo.id, userId: user.id, }); + if (data._action === "JOIN_TEAM_WITH_TRUST") { + const owner = teamInvitedTo.members.find((m) => m.role === "OWNER"); + invariant(owner, "Owner not found"); + + giveTrust({ + trustGiverUserId: user.id, + trustReceiverUserId: owner.id, + }); + } return redirect( teamInvitedTo.status === "PREPARING" @@ -372,10 +385,16 @@ function JoinTeamDialog({ }: { open: boolean; close: () => void; - members: string[]; + members: { + discordName: string; + role: GroupMember["role"]; + }[]; }) { const fetcher = useFetcher(); + const owner = members.find((m) => m.role === "OWNER"); + invariant(owner, "Owner not found"); + return ( - Join group with {joinListToNaturalString(members)}? + Join the group with{" "} + {joinListToNaturalString(members.map((m) => m.discordName))}? Join + + Join & trust {owner.discordName} + + + Trusting a user allows them to add you to groups without an invite + link in the future + ); diff --git a/app/features/tournament/queries/giveTrust.server.ts b/app/features/tournament/queries/giveTrust.server.ts index 0f09ae97a6..0db185f21e 100644 --- a/app/features/tournament/queries/giveTrust.server.ts +++ b/app/features/tournament/queries/giveTrust.server.ts @@ -7,7 +7,7 @@ const stm = sql.prepare(/*sql */ ` ) values ( @trustGiverUserId, @trustReceiverUserId - ) + ) on conflict do nothing `); export function giveTrust({