diff --git a/PLAN.md b/PLAN.md index 75d4766..db14e9a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -41,7 +41,7 @@ independent (see ARCHITECTURE §9.1). | 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | -| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | +| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | | 9 | Telegram integration (bot side-service, deep-link, push) | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 11 | Account linking & merge | todo | @@ -538,6 +538,73 @@ Open details: deployment target/host; dashboards; load expectations. (not a modal); word-check is alphabet/length-limited, cached and throttled. Design details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md). +- **Stage 8** (interview + implementation): + - **Scope = vertical slice continued**: the social/account/history operations were + opened end-to-end (UI → gateway transcode → backend REST → existing domain + services). The only new backend logic is `lobby.ListInvitations`, + `account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`), + the friend-code mechanism, and the friendships `declined`-status change. + - **Friends — two add paths** (interview, a deliberate plan change): **one-time + friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL, + SHA-256-hashed like email codes, single active per issuer, single-use, redeem + rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a + shared game — active or finished). An explicit **decline is permanent** (blocks + re-send), an **ignored request lazily expires after 30 days** and may be re-sent, + and a **code from the same person bypasses a prior decline**. This **supersedes + Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still + deletes; decline now sets `status='declined'`). Migration **00006** widens + `friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID + or name search — discovery is codes + befriend-an-opponent. + - **Badges = poll + push** (interview): a new generic **`notify`** push event + (`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/ + game_started) drives the lobby hamburger + "Friends" badge; emitted on friend- + request and invitation create and on the invitation's game start. The client polls + incoming requests + open invitations on lobby open and on focus (a missed push + while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred + (single-instance MVP, §10). + - **Language single-control** (interview): the Settings language control writes + through to the durable account's `preferred_language` (`profile.update`); guests + keep only the client preference. Seeding the language from the platform/client on + first provider login is a **Stage 9** forward-note. + - **Guests = durable-only** (interview): friends/blocks/invitations/statistics and + history management are durable-account-only; a guest sees a sign-in prompt. + Binding an email to an existing guest (account linking) stays **Stage 11**. + - **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active + game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client + exports via the **Web Share API** where available, else a **Blob download** + (`game-.gcg`). Capacitor-native file save lands with the native wrapper. + - **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen + from the lobby menu; Invitations is a lobby section + a "play with friends" mode in + New game; Stats is a lobby tab-bar button; profile editing is on Profile; history + + GCG stay in the game. + - **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email- + bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in + `pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new + REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}` + and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow + change (the Go and UI workflows already cover the new code). + - **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the + lobby notification badge fixed (it had inherited the hamburger-bar style) and made + a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit + validation on both UI and backend** — display-name format (letters + single + `␠`/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone` + parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid + capped at **12 h** (wrap-aware), email format — with Save disabled and invalid + fields red-bordered while any field is invalid; language stays in Settings; in a + game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge + became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check + word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab + bar) instead of hiding it. Two **iPhone-simulator** passes then made the chat and + modals keyboard-aware (`dvh` plus a `visualViewport` listener that sizes the modal + backdrop to the area above the keyboard), reserved the rack height so a finished + footer does not collapse, and compacted the play-with-friends form (a searchable + bounded-scroll friend list, a pinned invite, and an explicit, **required game + type** — a smart default is TODO-6). On the owner's call, **every profile / new-game + picker is a native ` + + + {#if code} +
+
+ + +
+ + {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} + +
+ {:else} + + {/if} + + + {#if incoming.length} +
+

{t('friends.incoming')}

+ {#each incoming as r (r.accountId)} +
+ {r.displayName} + + + + +
+ {/each} +
+ {/if} + +
+

{t('friends.yours')}

+ {#if friends.length} + {#each friends as f (f.accountId)} +
+ {f.displayName} + + + + +
+ {/each} + {:else} +

{t('friends.none')}

+ {/if} +
+ + {#if blocked.length} +
+

{t('friends.blockedList')}

+ {#each blocked as b (b.accountId)} +
+ {b.displayName} + +
+ {/each} +
+ {/if} + {/if} + + + + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 22175a8..ad0513a 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -6,15 +6,23 @@ import { app, handleError, showToast } from '../lib/app.svelte'; import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; - import { t } from '../lib/i18n/index.svelte'; + import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; - import type { GameView } from '../lib/model'; + import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); + let invitations = $state([]); + let incoming = $state([]); + + const guest = $derived(app.profile?.isGuest ?? true); async function load() { try { games = (await gateway.gamesList()).games; + if (!guest) { + [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); + app.notifications = invitations.length + incoming.length; + } } catch (e) { handleError(e); } @@ -42,18 +50,73 @@ } const menuItems = $derived([ + ...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]), { label: t('lobby.profile'), onclick: () => navigate('/profile') }, { label: t('lobby.settings'), onclick: () => navigate('/settings') }, { label: t('lobby.about'), onclick: () => navigate('/about') }, ]); + + async function acceptInvite(inv: Invitation) { + try { + const r = await gateway.invitationAccept(inv.id); + if (r.gameId) navigate(`/game/${r.gameId}`); + else await load(); + } catch (e) { + handleError(e); + } + } + const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id)); + const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id)); + async function act(fn: () => Promise) { + try { + await fn(); + await load(); + } catch (e) { + handleError(e); + } + } + + const variantKey: Record = { + english: 'new.english', + russian: 'new.russian', + erudit: 'new.erudit', + }; {#snippet menu()} - + {/snippet}
+ {#if invitations.length} +
+

{t('lobby.invitations')}

+ {#each invitations as inv (inv.id)} +
+ 💌 + + {#if inv.inviter.accountId === myId} + {t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })} + {t('invitations.waiting')} + {:else} + {t('invitations.from', { name: inv.inviter.displayName })} + {t(variantKey[inv.variant] ?? 'new.english')} + {/if} + + + {#if inv.inviter.accountId === myId} + + {:else} + + + {/if} + +
+ {/each} +
+ {/if} + {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)} {#if group.list.length}
@@ -72,7 +135,7 @@ {/if} {/each} - {#if !active.length && !finished.length} + {#if !active.length && !finished.length && !invitations.length}

{t('lobby.noActive')}

{/if}
@@ -82,11 +145,11 @@ - {/snippet} @@ -111,7 +174,8 @@ font-size: 0.9rem; margin: 0; } - .row { + .row, + .invite { display: flex; align-items: center; justify-content: space-between; @@ -147,4 +211,23 @@ line-height: 1; flex: 0 0 auto; } + .acts { + display: flex; + gap: 8px; + flex: 0 0 auto; + } + .btn { + padding: 8px 12px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-text); + border-radius: var(--radius-sm); + } + .ghost { + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + } diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 40d9c04..caaca8e 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,18 +1,28 @@ @@ -60,12 +116,61 @@ {:else} -

{t('new.subtitle')}

-
- {#each variants as v (v.id)} - - {/each} -
+ {#if !guest} +
+ + +
+ {/if} + + {#if mode === 'auto'} +

{t('new.subtitle')}

+
+ {#each variants as v (v.id)} + + {/each} +
+ {:else if friends.length === 0} +

{t('new.noFriends')}

+ {:else} +
+
+ {t('new.pickFriends')} ({selected.length}) + +
+
+ {#each filteredFriends as f (f.accountId)} + + {/each} + {#if filteredFriends.length === 0}

{/if} +
+
+ + + +
+ +
+ {/if} {/if} @@ -73,15 +178,20 @@