Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/game/src/components/online/invite-link-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cn } from "@repo/nativ/utils"
import { useMutation } from "@tanstack/react-query"
import { useId, useState } from "react"
import { useEffect, useId, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
CheckIcon,
Expand Down Expand Up @@ -55,6 +55,10 @@ export function InviteLinkCard({
const [status, setStatus] = useState<HandOffStatus>("idle")
const [justCopied, setJustCopied] = useState(false)
const [canShare] = useState(canShareLinks)
const copiedTimer = useRef<number>(undefined)

//the check outlives the tap by two seconds, and the sheet may not
useEffect(() => () => window.clearTimeout(copiedTimer.current), [])

const url = create.data?.url
const isMade = Boolean(url)
Expand All @@ -75,7 +79,11 @@ export function InviteLinkCard({
setStatus(next)
if (next !== "copied") return
setJustCopied(true)
window.setTimeout(() => setJustCopied(false), COPIED_MS)
window.clearTimeout(copiedTimer.current)
copiedTimer.current = window.setTimeout(
() => setJustCopied(false),
COPIED_MS,
)
}

async function handleShare() {
Expand Down
3 changes: 3 additions & 0 deletions apps/game/src/components/online/recent-opponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ function OpponentChip({
className="peer absolute inset-0 z-10 size-full cursor-[inherit] appearance-none rounded-xl opacity-0"
/>

{/* The face is for the eye: its initial read first would put a stray
letter in front of the name in the chip's accessible name. */}
<span
aria-hidden="true"
className={cn(
"rounded-[11px] transition-shadow peer-focus-visible:shadow-[0_0_0_2px_var(--color-surface),0_0_0_4px_var(--color-brand)]",
checked &&
Expand Down
32 changes: 19 additions & 13 deletions apps/game/src/hooks/use-online-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useApiError } from "@/hooks/use-api-error"
import { useAuth } from "@/providers/auth-provider"
import { useRealtime } from "@/providers/realtime-provider"
import type { RecentOpponent } from "@/utils/recent-opponents"
import { recentOpponentsOf } from "@/utils/recent-opponents"
import { recentOpponentsWhenReady } from "@/utils/recent-opponents"
import type { SyncState } from "@/utils/sync-state"
import { syncStateOf } from "@/utils/sync-state"

Expand All @@ -37,8 +37,9 @@ export type OnlineHome = {
isSending: boolean
/**
* The people this player has played lately, for the composer to offer by
* face. `undefined` while the games are still on their way; empty when there
* are none, or when the games could not be read.
* face. `undefined` until both game lists are in, so the first chips shown
* are the final ones; empty when there are none, or when the games could not
* be read.
*/
recentOpponents: RecentOpponent[] | undefined
/** How current the lists are. See `SyncState`. */
Expand Down Expand Up @@ -134,19 +135,24 @@ export function useOnlineHome(queryClient: QueryClient): OnlineHome {
}
}, [invites.data, myUserId])

//the same two lists, read for names. a list that failed offers nobody
//rather than a spinner that never ends: typing a name works without it
//the same two lists, read for names, once both are in so the chips shown
//first are the final ones. a list that failed offers what is left rather
//than a spinner that never ends: typing a name works without it
const activeData = activeGames.data
const finishedData = finishedGames.data
const gamesFailed = Boolean(activeGames.error || finishedGames.error)
const recentOpponents = useMemo(() => {
if (!myUserId) return undefined
if (!activeData && !finishedData) return gamesFailed ? [] : undefined
return recentOpponentsOf(
[...(activeData ?? []), ...(finishedData ?? [])],
myUserId,
)
}, [activeData, finishedData, gamesFailed, myUserId])
const recentOpponents = useMemo(
() =>
recentOpponentsWhenReady(
{
activeGames: activeData,
finishedGames: finishedData,
failed: gamesFailed,
},
myUserId,
),
[activeData, finishedData, gamesFailed, myUserId],
)

//one slot for the whole screen: only one of these can have just failed, and
//four error lines stacked down the page would be four ways of saying it
Expand Down
65 changes: 64 additions & 1 deletion apps/game/src/utils/recent-opponents.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"
import type { Game } from "@/data/online/queries"
import { recentOpponentsOf } from "@/utils/recent-opponents"
import {
recentOpponentsOf,
recentOpponentsWhenReady,
} from "@/utils/recent-opponents"

const ME = "me"

Expand Down Expand Up @@ -105,3 +108,63 @@ describe("recentOpponentsOf", () => {
).toEqual(["Ana", "Bia", "Rui"])
})
})

describe("recentOpponentsWhenReady", () => {
const active = [gameAgainst("Ana", 300, { status: "active" })]
const finished = [gameAgainst("Bia", 200)]

it("offers nothing yet while either list is still on its way", () => {
const sources = { activeGames: active, finishedGames: undefined }
expect(
recentOpponentsWhenReady({ ...sources, failed: false }, ME),
).toBeUndefined()
expect(
recentOpponentsWhenReady(
{ activeGames: undefined, finishedGames: finished, failed: false },
ME,
),
).toBeUndefined()
expect(
recentOpponentsWhenReady(
{
activeGames: undefined,
finishedGames: undefined,
failed: false,
},
ME,
),
).toBeUndefined()
})

it("reads the chips once, from both lists, when both are in", () => {
const names = recentOpponentsWhenReady(
{ activeGames: active, finishedGames: finished, failed: false },
ME,
)?.map((opponent) => opponent.name)
expect(names).toEqual(["Ana", "Bia"])
})

it("settles with what is in hand when a list failed", () => {
expect(
recentOpponentsWhenReady(
{ activeGames: active, finishedGames: undefined, failed: true },
ME,
)?.map((opponent) => opponent.name),
).toEqual(["Ana"])
expect(
recentOpponentsWhenReady(
{ activeGames: undefined, finishedGames: undefined, failed: true },
ME,
),
).toEqual([])
})

it("waits for a signed-in player", () => {
expect(
recentOpponentsWhenReady(
{ activeGames: active, finishedGames: finished, failed: false },
undefined,
),
).toBeUndefined()
})
})
31 changes: 31 additions & 0 deletions apps/game/src/utils/recent-opponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,34 @@ export function recentOpponentsOf(
.sort((first, second) => second.lastPlayedAt - first.lastPlayedAt)
.slice(0, RECENT_OPPONENT_LIMIT)
}

/** What the hub holds of the two lists the chips are read from. */
export type OpponentSources = {
/** `undefined` until the list is in. */
activeGames: Game[] | undefined
finishedGames: Game[] | undefined
/** Either list failed to load. */
failed: boolean
}

/**
* The chips, once they are final.
*
* The two lists arrive separately, and reading names off whichever came first
* shows a row that reorders or swaps a face when the other lands. So the row
* waits for both (`undefined`, which the composer draws as empty chips). A
* list that failed settles it with what is in hand, nobody if that is nothing:
* typing a name works without the chips.
*/
export function recentOpponentsWhenReady(
{ activeGames, finishedGames, failed }: OpponentSources,
myUserId: string | undefined,
): RecentOpponent[] | undefined {
if (!myUserId) return undefined
const bothIn = activeGames !== undefined && finishedGames !== undefined
if (!bothIn && !failed) return undefined
return recentOpponentsOf(
[...(activeGames ?? []), ...(finishedGames ?? [])],
myUserId,
)
}
14 changes: 11 additions & 3 deletions e2e/invite-composer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ for (const viewport of FITTING_PHONES) {
await hostPage.setViewportSize(viewport)
const composer = await openComposer(hostPage)

const chip = composer.getByRole("radio", { name: guestName })
const chip = composer.getByRole("radio", {
name: guestName,
exact: true,
})
await expect(composer.getByText("Play again with")).toBeVisible()
await expect(chip).toBeVisible()
await expect(
Expand Down Expand Up @@ -165,7 +168,9 @@ for (const viewport of FITTING_PHONES) {
// Ask them again while that invite is still out: the refusal is spoken
// in the line that was reserved for it, and the picked chip is at fault.
const again = await openComposer(hostPage)
await again.getByRole("radio", { name: guestName }).check()
await again
.getByRole("radio", { name: guestName, exact: true })
.check()
await expectNoLayoutShift(composer, () => send.click(), {
until: () => expect(spokenAlert(composer)).toBeVisible(),
})
Expand Down Expand Up @@ -370,7 +375,10 @@ test.describe("on a 375x667 phone", () => {
await hostPage.setViewportSize({ width: 375, height: 667 })
const composer = await openComposer(hostPage)
const field = composer.getByLabel("Someone else", { exact: true })
const chip = composer.getByRole("radio", { name: guestName })
const chip = composer.getByRole("radio", {
name: guestName,
exact: true,
})

await field.fill(guestName.toUpperCase(), { timeout: 5000 })
await expect(chip).toBeChecked({ timeout: 5000 })
Expand Down
Loading