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 (
+
+ )
+}
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. */}
+
+
+
- {/* The name is the only thing here that can be wrong, so the line
- belongs to it rather than to the sheet.
-
- Always in the layout, with a hint in it until the server has
- something to say about the name. A message that arrives into no
- space of its own pushes the board preview down and grows the
- sheet: a press on Send that moves the thing under the thumb. */}
- t(key))}
+
+ handleType(event.target.value)}
+ autoComplete="off"
+ autoCapitalize="none"
+ autoCorrect="off"
+ spellCheck={false}
+ maxLength={20}
+ disabled={pending}
+ invalid={Boolean(shownError) && !pickedName}
+ describedBy={errorId}
+ />
+
+ {/* The name is the only thing here that can be wrong, so the line
+ belongs to it rather than to the sheet.
+
+ Always in the layout, with a hint in it until the server has
+ something to say about the name. A message that arrives into no
+ space of its own pushes the rest down and grows the sheet: a
+ press on Send that moves the thing under the thumb. */}
+ t(key))}
+ />
+
+
+
+
+ {/* Keyed on the terms, so changing either one drops a link made on
+ the old ones rather than leaving it in the field. */}
+
+
+ setView("game")}
/>
+
+
+
+
+
+
+ {t("online:compose.game")}
+
+
{t("online:compose.setup")}
@@ -167,15 +284,6 @@ export function InviteComposer({
marbleDesign={marbleDesign}
/>
-
- {/* Keyed on the terms, so changing either one drops a link made on
- the old ones rather than leaving it in the field. */}
-
)
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"
+ />
+
+
+
+ )
+}
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 (
+