mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
UserCard data via query.batch
This commit is contained in:
54
MIGRATION.md
54
MIGRATION.md
@@ -67,6 +67,54 @@ Auth: `requireUser()` / `getUser()` / `actorId()` from
|
||||
`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per
|
||||
request in `hooks.server.ts`. No AsyncLocalStorage.
|
||||
|
||||
## Component-owned data (`query.batch`)
|
||||
|
||||
From `UserCard`, for a widget rendered many times per page whose data the React
|
||||
loader gathered for it.
|
||||
|
||||
**Before** (React Router): every loader that renders user cards collected the
|
||||
ids on the page, called `UserCardRepository.findAllByUserIds`, spread
|
||||
`{ userCards }` into its data, and a context handed the map down so each card
|
||||
could look itself up by id.
|
||||
|
||||
**After** (SvelteKit): the component asks for its own row and Kit collapses the
|
||||
calls. `query.batch` collects every call made in the same macrotask (the whole
|
||||
page, SSR included) into one request and one server invocation, whose callback
|
||||
returns a per-argument lookup:
|
||||
|
||||
```ts
|
||||
// user-card.remote.ts
|
||||
export const getUserCard = query.batch(
|
||||
v.object({ userId: id, withFriendCode: v.boolean() }),
|
||||
async (args) => {
|
||||
const { userCards } = await UserCardRepository.findAllByUserIds({
|
||||
userIds: R.unique(args.map((arg) => arg.userId)),
|
||||
});
|
||||
|
||||
return ({ userId }) => userCards.get(userId);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- UserCard.svelte -->
|
||||
const data = $derived(await getUserCard({ userId, withFriendCode }));
|
||||
```
|
||||
|
||||
Pages then render `<UserCard userId={...}>` and load nothing for it; the widget
|
||||
hands its resolved row to its `children` snippet
|
||||
(`Snippet<[UserCardData | undefined]>`) for callers that need a piece of it
|
||||
outside the popover. Prefer this over threading data (or a context) through
|
||||
pages when the same widget appears across many unrelated routes.
|
||||
|
||||
Loader-supplied authorization does not survive the move: an argument like
|
||||
`withFriendCode` is a *request* the client can forge, so whatever the route
|
||||
guard used to prove (participant, staff) the batch callback must re-derive for
|
||||
the viewer — here `ScrimPostRepository.findUserIdsSharingAcceptedScrim`.
|
||||
|
||||
`query.batch` throws when prerendering; a prerendered route cannot render a
|
||||
component that uses one.
|
||||
|
||||
## Write path (action → command), the golden pattern
|
||||
|
||||
From the `/scrims` slice (`scrims.remote.ts`):
|
||||
@@ -303,9 +351,9 @@ Where parity matters, out-specify it with a tripled class
|
||||
remote-function CSRF (which ignores `csrf.trustedOrigins`) sees the right
|
||||
per-port origin.
|
||||
- Context in async components must be set *before* the first `await`
|
||||
(`set_context_after_init` is fatal in prod). Pattern: `setUserCardContext({
|
||||
userCards: () => data.userCards })` above `const data = $derived(await …)` —
|
||||
the getter only runs after the await resolves.
|
||||
(`set_context_after_init` is fatal in prod). Pattern: `setSomeContext({ value:
|
||||
() => data.value })` above `const data = $derived(await …)` — the getter only
|
||||
runs after the await resolves.
|
||||
- Mutating `$state` synchronously inside a `$derived`-triggered fetch is a
|
||||
`state_unsafe_mutation` — the e2e fetch-counter patch defers its counter
|
||||
writes a microtask (`HydrationTestIndicator.svelte`).
|
||||
|
||||
@@ -23,7 +23,6 @@ import invariant from "@sendou/utils/invariant";
|
||||
import Avatar from "#lib/components/Avatar.svelte";
|
||||
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
|
||||
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
|
||||
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import { TAB_KEYS } from "./match-page-constants.ts";
|
||||
|
||||
// xxx: the active-roster editing flow (canEditSubbedOut/defaultIsEditing/
|
||||
@@ -37,8 +36,6 @@ interface Props {
|
||||
|
||||
let { teams }: Props = $props();
|
||||
|
||||
const userCardContext = getUserCardContext();
|
||||
|
||||
function defaultNameOf(team: RosterTabTeam) {
|
||||
invariant(team.defaultName, "team or defaultName must be provided");
|
||||
return team.defaultName;
|
||||
@@ -112,21 +109,20 @@ function defaultNameOf(team: RosterTabTeam) {
|
||||
{/snippet}
|
||||
|
||||
{#snippet rosterMemberLink(member: RosterTabMember)}
|
||||
{@const sentiment = userCardContext
|
||||
?.userCards()
|
||||
?.get(member.id)?.privateNote?.sentiment}
|
||||
<UserCard userId={member.id}>
|
||||
<span class="memberLink">
|
||||
<NoteAvatar {sentiment} size="xs">
|
||||
<Avatar user={member} size="xxs" />
|
||||
</NoteAvatar>
|
||||
<div class="memberNameStack">
|
||||
<span>{member.username}</span>
|
||||
{#if member.inGameName}
|
||||
<span class="memberInGameName">{member.inGameName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
<UserCard userId={member.id} withFriendCode>
|
||||
{#snippet children(card)}
|
||||
<span class="memberLink">
|
||||
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="xs">
|
||||
<Avatar user={member} size="xxs" />
|
||||
</NoteAvatar>
|
||||
<div class="memberNameStack">
|
||||
<span>{member.username}</span>
|
||||
{#if member.inGameName}
|
||||
<span class="memberInGameName">{member.inGameName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
{/snippet}
|
||||
</UserCard>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -649,3 +649,71 @@ export async function findUserScrims(userId: number): Promise<SidebarScrim[]> {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// xxx: perf test+add to be bench script
|
||||
/**
|
||||
* Of `otherUserIds`, the ones who participate in an accepted (and not canceled) scrim that `userId`
|
||||
* also participates in — i.e. the people they are actually scheduled to play with. Used to check
|
||||
* whether a viewer has a reason to be shown someone's friend code.
|
||||
*/
|
||||
export async function findUserIdsSharingAcceptedScrim({
|
||||
userId,
|
||||
otherUserIds,
|
||||
}: {
|
||||
userId: number;
|
||||
otherUserIds: Array<number>;
|
||||
}): Promise<Set<number>> {
|
||||
if (otherUserIds.length === 0) return new Set();
|
||||
|
||||
const postIdsOfUser = db
|
||||
.selectFrom("ScrimPostUser")
|
||||
.select("ScrimPostUser.scrimPostId")
|
||||
.where("ScrimPostUser.userId", "=", userId)
|
||||
.union(
|
||||
db
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.innerJoin(
|
||||
"ScrimPostRequestUser",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
"ScrimPostRequest.id",
|
||||
)
|
||||
.select("ScrimPostRequest.scrimPostId")
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.where("ScrimPostRequestUser.userId", "=", userId),
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("ScrimPost")
|
||||
.innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id")
|
||||
.innerJoin("ScrimPostRequest", (join) =>
|
||||
join
|
||||
.onRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.on("ScrimPostRequest.isAccepted", "=", 1),
|
||||
)
|
||||
.select("ScrimPostUser.userId")
|
||||
.where("ScrimPost.canceledAt", "is", null)
|
||||
.where("ScrimPost.id", "in", postIdsOfUser)
|
||||
.where("ScrimPostUser.userId", "in", otherUserIds)
|
||||
.union((eb) =>
|
||||
eb
|
||||
.selectFrom("ScrimPost")
|
||||
.innerJoin(
|
||||
"ScrimPostRequest",
|
||||
"ScrimPostRequest.scrimPostId",
|
||||
"ScrimPost.id",
|
||||
)
|
||||
.innerJoin(
|
||||
"ScrimPostRequestUser",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
"ScrimPostRequest.id",
|
||||
)
|
||||
.select("ScrimPostRequestUser.userId")
|
||||
.where("ScrimPost.canceledAt", "is", null)
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.where("ScrimPost.id", "in", postIdsOfUser)
|
||||
.where("ScrimPostRequestUser.userId", "in", otherUserIds),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((row) => row.userId));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { untrack } from "svelte";
|
||||
import Main from "#lib/components/Main.svelte";
|
||||
import MatchPage from "#lib/components/match-page/MatchPage.svelte";
|
||||
import { chatUi } from "#lib/features/chat/chat-state.svelte.ts";
|
||||
import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import { getScrim } from "../scrims.remote.ts";
|
||||
import ScrimMatchBanner from "./ScrimMatchBanner.svelte";
|
||||
import ScrimMatchHeader from "./ScrimMatchHeader.svelte";
|
||||
@@ -15,13 +14,6 @@ interface Props {
|
||||
|
||||
let { scrimPostId }: Props = $props();
|
||||
|
||||
// xxx: ???
|
||||
// context must be provided before the first await of an async component; the
|
||||
// getter closes over `data` which resolves before anything renders
|
||||
setUserCardContext({
|
||||
userCards: () => data.userCards,
|
||||
});
|
||||
|
||||
const data = $derived(await getScrim({ scrimPostId }));
|
||||
|
||||
// xxx: need a better, generic solution
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Avatar from "#lib/components/Avatar.svelte";
|
||||
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
|
||||
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
|
||||
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import { m } from "#lib/paraglide/messages.js";
|
||||
import type { ScrimPostUser } from "../scrims-types.ts";
|
||||
|
||||
@@ -12,8 +11,6 @@ interface Props {
|
||||
|
||||
let { users }: Props = $props();
|
||||
|
||||
const cards = getUserCardContext();
|
||||
|
||||
const sortedUsers = $derived(
|
||||
[...users].sort((a, b) => Number(b.isOwner) - Number(a.isOwner)),
|
||||
);
|
||||
@@ -22,22 +19,21 @@ const sortedUsers = $derived(
|
||||
<div class="stack md">
|
||||
{#each sortedUsers as user (user.id)}
|
||||
<UserCard userId={user.id} withMutualFriends>
|
||||
<span class="stack horizontal sm items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cards?.userCards()?.get(user.id)?.privateNote?.sentiment}
|
||||
size="sm"
|
||||
>
|
||||
<Avatar size="xs" {user} />
|
||||
</NoteAvatar>
|
||||
<span>
|
||||
{user.username}
|
||||
{#if user.isOwner}
|
||||
<div class="text-lighter text-xs">
|
||||
{m.scrims_cancelRequestModal_requester()}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet children(card)}
|
||||
<span class="stack horizontal sm items-center">
|
||||
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" {user} />
|
||||
</NoteAvatar>
|
||||
<span>
|
||||
{user.username}
|
||||
{#if user.isOwner}
|
||||
<div class="text-lighter text-xs">
|
||||
{m.scrims_cancelRequestModal_requester()}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
</UserCard>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Avatar from "#lib/components/Avatar.svelte";
|
||||
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
|
||||
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
|
||||
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import type { ScrimPostUser } from "../scrims-types.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -12,17 +11,16 @@ interface Props {
|
||||
}
|
||||
|
||||
let { teamAvatarUrl, teamName, owner }: Props = $props();
|
||||
|
||||
const cards = getUserCardContext();
|
||||
const cardData = $derived(cards?.userCards()?.get(owner.id));
|
||||
</script>
|
||||
|
||||
{#if teamAvatarUrl}
|
||||
<Avatar size="xs" url={teamAvatarUrl} alt={teamName} />
|
||||
{:else}
|
||||
<UserCard userId={owner.id} withMutualFriends>
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" user={owner} alt={owner.username} />
|
||||
</NoteAvatar>
|
||||
{#snippet children(card)}
|
||||
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" user={owner} alt={owner.username} />
|
||||
</NoteAvatar>
|
||||
{/snippet}
|
||||
</UserCard>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button, Popover } from "@sendou/components";
|
||||
import Avatar from "#lib/components/Avatar.svelte";
|
||||
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
|
||||
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
|
||||
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import type { ScrimPostUser } from "../scrims-types.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -12,8 +11,6 @@ interface Props {
|
||||
}
|
||||
|
||||
let { users }: Props = $props();
|
||||
|
||||
const cards = getUserCardContext();
|
||||
</script>
|
||||
|
||||
<Popover>
|
||||
@@ -25,16 +22,14 @@ const cards = getUserCardContext();
|
||||
<div class="stack md">
|
||||
{#each users as user (user.id)}
|
||||
<UserCard userId={user.id} withMutualFriends>
|
||||
<span class="stack horizontal sm items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cards?.userCards()?.get(user.id)?.privateNote
|
||||
?.sentiment}
|
||||
size="xs"
|
||||
>
|
||||
<Avatar size="xxs" {user} />
|
||||
</NoteAvatar>
|
||||
{user.username}
|
||||
</span>
|
||||
{#snippet children(card)}
|
||||
<span class="stack horizontal sm items-center">
|
||||
<NoteAvatar sentiment={card?.privateNote?.sentiment} size="xs">
|
||||
<Avatar size="xxs" {user} />
|
||||
</NoteAvatar>
|
||||
{user.username}
|
||||
</span>
|
||||
{/snippet}
|
||||
</UserCard>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as R from "remeda";
|
||||
import LocaleTime from "#lib/components/LocaleTime.svelte";
|
||||
import Main from "#lib/components/Main.svelte";
|
||||
import { loggedInUser } from "#lib/features/auth/user-state.ts";
|
||||
import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
|
||||
import { m } from "#lib/paraglide/messages.js";
|
||||
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
|
||||
@@ -23,12 +22,6 @@ const user = $derived(loggedInUser());
|
||||
|
||||
const params = searchParamsState(scrimsSearchParams);
|
||||
|
||||
// context must be provided before the first await of an async component; the
|
||||
// getter closes over `data` which resolves before anything renders
|
||||
setUserCardContext({
|
||||
userCards: () => data.userCards,
|
||||
});
|
||||
|
||||
const data = $derived(
|
||||
await getScrimPosts({
|
||||
weekdayTimes: params.current.weekdayTimes,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { resolveNotifications } from "#lib/features/notifications/core/resolve.s
|
||||
import * as SQGroupRepository from "#lib/features/sendouq/SQGroupRepository.server.ts";
|
||||
import * as TeamRepository from "#lib/features/team/TeamRepository.server.ts";
|
||||
import { getMemberRoleType } from "#lib/features/team/team-utils.ts";
|
||||
import * as UserCardRepository from "#lib/features/user-page/UserCardRepository.server.ts";
|
||||
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
|
||||
import { requirePermission } from "#lib/modules/permissions/guards.server.ts";
|
||||
import * as Events from "#lib/server/events.ts";
|
||||
@@ -114,19 +113,7 @@ export const getScrimPosts = query(
|
||||
}),
|
||||
}));
|
||||
|
||||
const cardUserIds = R.unique(
|
||||
posts.flatMap((post) => [
|
||||
...post.users.map((postUser) => postUser.id),
|
||||
...post.requests.flatMap((request) =>
|
||||
request.users.map((requestUser) => requestUser.id),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.findAllByUserIds({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
posts: dividePosts(posts, user?.id),
|
||||
teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
|
||||
filters,
|
||||
@@ -212,10 +199,6 @@ async function scrimSnapshot(scrimPostId: number) {
|
||||
});
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.findAllByUserIds({
|
||||
userIds: participantIds,
|
||||
include: { friendCode: true },
|
||||
})),
|
||||
post,
|
||||
chatRoomId:
|
||||
user.roles.includes("STAFF") || participantIds.includes(user.id)
|
||||
|
||||
@@ -23,13 +23,15 @@ import Placement from "#lib/components/Placement.svelte";
|
||||
import TierImage from "#lib/components/TierImage.svelte";
|
||||
import type { XRankPlacementRegion } from "#lib/db/tables-json.ts";
|
||||
import { loggedInUser } from "#lib/features/auth/user-state.ts";
|
||||
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
|
||||
import type {
|
||||
UserCardData,
|
||||
UserCardFriendship,
|
||||
UserCardStat,
|
||||
} from "#lib/features/user-page/user-card-types.ts";
|
||||
import { getUserCardFriendship } from "#lib/features/user-page/user-card.remote.ts";
|
||||
import {
|
||||
getUserCard,
|
||||
getUserCardFriendship,
|
||||
} from "#lib/features/user-page/user-card.remote.ts";
|
||||
import { m } from "#lib/paraglide/messages.js";
|
||||
import {
|
||||
brandImageUrl,
|
||||
@@ -54,30 +56,24 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
|
||||
};
|
||||
|
||||
interface Props {
|
||||
userId?: number;
|
||||
data?: UserCardData;
|
||||
userId: number;
|
||||
/** Ask for the user's friend code; the server only sends it to viewers entitled to it. */
|
||||
withFriendCode?: boolean;
|
||||
/** Fetch and show the mutual friends row. Off by default. */
|
||||
withMutualFriends?: boolean;
|
||||
children: Snippet;
|
||||
children: Snippet<[UserCardData | undefined]>;
|
||||
}
|
||||
|
||||
let {
|
||||
userId,
|
||||
data: dataProp,
|
||||
withFriendCode = false,
|
||||
withMutualFriends = false,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const context = getUserCardContext();
|
||||
const data = $derived(await getUserCard({ userId, withFriendCode }));
|
||||
|
||||
const data = $derived(
|
||||
dataProp ??
|
||||
(typeof userId === "number"
|
||||
? context?.userCards()?.get(userId)
|
||||
: undefined),
|
||||
);
|
||||
|
||||
const isOwnCard = $derived(loggedInUser()?.id === data?.id);
|
||||
const isOwnCard = $derived(loggedInUser()?.id === userId);
|
||||
const returnTo = $derived(`${page.url.pathname}${page.url.search}`);
|
||||
|
||||
const stats = $derived(
|
||||
@@ -101,11 +97,10 @@ function handleOpenChange(nextIsOpen: boolean) {
|
||||
if (!nextIsOpen) return;
|
||||
if (friendshipRequested) return;
|
||||
if (isOwnCard) return;
|
||||
const targetUserId = data?.id;
|
||||
if (typeof targetUserId !== "number") return;
|
||||
|
||||
// xxx: do this properly
|
||||
friendshipRequested = true;
|
||||
getUserCardFriendship({ userId: targetUserId, withMutualFriends })
|
||||
getUserCardFriendship({ userId, withMutualFriends })
|
||||
.then((result) => {
|
||||
friendship = result;
|
||||
})
|
||||
@@ -167,10 +162,8 @@ function customThemeStyle(customTheme: UserCardData["customTheme"]) {
|
||||
|
||||
<!--
|
||||
@component
|
||||
Click-to-open trigger that shows a popover with the user's card. Card data is resolved by `userId`
|
||||
from the {@link UserCardContext} (set by pages whose queries spread `{ userCards }` from
|
||||
`UserCardRepository.findAllByUserIds`); pass `data` directly to bypass the lookup. When no card data
|
||||
exists for the user, the `children` are rendered plain without a trigger.
|
||||
Click-to-open trigger that shows a popover with the user's card. Card data is loaded by the card
|
||||
itself through the batched `getUserCard` query.
|
||||
|
||||
Viewer-relative friendship data (`isFriend`) is lazy-loaded via the `getUserCardFriendship` remote
|
||||
query the first time the card opens. Mutual friends are only fetched and shown when
|
||||
@@ -303,7 +296,7 @@ both the extra query and the row.
|
||||
{/snippet}
|
||||
|
||||
{#if !data}
|
||||
{@render children()}
|
||||
{@render children(data)}
|
||||
{:else}
|
||||
<Popover
|
||||
{isOpen}
|
||||
@@ -312,7 +305,7 @@ both the extra query and the row.
|
||||
>
|
||||
{#snippet trigger(triggerProps)}
|
||||
<button type="button" class="trigger" {...triggerProps}>
|
||||
{@render children()}
|
||||
{@render children(data)}
|
||||
</button>
|
||||
{/snippet}
|
||||
<div
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createContext } from "svelte";
|
||||
import type { UserCardData } from "./user-card-types.ts";
|
||||
|
||||
export interface UserCardContext {
|
||||
/** Card data by user id, as returned by `UserCardRepository.findAllByUserIds`. */
|
||||
userCards: () => Map<number, UserCardData> | undefined;
|
||||
}
|
||||
|
||||
const [getOrThrow, set] = createContext<UserCardContext>();
|
||||
|
||||
/** Provides `UserCard` data for the subtree; pages whose queries include `userCards` set this. */
|
||||
export const setUserCardContext = set;
|
||||
|
||||
/**
|
||||
* The nearest provided {@link UserCardContext}, or `undefined` when no ancestor set one —
|
||||
* `UserCard` then renders its children plain without the popover.
|
||||
*/
|
||||
export function getUserCardContext(): UserCardContext | undefined {
|
||||
try {
|
||||
return getOrThrow();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,48 @@
|
||||
import * as R from "remeda";
|
||||
import * as v from "valibot";
|
||||
import { getUser } from "#lib/features/auth/user.server.ts";
|
||||
import * as FriendRepository from "#lib/features/friends/FriendRepository.server.ts";
|
||||
import * as ScrimPostRepository from "#lib/features/scrims/ScrimPostRepository.server.ts";
|
||||
import { id } from "#lib/utils/schemas.ts";
|
||||
import { query } from "$app/server";
|
||||
import type { UserCardFriendship } from "./user-card-types.ts";
|
||||
import * as UserCardRepository from "./UserCardRepository.server.ts";
|
||||
import type { UserCardData, UserCardFriendship } from "./user-card-types.ts";
|
||||
|
||||
/**
|
||||
* Card data for one user, loaded by the `UserCard` component itself. Every card of a page calls
|
||||
* this in the same macrotask, so they resolve as one request and one batch of database queries.
|
||||
*
|
||||
* `withFriendCode` is a request, not a grant: friend codes are private to the people a user is
|
||||
* scheduled to play with, so the server resolves who the viewer may see one of (see
|
||||
* {@link friendCodeVisibleUserIds}) rather than trusting the caller.
|
||||
*/
|
||||
export const getUserCard = query.batch(
|
||||
v.object({ userId: id, withFriendCode: v.boolean() }),
|
||||
async (args) => {
|
||||
const userIds = R.unique(args.map((arg) => arg.userId));
|
||||
const friendCodeRequestedIds = R.unique(
|
||||
args.filter((arg) => arg.withFriendCode).map((arg) => arg.userId),
|
||||
);
|
||||
|
||||
// xxx: add cache
|
||||
const [{ userCards }, friendCodeVisibleIds] = await Promise.all([
|
||||
UserCardRepository.findAllByUserIds({
|
||||
userIds,
|
||||
include: { friendCode: friendCodeRequestedIds.length > 0 },
|
||||
}),
|
||||
friendCodeVisibleUserIds(friendCodeRequestedIds),
|
||||
]);
|
||||
|
||||
return ({ userId, withFriendCode }): UserCardData | undefined => {
|
||||
const card = userCards.get(userId);
|
||||
if (!card) return undefined;
|
||||
|
||||
if (withFriendCode && friendCodeVisibleIds.has(userId)) return card;
|
||||
|
||||
return { ...card, friendCode: null };
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
|
||||
@@ -54,3 +93,22 @@ export const getUserCardFriendship = query(
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Of `userIds`, the ones whose friend code the viewer has a reason to see right now: they share an
|
||||
* accepted scrim. Staff see every requested code, and a logged out viewer none.
|
||||
*/
|
||||
async function friendCodeVisibleUserIds(
|
||||
userIds: Array<number>,
|
||||
): Promise<Set<number>> {
|
||||
if (userIds.length === 0) return new Set();
|
||||
|
||||
const viewer = getUser();
|
||||
if (!viewer) return new Set();
|
||||
if (viewer.roles.includes("STAFF")) return new Set(userIds);
|
||||
|
||||
return ScrimPostRepository.findUserIdsSharingAcceptedScrim({
|
||||
userId: viewer.id,
|
||||
otherUserIds: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user