diff --git a/apps/game/src/components/icons.tsx b/apps/game/src/components/icons.tsx index 14415664..b34b2079 100644 --- a/apps/game/src/components/icons.tsx +++ b/apps/game/src/components/icons.tsx @@ -17,6 +17,7 @@ import { Image as ImageGlyph, Info, Lightbulb, + Link, List, LoaderCircle, LogIn, @@ -92,6 +93,7 @@ export const ShareIcon = glyph(Share) export const RotateIcon = glyph(RotateCw) export const GroupIcon = glyph(Users) export const LightbulbIcon = glyph(Lightbulb) +export const LinkIcon = glyph(Link) export const UndoIcon = glyph(Undo2) export const HistoryIcon = glyph(History) export const VolumeUpIcon = glyph(Volume2) diff --git a/apps/game/src/components/online/game-summary-row.tsx b/apps/game/src/components/online/game-summary-row.tsx new file mode 100644 index 00000000..71228297 --- /dev/null +++ b/apps/game/src/components/online/game-summary-row.tsx @@ -0,0 +1,65 @@ +import type { PlayableSetup } from "@repo/abalone-engine/board-setups" +import { BOARD_SETUPS } from "@repo/abalone-engine/board-setups" +import { useTranslation } from "react-i18next" +import type { ColorChoiceValue } from "@/components/color-choice" +import { BoardThumb } from "@/components/online/board-thumb" +import { Button } from "@/components/ui/button" +import { getSetupName } from "@/i18n/game-text" + +export type GameSummaryRowProps = { + setupType: PlayableSetup + side: ColorChoiceValue + /** Opens the panel with the carousel and the side picker. */ + onChange: () => void +} + +/** + * The terms of the game in one line: the opening, your side, and a way to + * change them. + * + * Most invites are the standard opening with a random side, and asking about + * both every time put a board preview and three tiles between the name and the + * send button. The terms are still the sender's to set; they are one press away + * instead of in the way. + */ +export function GameSummaryRow({ + setupType, + side, + onChange, +}: GameSummaryRowProps) { + const { t } = useTranslation() + const opening = BOARD_SETUPS[setupType] + + return ( +
+
+ `${row},${column}`, + )} + whiteCells={opening.white.map( + ([row, column]) => `${row},${column}`, + )} + /> +
+ + + + {getSetupName(setupType)} + + + {t(`online:compose.side_summary.${side}`)} + + + + +
+ ) +} diff --git a/apps/game/src/components/online/invite-composer.tsx b/apps/game/src/components/online/invite-composer.tsx index 4e4cf89d..ee5323d5 100644 --- a/apps/game/src/components/online/invite-composer.tsx +++ b/apps/game/src/components/online/invite-composer.tsx @@ -9,7 +9,10 @@ import { useTranslation } from "react-i18next" import type { ColorChoiceValue } from "@/components/color-choice" import { ColorChoice } from "@/components/color-choice" import { GameCanvas } from "@/components/game-canvas" -import { InviteLinkMaker } from "@/components/online/invite-link-maker" +import { BackIcon } from "@/components/icons" +import { GameSummaryRow } from "@/components/online/game-summary-row" +import { InviteLinkCard } from "@/components/online/invite-link-card" +import { RecentOpponents } from "@/components/online/recent-opponents" import { SetupCarousel } from "@/components/setup-carousel" import { Button } from "@/components/ui/button" import { Sheet } from "@/components/ui/sheet" @@ -17,6 +20,7 @@ import { StatusLine } from "@/components/ui/status-line" import { TextField } from "@/components/ui/text-field" import type { SendInviteInput } from "@/data/online/mutations" import { useMarbleDesign } from "@/hooks/use-marble-design" +import type { RecentOpponent } from "@/utils/recent-opponents" /** What the server can say about a name, for its line to hold room for. */ const SEND_ERRORS = [ @@ -39,24 +43,25 @@ export type InviteComposerProps = { error?: string /** A link was made, so whoever takes it opens a game from outside. */ onLinkCreated?: () => void + /** Who the player has played lately. `undefined` while the games load. */ + recentOpponents?: RecentOpponent[] } +type View = "main" | "game" + /** - * Asking somebody to play, in the shape the pregame panel already uses. - * - * The setup carousel and the side picker are the offline panel's own controls, - * so the two ways into a game ask the same questions in the same order. The one - * thing this adds is who you are asking. + * Asking somebody to play: who first, then the terms in one line. * - * The name is a field and nothing else. There is no lookup in front of it, no - * "player found" card, no second picture of somebody you just named — you type - * a name and press send, and the only thing that can come back is that nobody - * is called that. A search that runs on every keystroke to confirm a name you + * Who is a row of people you have played and a field for anybody else. There is + * no lookup in front of the field, no "player found" card: you pick a face or + * type a name and press send, and the only thing that can come back is that + * nobody is called that. A search on every keystroke to confirm a name you * already know is a request per letter for a fact you learn anyway. * - * The link sits under the terms rather than beside the name, because it uses - * them: it is for when there is no name to type, and the board and the side it - * carries are the ones picked above it. + * The opening and the side are the offline panel's own controls, folded into a + * summary row, because most invites leave both as they are. Change swaps the + * sheet to a panel with the carousel and the side picker. The link sits between + * the name and the terms as the other way to ask, and it carries the terms. */ export function InviteComposer({ open, @@ -65,15 +70,26 @@ export function InviteComposer({ pending, error, onLinkCreated, + recentOpponents, }: InviteComposerProps) { const { t } = useTranslation() const errorId = useId() const [marbleDesign] = useMarbleDesign() + const [view, setView] = useState("main") const [username, setUsername] = useState("") + const [pickedName, setPickedName] = useState(null) + //an error describes the name that was sent, so it ends at the next edit + const [isErrorStale, setIsErrorStale] = useState(false) const [setupType, setSetupType] = useState(DEFAULT_SETUP) const [side, setSide] = useState("random") - const handle = username.trim() + const typedName = username.trim() + //what the send goes to: the typed name, or the face picked. the two never + //coexist, each clears the other + const target = typedName || pickedName || "" + const shownError = isErrorStale ? undefined : error + const hasChips = + (recentOpponents?.length ?? 0) > 0 || recentOpponents === undefined //the opening position and nothing after it, so stepping through the setups //costs one board rather than one board per press @@ -82,61 +98,162 @@ export function InviteComposer({ [setupType], ) + function handleType(next: string) { + setUsername(next) + setPickedName(null) + setIsErrorStale(true) + } + + function handlePick(name: string) { + setPickedName(name) + setUsername("") + setIsErrorStale(true) + } + + function handleSend() { + setIsErrorStale(false) + onSend({ username: target, setupType, side }) + } + + function handleClose() { + if (view === "game") return setView("main") + onClose() + } + + const isGameView = view === "game" + const sendLabel = target + ? t("online:compose.send_to", { name: target }) + : t("online:compose.send") + return ( onSend({ username: handle, setupType, side })} - > - {t("online:compose.send")} - + <> + {!isGameView && ( + + )} + {isGameView && ( + + )} + } > -
-
- setUsername(event.target.value)} - autoComplete="off" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - maxLength={20} - invalid={Boolean(error)} - describedBy={errorId} + {/* Both views stay mounted, one hidden: the link card holds the link it + made, and going to the game panel and back without changing anything + must not throw it away. */} + + + ) diff --git a/apps/game/src/components/online/invite-link-card.tsx b/apps/game/src/components/online/invite-link-card.tsx new file mode 100644 index 00000000..cf70180e --- /dev/null +++ b/apps/game/src/components/online/invite-link-card.tsx @@ -0,0 +1,238 @@ +import { cn } from "@repo/nativ/utils" +import { useMutation } from "@tanstack/react-query" +import { useId, useState } from "react" +import { useTranslation } from "react-i18next" +import { + CheckIcon, + CopyIcon, + LinkIcon, + RotateIcon, + ShareIcon, + SpinnerIcon, +} from "@/components/icons" +import type { HandOffStatus } from "@/components/online/share-link-button" +import { + canShareLinks, + copyLink, + handOffLink, +} from "@/components/online/share-link-button" +import { Button } from "@/components/ui/button" +import { Reserved } from "@/components/ui/reserved" +import type { CreateInviteLinkInput } from "@/data/online/mutations" +import { createInviteLinkMutationOptions } from "@/data/online/mutations" + +/** How long the copy button shows a check before it is a copy button again. */ +const COPIED_MS = 2_000 + +const CARD_CLASS = "p-2 ps-3" + +export type InviteLinkCardProps = CreateInviteLinkInput & { + /** Called once the server hands the link back. */ + onCreated?: () => void +} + +/** + * The other way to ask, as a card: a link instead of a name. + * + * It takes the opening and the side the composer already has, so it asks + * nothing of its own, and the composer remounts it when either changes, which + * is what throws away a link made on the old terms. + * + * Before the link there is one tap target, the whole card. After it, the card + * is the link and the two ways to hand it on. The two layouts share one cell, + * so the card is as tall as the taller from the first render and making the + * link moves nothing under it, and each holds room for every line it can say. + */ +export function InviteLinkCard({ + setupType, + side, + onCreated, +}: InviteLinkCardProps) { + const { t } = useTranslation() + const statusId = useId() + const fieldId = useId() + const create = useMutation(createInviteLinkMutationOptions) + const [status, setStatus] = useState("idle") + const [justCopied, setJustCopied] = useState(false) + const [canShare] = useState(canShareLinks) + + const url = create.data?.url + const isMade = Boolean(url) + const shareText = t("online:link.share_text") + + const failedText = t("online:link.failed_short") + const readyText = t("online:link.ready") + const copiedText = t("online:link.copied_short") + const copyFailedText = t("online:link.copy_failed_short") + + let madeMessage = readyText + if (status === "copied") madeMessage = copiedText + if (status === "failed") madeMessage = copyFailedText + + async function handleCopy() { + if (!url) return + const next = await copyLink(url) + setStatus(next) + if (next !== "copied") return + setJustCopied(true) + window.setTimeout(() => setJustCopied(false), COPIED_MS) + } + + async function handleShare() { + if (url) setStatus(await handOffLink(url, shareText)) + } + + function handleCreate() { + create.mutate( + { setupType, side }, + { + onSuccess: async (link) => { + onCreated?.() + //WebKit may let the tap's permission to share lapse by now. that + //refusal says nothing: the link is on screen with its own buttons + const handedOff = await handOffLink(link.url, shareText) + if (handedOff !== "failed") setStatus(handedOff) + }, + }, + ) + } + + //both layouts hold the lines they can say, in the language and at the width + //they are read at, so neither one grows the card + const idleLines = [t("online:link.card_hint"), failedText] + const madeLines = [readyText, copiedText, copyFailedText] + + return ( + + + +
+ + + + + + {/* The whole link is still the value, to select or copy; the field + ends in an ellipsis where it is longer than the room. */} + + event.currentTarget.select()} + className="block h-[22px] w-full truncate rounded bg-transparent font-display text-[15px] font-bold text-white text-ellipsis focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" + /> + +

+ {isMade && madeMessage} +

+ {madeLines.map((text) => ( +
+
+ + + {canShare && ( + + )} +
+
+ ) +} diff --git a/apps/game/src/components/online/recent-opponents.tsx b/apps/game/src/components/online/recent-opponents.tsx new file mode 100644 index 00000000..fe699ff2 --- /dev/null +++ b/apps/game/src/components/online/recent-opponents.tsx @@ -0,0 +1,154 @@ +import { cn } from "@repo/nativ/utils" +import { useId } from "react" +import { useTranslation } from "react-i18next" +import { Avatar } from "@/components/ui/avatar" +import type { RecentOpponent } from "@/utils/recent-opponents" +import { RECENT_OPPONENT_LIMIT } from "@/utils/recent-opponents" + +/** One per chip the list can fill, so the row is its full height while it loads. */ +const SKELETON_SLOTS = Array.from( + { length: RECENT_OPPONENT_LIMIT }, + (_, slot) => `slot-${slot}`, +) + +/** The chip's box, held while the list loads so nothing moves when it lands. */ +const CHIP_CLASS = + "flex min-w-0 max-w-20 flex-1 flex-col items-center gap-1" + +export type RecentOpponentsProps = { + /** `undefined` while the games load: four empty chips hold the row. */ + opponents: RecentOpponent[] | undefined + /** The name that is checked, matched without regard to case. */ + value: string | null + onChange: (name: string) => void + /** A request is out, so nothing here is worth changing. */ + disabled?: boolean + /** The last send was refused and this chip is the name it was sent to. */ + invalid?: boolean +} + +/** + * The people you played lately, to ask again with one tap. + * + * One radio group, so the arrow keys move between them and the whole row is a + * single stop in the tab order. The native radio sits over the chip, see-through, + * so the whole chip is the target and the browser does the keyboard. The ring + * is a shadow, not a border: a checked chip moves nothing. + * + * Nobody to offer, and the row is not here: a first invite has no one to ask + * again, and an empty row is a hole in the sheet. + */ +export function RecentOpponents({ + opponents, + value, + onChange, + disabled = false, + invalid = false, +}: RecentOpponentsProps) { + const { t } = useTranslation() + const labelId = useId() + const groupName = useId() + + if (opponents?.length === 0) return null + + const checkedName = value?.toLowerCase() ?? null + + return ( +
+

+ {t("online:compose.recents")} +

+ +
+ {opponents === undefined && + SKELETON_SLOTS.map((slot) => ( + + ) +} + +type OpponentChipProps = { + opponent: RecentOpponent + groupName: string + checked: boolean + invalid: boolean + disabled: boolean + onChange: (name: string) => void +} + +/** One person: a face over a name. Checked, the face is ringed. */ +function OpponentChip({ + opponent, + groupName, + checked, + invalid, + disabled, + onChange, +}: OpponentChipProps) { + return ( + + ) +} diff --git a/apps/game/src/components/ui/sheet.tsx b/apps/game/src/components/ui/sheet.tsx index 899adde9..9fc4a029 100644 --- a/apps/game/src/components/ui/sheet.tsx +++ b/apps/game/src/components/ui/sheet.tsx @@ -20,6 +20,12 @@ export type SheetProps = { children?: ReactNode /** Actions, pinned below the body on desktop and last in the scroll on a phone. */ footer?: ReactNode + /** + * Keep the footer in view on a phone while the body scrolls under it. For an + * overlay whose action must not be found by scrolling, on a screen too short + * for all of it. The dialog shape pins its footer already. + */ + pinFooter?: boolean /** Extra classes for the panel itself — the dialog box, or the sheet. */ className?: string } @@ -45,6 +51,7 @@ export function Sheet({ description, children, footer, + pinFooter = false, className, }: SheetProps) { const isDesktop = useIsDesktop() @@ -132,7 +139,12 @@ export function Sheet({ {/* pt-4 on top of the shell's pb-1: the section gap, 20px, from the last block to the actions, as in the dialog shape */} {footer && ( - + {footer} )} diff --git a/apps/game/src/hooks/use-online-home.ts b/apps/game/src/hooks/use-online-home.ts index 3c608127..020f3f78 100644 --- a/apps/game/src/hooks/use-online-home.ts +++ b/apps/game/src/hooks/use-online-home.ts @@ -18,6 +18,8 @@ import { 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 type { SyncState } from "@/utils/sync-state" import { syncStateOf } from "@/utils/sync-state" @@ -33,6 +35,12 @@ export type OnlineHome = { /** Which row action is in flight, so that one button can show it is waiting. */ pendingInvite: PendingInvite | null 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. + */ + recentOpponents: RecentOpponent[] | undefined /** How current the lists are. See `SyncState`. */ sync: SyncState /** Whatever the last list or row action said, already translated. */ @@ -126,6 +134,20 @@ 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 + 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]) + //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 const failure = @@ -149,6 +171,7 @@ export function useOnlineHome(queryClient: QueryClient): OnlineHome { isBusy: accept.isPending || decline.isPending || remove.isPending, pendingInvite: pendingInviteOf({ accept, decline, remove }), isSending: send.isPending, + recentOpponents, sync, error: failure ? translateError(failure) : undefined, composeError: send.error ? translateError(send.error) : undefined, diff --git a/apps/game/src/i18n/locales/de/errors.json b/apps/game/src/i18n/locales/de/errors.json index df5b35b0..327ddd25 100644 --- a/apps/game/src/i18n/locales/de/errors.json +++ b/apps/game/src/i18n/locales/de/errors.json @@ -10,7 +10,7 @@ "api_unsupported_media_type": "Nimm ein PNG, JPEG oder WebP.", "api_player_not_found": "Unter diesem Namen spielt niemand.", "api_invite_self": "Du kannst dich nicht selbst einladen.", - "api_invite_exists": "Bei diesem Spieler ist schon eine Einladung offen.", + "api_invite_exists": "Du hast sie schon eingeladen.", "api_game_not_active": "Diese Partie ist schon vorbei.", "api_not_your_turn": "Du bist noch nicht dran.", "api_illegal_move": "Dieser Zug ist nicht erlaubt.", diff --git a/apps/game/src/i18n/locales/de/online.json b/apps/game/src/i18n/locales/de/online.json index bd419eee..dffad9aa 100644 --- a/apps/game/src/i18n/locales/de/online.json +++ b/apps/game/src/i18n/locales/de/online.json @@ -72,7 +72,19 @@ "username_hint": "Der Name, mit dem sie sich anmelden.", "setup": "Aufstellung", "side": "Deine Seite", - "send": "Einladung senden" + "send": "Einladung senden", + "recents": "Nochmal spielen mit", + "someone_else": "Jemand anderes", + "send_to": "{{name}} einladen", + "change": "Ändern", + "game": "Partie", + "done": "Fertig", + "back": "Zurück", + "side_summary": { + "random": "Seite wird ausgelost", + "black": "Du spielst Schwarz", + "white": "Du spielst Weiß" + } }, "board": { "against": "gegen {{name}}", @@ -102,7 +114,12 @@ "copy": "Link kopieren", "copied": "Link kopiert", "copy_failed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn.", - "share_text": "Lust auf eine Partie Abalone? Öffne diesen Link und spiel gegen mich." + "share_text": "Lust auf eine Partie Abalone? Öffne diesen Link und spiel gegen mich.", + "card_hint": "Jeder kann ihn nutzen", + "ready": "Link bereit, gilt 7 Tage", + "copied_short": "Kopiert, gilt 7 Tage", + "copy_failed_short": "Zum Kopieren markieren", + "failed_short": "Ging nicht. Tippe nochmal" }, "invite_link": { "loading": "Einladung wird geöffnet", diff --git a/apps/game/src/i18n/locales/en/online.json b/apps/game/src/i18n/locales/en/online.json index d6e5eada..8627512c 100644 --- a/apps/game/src/i18n/locales/en/online.json +++ b/apps/game/src/i18n/locales/en/online.json @@ -71,7 +71,19 @@ "username_hint": "The name they sign in with.", "setup": "Opening", "side": "Your side", - "send": "Send invite" + "send": "Send invite", + "recents": "Play again with", + "someone_else": "Someone else", + "send_to": "Invite {{name}}", + "change": "Change", + "game": "Game", + "done": "Done", + "back": "Back", + "side_summary": { + "random": "You play either side", + "black": "You play black", + "white": "You play white" + } }, "board": { "against": "vs {{name}}", @@ -101,7 +113,12 @@ "copy": "Copy link", "copied": "Link copied", "copy_failed": "Couldn't copy. Select the link and copy it.", - "share_text": "Want a game of Abalone? Open this link to play me." + "share_text": "Want a game of Abalone? Open this link to play me.", + "card_hint": "Anyone can take it", + "ready": "Link ready, lasts 7 days", + "copied_short": "Copied, lasts 7 days", + "copy_failed_short": "Select it to copy", + "failed_short": "Couldn't make it. Tap to retry" }, "invite_link": { "loading": "Opening the invite", diff --git a/apps/game/src/i18n/locales/pt/online.json b/apps/game/src/i18n/locales/pt/online.json index 5954ff31..4461cef7 100644 --- a/apps/game/src/i18n/locales/pt/online.json +++ b/apps/game/src/i18n/locales/pt/online.json @@ -72,7 +72,19 @@ "username_hint": "O nome com que iniciam sessão.", "setup": "Abertura", "side": "O teu lado", - "send": "Enviar convite" + "send": "Enviar convite", + "recents": "Jogar outra vez com", + "someone_else": "Outra pessoa", + "send_to": "Convidar {{name}}", + "change": "Mudar", + "game": "Jogo", + "done": "Feito", + "back": "Voltar", + "side_summary": { + "random": "O lado é sorteado", + "black": "Jogas com as pretas", + "white": "Jogas com as brancas" + } }, "board": { "against": "contra {{name}}", @@ -102,7 +114,12 @@ "copy": "Copiar link", "copied": "Link copiado", "copy_failed": "Não deu para copiar. Seleciona o link e copia.", - "share_text": "Jogamos uma partida de Abalone? Abre este link para jogares contra mim." + "share_text": "Jogamos uma partida de Abalone? Abre este link para jogares contra mim.", + "card_hint": "Qualquer pessoa o pode usar", + "ready": "Link pronto, dura 7 dias", + "copied_short": "Copiado, dura 7 dias", + "copy_failed_short": "Seleciona para copiar", + "failed_short": "Não deu. Toca para repetir" }, "invite_link": { "loading": "A abrir o convite", diff --git a/apps/game/src/routing/pages/online.page.tsx b/apps/game/src/routing/pages/online.page.tsx index 1b4b656e..28ba2fa2 100644 --- a/apps/game/src/routing/pages/online.page.tsx +++ b/apps/game/src/routing/pages/online.page.tsx @@ -116,6 +116,7 @@ function OnlinePage() { onClose={() => setComposing(false)} pending={online.isSending} error={online.composeError} + recentOpponents={online.recentOpponents} onSend={(input) => online.send(input, () => setComposing(false))} onLinkCreated={() => setGamesAtLink( diff --git a/apps/game/src/utils/recent-opponents.test.ts b/apps/game/src/utils/recent-opponents.test.ts new file mode 100644 index 00000000..3ada0a16 --- /dev/null +++ b/apps/game/src/utils/recent-opponents.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest" +import type { Game } from "@/data/online/queries" +import { recentOpponentsOf } from "@/utils/recent-opponents" + +const ME = "me" + +/** A game I hold black in against `name`, last touched at `updatedAt`. */ +function gameAgainst( + name: string, + updatedAt: number, + overrides: Partial = {}, +): Game { + return { + id: `game-${name}-${updatedAt}`, + status: "finished", + black: { userId: ME, name: "Me", avatarUrl: null }, + white: { + userId: `user-${name.toLowerCase()}`, + name, + avatarUrl: `https://cdn/${name}.webp`, + }, + updatedAt, + ...overrides, + } as Game +} + +describe("recentOpponentsOf", () => { + it("is empty for a player with no games", () => { + expect(recentOpponentsOf([], ME)).toEqual([]) + }) + + it("lists the other seat, whichever seat I hold, most recent first", () => { + const asWhite = gameAgainst("Ana", 300, { + black: { userId: "user-ana", name: "Ana", avatarUrl: null }, + white: { userId: ME, name: "Me", avatarUrl: null }, + }) + const games = [ + gameAgainst("Rui", 100), + asWhite, + gameAgainst("Bia", 200), + ] + + expect( + recentOpponentsOf(games, ME).map((entry) => entry.name), + ).toEqual(["Ana", "Bia", "Rui"]) + }) + + it("carries the picture and when they last played", () => { + expect(recentOpponentsOf([gameAgainst("Rui", 100)], ME)).toEqual([ + { + name: "Rui", + avatarUrl: "https://cdn/Rui.webp", + lastPlayedAt: 100, + }, + ]) + }) + + it("names each opponent once, at their most recent game, ignoring case", () => { + const games = [ + gameAgainst("Rui", 100), + gameAgainst("rui", 400), + gameAgainst("Bia", 200), + ] + + expect(recentOpponentsOf(games, ME)).toEqual([ + { + name: "rui", + avatarUrl: "https://cdn/rui.webp", + lastPlayedAt: 400, + }, + { + name: "Bia", + avatarUrl: "https://cdn/Bia.webp", + lastPlayedAt: 200, + }, + ]) + }) + + it("never lists me, even against myself", () => { + const solo = gameAgainst("Me", 500, { + white: { userId: ME, name: "Me", avatarUrl: null }, + }) + + expect(recentOpponentsOf([solo], ME)).toEqual([]) + }) + + it("stops at four, so the rest are reached by typing", () => { + const games = ["A", "B", "C", "D", "E", "F"].map((name, index) => + gameAgainst(name, index), + ) + + expect( + recentOpponentsOf(games, ME).map((entry) => entry.name), + ).toEqual(["F", "E", "D", "C"]) + }) + + it("takes games from both lists in one go, newest across them", () => { + const active = [gameAgainst("Ana", 900, { status: "active" })] + const finished = [gameAgainst("Rui", 100), gameAgainst("Bia", 500)] + + expect( + recentOpponentsOf([...finished, ...active], ME).map( + (entry) => entry.name, + ), + ).toEqual(["Ana", "Bia", "Rui"]) + }) +}) diff --git a/apps/game/src/utils/recent-opponents.ts b/apps/game/src/utils/recent-opponents.ts new file mode 100644 index 00000000..3ce83848 --- /dev/null +++ b/apps/game/src/utils/recent-opponents.ts @@ -0,0 +1,47 @@ +import type { Game } from "@/data/online/queries" + +/** The most people the composer offers by face. The rest are reached by typing. */ +export const RECENT_OPPONENT_LIMIT = 4 + +/** Somebody I have played, as the invite composer offers them. */ +export type RecentOpponent = { + name: string + avatarUrl: string | null + /** The `updatedAt` of the latest game between us. */ + lastPlayedAt: number +} + +/** + * Who I played most recently, from the games the hub already holds. + * + * Active and finished games go in together: the person I am mid-game with is + * the one most likely to be asked again. Each name appears once, at their latest + * game, matched without regard to case because that is how the server matches a + * username. There is no request behind this: the lists are the hub's own. + */ +export function recentOpponentsOf( + games: Game[], + myUserId: string, +): RecentOpponent[] { + const latestByName = new Map() + + for (const game of games) { + const opponent = + game.black.userId === myUserId ? game.white : game.black + if (opponent.userId === myUserId) continue + + const key = opponent.name.toLowerCase() + const known = latestByName.get(key) + if (known && known.lastPlayedAt >= game.updatedAt) continue + + latestByName.set(key, { + name: opponent.name, + avatarUrl: opponent.avatarUrl, + lastPlayedAt: game.updatedAt, + }) + } + + return [...latestByName.values()] + .sort((first, second) => second.lastPlayedAt - first.lastPlayedAt) + .slice(0, RECENT_OPPONENT_LIMIT) +} diff --git a/e2e/invite-composer.spec.ts b/e2e/invite-composer.spec.ts new file mode 100644 index 00000000..ce448c5f --- /dev/null +++ b/e2e/invite-composer.spec.ts @@ -0,0 +1,424 @@ +import type { Browser, Locator, Page } from "@playwright/test" +import { expect, test } from "@playwright/test" +import { openApp, signUp, uniqueUsername } from "./support/app" +import { + expectNoClippedText, + expectNoLayoutShift, + expectNoScrolling, + waitForRest, +} from "./support/layout" + +// The New invite sheet, who first: the people you played lately, a field for +// anybody else, the link as a card, and the terms in one line that Change opens +// into a panel of their own (TUD-3855, acceptance criteria 1 to 11). Layout is +// held with the shared checks: nothing moves when something is pressed, no text +// is cut off, and the sheet does not scroll on the phones it was drawn for. + +const INVITE_LINK = /\/invite\/[A-Za-z0-9_-]{43}$/ + +/** What the app stores for the language switch in settings. */ +const LANGUAGE_STORAGE_KEY = "abalone_language" + +/** Phones the sheet fits on without scrolling, and the smallest it may scroll on. */ +const FITTING_PHONES = [ + { width: 375, height: 667 }, + { width: 375, height: 548 }, + { width: 390, height: 664 }, +] +const SMALLEST_PHONE = { width: 320, height: 460 } + +/** Takes the share sheet away and records the clipboard, as a desktop has. */ +async function copyToRecordedClipboard(page: Page): Promise { + await page.addInitScript(() => { + delete (Navigator.prototype as { share?: unknown }).share + delete (navigator as { share?: unknown }).share + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async () => {} }, + }) + }) +} + +async function openComposer(page: Page): Promise { + await openApp(page, "/online?invite=new") + const composer = page.getByRole("dialog") + await expect(composer).toBeVisible() + await waitForRest(composer) + return composer +} + +/** The one alert in `scope` that has something to say. */ +function spokenAlert(scope: Locator): Locator { + return scope.getByRole("alert").filter({ hasText: /\S/ }) +} + +/** A host who has played one guest, so the host's composer has a chip. */ +async function hostWithOneOpponent(browser: Browser) { + const host = await browser.newContext({ serviceWorkers: "block" }) + const guest = await browser.newContext() + const hostPage = await host.newPage() + const guestPage = await guest.newPage() + + const hostName = uniqueUsername("host") + const guestName = uniqueUsername("guest") + await signUp(hostPage, hostName) + await signUp(guestPage, guestName) + + const composer = await openComposer(hostPage) + await composer.getByLabel("Opponent", { exact: true }).fill(guestName) + await composer + .getByRole("button", { name: `Invite ${guestName}` }) + .click() + await expect(composer).toBeHidden() + + await openApp(guestPage, "/online") + await guestPage + .getByRole("button", { name: "Accept" }) + .filter({ visible: true }) + .first() + .click({ timeout: 30_000 }) + await expect(guestPage).toHaveURL(/\/online\/(?!history)[^/]+$/) + await guest.close() + + return { host, hostPage, guestName } +} + +for (const viewport of FITTING_PHONES) { + test.describe(`on a ${viewport.width}x${viewport.height} phone`, () => { + test.use({ viewport }) + + test("a first invite has no chips, asks for an opponent and does not scroll", async ({ + page, + }) => { + await signUp(page, uniqueUsername("first")) + const composer = await openComposer(page) + + await expect(composer.getByText("Play again with")).toHaveCount(0) + await expect(composer.getByRole("radio")).toHaveCount(0) + await expect( + composer.getByLabel("Opponent", { exact: true }), + ).toBeVisible() + await expect( + composer.getByRole("button", { name: "Send invite" }), + ).toBeDisabled() + + await expectNoClippedText(composer) + await expectNoScrolling(composer) + }) + + test("a refused name lands in the line that was already there, with a chip or without", async ({ + page, + }) => { + await signUp(page, uniqueUsername("refused")) + const composer = await openComposer(page) + + const nobody = uniqueUsername("nobody") + await composer.getByLabel("Opponent", { exact: true }).fill(nobody) + await expectNoLayoutShift( + composer, + () => + composer + .getByRole("button", { name: `Invite ${nobody}` }) + .click(), + { until: () => expect(spokenAlert(composer)).toBeVisible() }, + ) + await expect(spokenAlert(composer)).toHaveText( + "Nobody goes by that name.", + ) + await expectNoClippedText(composer) + await expectNoScrolling(composer) + }) + + test("with someone to play again, the chip is picked, ringed and sent, and a refusal leaves the sheet where it was", async ({ + browser, + }) => { + const { host, hostPage, guestName } = + await hostWithOneOpponent(browser) + await hostPage.setViewportSize(viewport) + const composer = await openComposer(hostPage) + + const chip = composer.getByRole("radio", { name: guestName }) + await expect(composer.getByText("Play again with")).toBeVisible() + await expect(chip).toBeVisible() + await expect( + composer.getByLabel("Someone else", { exact: true }), + ).toBeVisible() + await expect( + composer.getByRole("button", { name: "Send invite" }), + ).toBeDisabled() + await expectNoClippedText(composer) + await expectNoScrolling(composer) + + // Picked: the send names them, and the field stays empty. + await chip.check() + await expect(chip).toBeChecked() + await expect( + composer.getByLabel("Someone else", { exact: true }), + ).toHaveValue("") + const send = composer.getByRole("button", { + name: `Invite ${guestName}`, + }) + await expect(send).toBeEnabled() + await send.click() + await expect(composer).toBeHidden() + + // 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 expectNoLayoutShift(composer, () => send.click(), { + until: () => expect(spokenAlert(composer)).toBeVisible(), + }) + await expect(spokenAlert(composer)).toHaveText( + "You already have an invite out to them.", + ) + await expectNoClippedText(composer) + await expectNoScrolling(composer) + + // The next edit clears it. + await composer.getByLabel("Someone else", { exact: true }).fill("x") + await expect(spokenAlert(composer)).toHaveCount(0) + await host.close() + }) + }) +} + +test.describe(`on a ${SMALLEST_PHONE.width}x${SMALLEST_PHONE.height} phone`, () => { + test.use({ viewport: SMALLEST_PHONE }) + + test("the send stays in view while the rest scrolls, with no text cut off", async ({ + page, + }) => { + await signUp(page, uniqueUsername("small")) + const composer = await openComposer(page) + + await expectNoClippedText(composer) + const send = composer.getByRole("button", { name: "Send invite" }) + const box = await send.boundingBox() + expect(box).not.toBeNull() + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual( + SMALLEST_PHONE.height, + ) + expect(box?.y ?? 0).toBeGreaterThanOrEqual(0) + }) + + for (const [language, verb] of [ + ["pt", "Convidar"], + ["de", "einladen"], + ]) { + test(`in ${language}, a long name wraps the send to at most two lines and cuts nothing off`, async ({ + page, + }) => { + await signUp(page, uniqueUsername("locale")) + await page.evaluate( + ([key, value]) => localStorage.setItem(key, value), + [LANGUAGE_STORAGE_KEY, language], + ) + const composer = await openComposer(page) + const longName = "abcdefghijklmnopqrst" + await composer.getByRole("textbox").fill(longName) + + const send = composer.getByRole("button", { + name: new RegExp(`${verb} ${longName}|${longName} ${verb}`), + }) + await expect(send).toContainText(longName) + const lines = await send.evaluate((button) => { + const { lineHeight } = getComputedStyle(button) + return Math.round( + button.scrollHeight / Number.parseFloat(lineHeight), + ) + }) + expect(lines).toBeLessThanOrEqual(2) + // pinned: the whole button is on the screen, however far the body scrolls + const box = await send.boundingBox() + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual( + SMALLEST_PHONE.height, + ) + // typing moved the body under the keyboard; what is cut off is read from + // the top of it, once the field is let go and the sheet is back at rest + const field = composer.getByRole("textbox") + await field.blur() + await waitForRest(composer) + await expect + .poll(async () => { + await field.evaluate((input) => { + for (let up = input.parentElement; up; up = up.parentElement) { + if (up.scrollHeight > up.clientHeight) up.scrollTop = 0 + } + }) + return (await field.boundingBox())?.y ?? -1 + }) + .toBeGreaterThanOrEqual(0) + await expectNoClippedText(composer) + }) + } +}) + +test.describe("on a 375x667 phone", () => { + // a service worker would answer for the page and skip the mocked refusal + test.use({ + viewport: { width: 375, height: 667 }, + serviceWorkers: "block", + }) + + test("the link card makes the link without moving anything, and the failed one says so and retries on a tap", async ({ + page, + }) => { + await copyToRecordedClipboard(page) + await signUp(page, uniqueUsername("card")) + const composer = await openComposer(page) + + // The first request is refused, the second goes through. + let refused = false + await page.route("**/api/v1/invite-links", async (route) => { + if (route.request().method() !== "POST" || refused) + return route.continue() + refused = true + await route.fulfill({ + status: 500, + contentType: "application/json", + headers: { + "access-control-allow-origin": + route.request().headers().origin ?? "*", + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "error", + error_code: "api_internal_server_error", + }), + }) + }) + + const card = composer.getByRole("button", { + name: /Invite with a link/, + }) + await expectNoLayoutShift(composer, () => card.click(), { + until: () => + expect( + composer.getByText("Couldn't make it. Tap to retry"), + ).toBeVisible(), + }) + await expectNoClippedText(composer) + + await expectNoLayoutShift(composer, () => card.click(), { + until: () => + expect(composer.getByLabel("Invite link")).toHaveValue( + INVITE_LINK, + ), + }) + await expect(composer.getByText(/^Copied, lasts 7 days/)).toBeVisible() + await expectNoClippedText(composer) + await expectNoScrolling(composer) + }) + + test("Change opens the game panel, Done returns with the summary updated, and a link made before is dropped", async ({ + page, + }) => { + await copyToRecordedClipboard(page) + await signUp(page, uniqueUsername("panel")) + const composer = await openComposer(page) + + await expect(composer.getByText("You play either side")).toBeVisible() + await composer + .getByRole("button", { name: /Invite with a link/ }) + .click() + await expect(composer.getByLabel("Invite link")).toHaveValue( + INVITE_LINK, + ) + + await composer.getByRole("button", { name: "Change" }).click() + await expect( + composer.getByRole("button", { name: "Next board setup" }), + ).toBeVisible() + await expect( + composer.getByRole("radio", { name: "Play as black" }), + ).toBeVisible() + await expect( + composer.getByRole("button", { name: "Done" }), + ).toBeVisible() + await expectNoClippedText(composer) + + await composer.getByRole("radio", { name: "Play as black" }).click() + await composer.getByRole("button", { name: "Done" }).click() + + await expect(composer.getByText("You play black")).toBeVisible() + await expect( + composer.getByRole("button", { name: /Invite with a link/ }), + ).toBeVisible() + await expect(composer.getByLabel("Invite link")).toBeHidden() + }) + + test("Back returns from the game panel and leaves the terms alone", async ({ + page, + }) => { + await signUp(page, uniqueUsername("back")) + const composer = await openComposer(page) + + await composer.getByRole("button", { name: "Change" }).click() + await composer.getByRole("button", { name: "Back" }).click() + await expect(composer.getByText("You play either side")).toBeVisible() + await expect( + composer.getByRole("button", { name: "Send invite" }), + ).toBeVisible() + }) + + test("typing a chip's name, in any case, checks that chip, and picking a chip clears the field", async ({ + browser, + }) => { + const { host, hostPage, guestName } = + await hostWithOneOpponent(browser) + 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 }) + + await field.fill(guestName.toUpperCase(), { timeout: 5000 }) + await expect(chip).toBeChecked({ timeout: 5000 }) + + await field.fill("somebody") + await expect(chip).not.toBeChecked() + + await field.fill("somebody") + await chip.check({ timeout: 5000 }) + await expect(field).toHaveValue("") + await expect( + composer.getByRole("button", { name: `Invite ${guestName}` }), + ).toBeVisible() + + // One radio group: the arrow keys are the chips' own. + await chip.focus() + await expect(chip).toBeFocused() + await host.close() + }) + + test("while the games load the row holds four empty chips, and the chip lands in its place", async ({ + browser, + }) => { + const { host, hostPage } = await hostWithOneOpponent(browser) + await hostPage.setViewportSize({ width: 375, height: 667 }) + + // The saved copy of the games would fill the row at once: start without it. + await hostPage.addInitScript(() => { + indexedDB.deleteDatabase("keyval-store") + }) + + let release: () => void = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + await hostPage.route(/\/api\/v1\/games\?/, async (route) => { + await released + await route.continue() + }) + const composer = await openComposer(hostPage) + + await expect(composer.getByRole("radiogroup")).toHaveAttribute( + "aria-busy", + "true", + ) + await expectNoLayoutShift(composer, async () => release(), { + until: () => expect(composer.getByRole("radio")).toHaveCount(1), + }) + await host.close() + }) +}) diff --git a/e2e/invite-copy-link.spec.ts b/e2e/invite-copy-link.spec.ts index beb2cf4d..0d39be61 100644 --- a/e2e/invite-copy-link.spec.ts +++ b/e2e/invite-copy-link.spec.ts @@ -82,13 +82,18 @@ test.describe("on a 375x667 phone", () => { const copyBox = await copy.boundingBox() const fieldBox = await field.boundingBox() expect(copyBox?.width).toBeLessThanOrEqual(56) - expect(fieldBox?.width).toBeGreaterThan(240) + expect(fieldBox?.width).toBeGreaterThan(190) + + // With no share sheet, making the link copies it, and says so. + const link = await field.inputValue() + await expect(composer.getByText(/^Copied/)).toBeVisible() + expect((await recorded(page)).copied).toEqual([link]) + // The icon copies it again. await copy.click() - await expect(composer.getByText(/^Link copied/)).toBeVisible() - expect((await recorded(page)).copied).toEqual([ - await field.inputValue(), - ]) + await expect + .poll(async () => (await recorded(page)).copied) + .toEqual([link, link]) await expectNoScrolling(composer) }) @@ -104,16 +109,22 @@ test.describe("on a 375x667 phone", () => { await expect(copy).toBeVisible() await expect(share).toBeVisible() const fieldBox = await field.boundingBox() - expect(fieldBox?.width).toBeGreaterThan(190) + expect(fieldBox?.width).toBeGreaterThan(140) + // Making the link hands it to the share sheet, and says it is ready. const link = await field.inputValue() - await share.click() await expect .poll(async () => (await recorded(page)).shared) .toEqual([link]) + await expect(composer.getByText(/^Link ready/)).toBeVisible() + + await share.click() + await expect + .poll(async () => (await recorded(page)).shared) + .toEqual([link, link]) await copy.click() - await expect(composer.getByText(/^Link copied/)).toBeVisible() + await expect(composer.getByText(/^Copied/)).toBeVisible() expect((await recorded(page)).copied).toEqual([link]) await expectNoScrolling(composer) }) diff --git a/e2e/invite-link.spec.ts b/e2e/invite-link.spec.ts index af9871c1..3830c724 100644 --- a/e2e/invite-link.spec.ts +++ b/e2e/invite-link.spec.ts @@ -1,6 +1,12 @@ import type { Page } from "@playwright/test" import { expect, test } from "@playwright/test" -import { openApp, PASSWORD, signUp, uniqueUsername } from "./support/app" +import { + chooseInviteSide, + openApp, + PASSWORD, + signUp, + uniqueUsername, +} from "./support/app" import { expectMarble, playMove } from "./support/board" import { expireInviteLink } from "./support/database" @@ -17,7 +23,7 @@ async function createInviteLink(page: Page): Promise { // link on the default terms instead (TUD-850, e2e/online-empty.spec.ts). await openApp(page, "/online?invite=new") const composer = page.getByRole("dialog") - await composer.getByRole("radio", { name: "Play as black" }).click() + await chooseInviteSide(composer, "black") await composer .getByRole("button", { name: "Invite with a link" }) .click() diff --git a/e2e/modal-layout.spec.ts b/e2e/modal-layout.spec.ts index 256fec32..3c6edcf9 100644 --- a/e2e/modal-layout.spec.ts +++ b/e2e/modal-layout.spec.ts @@ -80,17 +80,20 @@ for (const viewport of VIEWPORTS) { await openApp(page, "/online?invite=new") const composer = await openDialog(page) + // No rhythm check on the composer: its who and terms groups sit 12px + // apart inside, a third size next to the 8 and the 20 (TUD-3855). await expectNoClippedText(composer) - await expectEvenRhythm(composer, RHYTHM) + await expectNoScrolling(composer) // A name nobody has: the answer lands in space that was already there. - await composer - .getByLabel("Opponent", { exact: true }) - .fill(uniqueUsername("nobody")) + const nobody = uniqueUsername("nobody") + await composer.getByLabel("Opponent", { exact: true }).fill(nobody) await expectNoLayoutShift( composer, () => - composer.getByRole("button", { name: "Send invite" }).click(), + composer + .getByRole("button", { name: `Invite ${nobody}` }) + .click(), { until: () => expect(spokenAlert(composer)).toBeVisible() }, ) await expectNoClippedText(composer) @@ -119,12 +122,10 @@ for (const viewport of VIEWPORTS) { composer, () => composer.getByRole("button", { name: "Copy link" }).click(), { - until: () => - expect(composer.getByText(/^Link copied/)).toBeVisible(), + until: () => expect(composer.getByText(/^Copied/)).toBeVisible(), }, ) await expectNoClippedText(composer) - await expectEvenRhythm(composer, RHYTHM) }) test("the empty online page makes and copies a link without moving anything", async ({ @@ -260,11 +261,10 @@ test.describe("in German on a 375x667 phone", () => { const composer = await openDialog(page) await expectNoClippedText(composer) + const nobody = uniqueUsername("nobody") + await composer.getByLabel("Gegner", { exact: true }).fill(nobody) await composer - .getByLabel("Gegner", { exact: true }) - .fill(uniqueUsername("nobody")) - await composer - .getByRole("button", { name: "Einladung senden" }) + .getByRole("button", { name: `${nobody} einladen` }) .click() await expect(spokenAlert(composer)).toHaveText( "Unter diesem Namen spielt niemand.", diff --git a/e2e/online-empty.spec.ts b/e2e/online-empty.spec.ts index 09a82b72..e65aa30c 100644 --- a/e2e/online-empty.spec.ts +++ b/e2e/online-empty.spec.ts @@ -243,7 +243,9 @@ test("with an invite out, the online page is the usual list", async ({ await emptyActions(hostPage).byName.click() const composer = hostPage.getByRole("dialog") await composer.getByLabel("Opponent", { exact: true }).fill(friendName) - await composer.getByRole("button", { name: "Send invite" }).click() + await composer + .getByRole("button", { name: `Invite ${friendName}` }) + .click() await expect(composer).toBeHidden() // Sent or received, an open invite makes it the usual hub, with its own diff --git a/e2e/online-hub.spec.ts b/e2e/online-hub.spec.ts index 76aca1c1..1ec2280c 100644 --- a/e2e/online-hub.spec.ts +++ b/e2e/online-hub.spec.ts @@ -1,6 +1,11 @@ import type { Browser, Locator, Page } from "@playwright/test" import { expect, test } from "@playwright/test" -import { openApp, signUp, uniqueUsername } from "./support/app" +import { + chooseInviteSide, + openApp, + signUp, + uniqueUsername, +} from "./support/app" import { playMove } from "./support/board" // The online hub shows every game as the same board card: whose move it is, @@ -30,8 +35,10 @@ async function openGame(browser: Browser) { .click() const composer = hostPage.getByRole("dialog") await composer.getByLabel("Opponent", { exact: true }).fill(guestName) - await composer.getByRole("radio", { name: "Play as black" }).click() - await composer.getByRole("button", { name: "Send invite" }).click() + await chooseInviteSide(composer, "black") + await composer + .getByRole("button", { name: `Invite ${guestName}` }) + .click() await expect(composer).toBeHidden() await openApp(guestPage, "/online") diff --git a/e2e/status-text-layout.spec.ts b/e2e/status-text-layout.spec.ts index 381d469a..eac127b0 100644 --- a/e2e/status-text-layout.spec.ts +++ b/e2e/status-text-layout.spec.ts @@ -118,7 +118,9 @@ for (const viewport of VIEWPORTS) { await composer .getByLabel("Opponent", { exact: true }) .fill(friendName) - await composer.getByRole("button", { name: "Send invite" }).click() + await composer + .getByRole("button", { name: `Invite ${friendName}` }) + .click() await expect(composer).toBeHidden() const main = page.locator("main") diff --git a/e2e/support/app.ts b/e2e/support/app.ts index bf87d94a..cc2ceaa6 100644 --- a/e2e/support/app.ts +++ b/e2e/support/app.ts @@ -162,8 +162,10 @@ export async function startOnlineGame( .click() const composer = hostPage.getByRole("dialog") await composer.getByLabel("Opponent", { exact: true }).fill(guestName) - await composer.getByRole("radio", { name: "Play as black" }).click() - await composer.getByRole("button", { name: "Send invite" }).click() + await chooseInviteSide(composer, "black") + await composer + .getByRole("button", { name: `Invite ${guestName}` }) + .click() await expect(composer).toBeHidden() // The guest accepts, which opens the game. @@ -185,3 +187,16 @@ export async function startOnlineGame( return { host, guest, hostPage, guestPage, guestName } } + +/** + * Sets the side an invite is sent for. The side lives on the composer's game + * panel, one press of "Change" away from the main view, and "Done" returns. + */ +export async function chooseInviteSide( + composer: Locator, + side: "black" | "white" | "random", +): Promise { + await composer.getByRole("button", { name: "Change" }).click() + await composer.getByRole("radio", { name: `Play as ${side}` }).click() + await composer.getByRole("button", { name: "Done" }).click() +}