mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Replay indicator + add trust
This commit is contained in:
@@ -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 ? (
|
||||
<input type="hidden" name="_action" value={_action} />
|
||||
) : null}
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={rest.disabled || isSubmitting}
|
||||
type="submit"
|
||||
name={!isActionValue ? name() : undefined}
|
||||
value={!isActionValue ? value() : undefined}
|
||||
data-testid={testId ?? "submit-button"}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={rest.disabled || isSubmitting}
|
||||
type="submit"
|
||||
name={name()}
|
||||
value={value()}
|
||||
data-testid={testId ?? "submit-button"}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,8 +115,15 @@ export function GroupCard({
|
||||
{group.tier ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}
|
||||
<div>
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}{" "}
|
||||
{group.isReplay ? (
|
||||
<>
|
||||
/ <span className="text-theme-secondary">REPLAY</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{action && (ownRole === "OWNER" || ownRole === "MANAGER") ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -6,6 +6,7 @@ export type LookingGroup = {
|
||||
id: number;
|
||||
mapListPreference?: Group["mapListPreference"];
|
||||
tier?: TieredSkill["tier"];
|
||||
isReplay?: boolean;
|
||||
members?: {
|
||||
id: number;
|
||||
discordId: string;
|
||||
|
||||
@@ -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<GroupMember, "groupId" | "userId">;
|
||||
|
||||
export function findRecentMatchPlayersByUserId(userId: number) {
|
||||
return stm.all({ userId }) as Array<RecentMatchPlayer>;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
isOpen={open}
|
||||
@@ -383,17 +402,29 @@ function JoinTeamDialog({
|
||||
closeOnAnyClick={false}
|
||||
className="text-center"
|
||||
>
|
||||
Join group with {joinListToNaturalString(members)}?
|
||||
Join the group with{" "}
|
||||
{joinListToNaturalString(members.map((m) => m.discordName))}?
|
||||
<fetcher.Form
|
||||
className="stack horizontal justify-center sm mt-4"
|
||||
className="stack horizontal justify-center sm mt-4 flex-wrap"
|
||||
method="post"
|
||||
>
|
||||
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
|
||||
Join
|
||||
</SubmitButton>
|
||||
<SubmitButton
|
||||
_action="JOIN_TEAM_WITH_TRUST"
|
||||
state={fetcher.state}
|
||||
variant="outlined"
|
||||
>
|
||||
Join & trust {owner.discordName}
|
||||
</SubmitButton>
|
||||
<Button onClick={close} variant="destructive">
|
||||
No thanks
|
||||
</Button>
|
||||
<FormMessage type="info">
|
||||
Trusting a user allows them to add you to groups without an invite
|
||||
link in the future
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ const stm = sql.prepare(/*sql */ `
|
||||
) values (
|
||||
@trustGiverUserId,
|
||||
@trustReceiverUserId
|
||||
)
|
||||
) on conflict do nothing
|
||||
`);
|
||||
|
||||
export function giveTrust({
|
||||
|
||||
Reference in New Issue
Block a user