feat: OTP rate limit, private-room invite UX, in-game UI fixes
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 54s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s

Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
  5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
  POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
  auth.rateLimited. Knobs in appsettings Sms (Sms__* env).

Private rooms (invite)
- Cancel-invite button on pending seats; friend picker shows presence
  (online/offline/in-game, sorted online-first) and flags in-game players.
- Mock invite stays pending ~3.5s and a cancel truly stops the auto-accept
  (was a bug that re-seated cancelled invites).

In-game UI
- Scoreboard is compact + shrink-safe (no overflow on narrow screens).
- Played trick cards land dead-center (were ~2px off the corner anchor).

Plus the in-flight typing-indicator work (GameHub, ChatScreen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-14 00:30:20 +03:30
parent 78878efc22
commit bc695bc8e9
12 changed files with 257 additions and 58 deletions
+15 -15
View File
@@ -118,12 +118,12 @@ export function GameTable({
{/* Top HUD */}
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<SpeedBadge />
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleAll}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("settings.audio")}
>
{muted ? (
@@ -135,7 +135,7 @@ export function GameTable({
{onForfeit && (
<button
onClick={() => setAskFf(true)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("forfeit.title")}
>
<Flag className="size-4 text-rose-300/90" />
@@ -143,7 +143,7 @@ export function GameTable({
)}
<button
onClick={exit}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
@@ -234,23 +234,23 @@ function Scoreboard() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
return (
<div className="glass rounded-2xl px-2.5 py-1.5 gap-2.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center">
<div className="glass rounded-2xl px-2 py-1 gap-1.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center shrink min-w-0">
<ScoreCol
label={t("team.us")}
tricks={game.roundTricks[0]}
score={game.matchScore[0]}
accent="text-teal-400"
/>
<div className="text-cream/30 text-lg font-light">/</div>
<div className="text-cream/30 text-base sm:text-lg font-light shrink-0">/</div>
<ScoreCol
label={t("team.them")}
tricks={game.roundTricks[1]}
score={game.matchScore[1]}
accent="text-rose-400"
/>
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div>
<div className="ltr:ml-1.5 rtl:mr-1.5 sm:ltr:ml-2 sm:rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-2 rtl:pr-2 sm:ltr:pl-3 sm:rtl:pr-3 shrink-0">
<div className="text-[9px] sm:text-[10px] text-cream/50 leading-none">{t("home.target")}</div>
<div className="gold-text font-bold text-center text-sm sm:text-base leading-tight">{game.targetScore}</div>
</div>
</div>
);
@@ -269,10 +269,10 @@ function ScoreCol({
}) {
const { t } = useI18n();
return (
<div className="text-center min-w-10 sm:min-w-14">
<div className={cn("text-[11px] sm:text-xs font-semibold", accent)}>{label}</div>
<div className="text-lg sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
<div className="text-center min-w-8 sm:min-w-14 shrink-0">
<div className={cn("text-[10px] sm:text-xs font-semibold truncate", accent)}>{label}</div>
<div className="text-base sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[9px] sm:text-[10px] text-cream/45 mt-0.5 truncate">
{t("score.tricks")}: {tricks}
</div>
</div>
@@ -439,8 +439,8 @@ function TrickArea({
}) {
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1">
<div className="absolute inset-0">
<div className="absolute left-1/2 top-1/2 size-0">
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
+11 -2
View File
@@ -78,8 +78,17 @@ function PhoneForm({ onDone }: { onDone: () => void }) {
const res = await requestOtp(phone.trim());
setDevCode(res.devCode ?? null);
setSent(true); // advance to the code step regardless of dev/prod
} catch {
setError(t("auth.sendFailed"));
} catch (e) {
// The service throws the response body text; surface a friendly rate-limit message.
let msg = t("auth.sendFailed");
try {
const body = JSON.parse((e as Error).message);
if (body?.error === "RATE_LIMITED")
msg = t("auth.rateLimited", { s: body.retryAfter ?? 60 });
} catch {
/* not JSON — keep the generic message */
}
setError(msg);
} finally {
setBusy(false);
}
+51 -3
View File
@@ -26,9 +26,12 @@ export function ChatScreen() {
const [text, setText] = useState("");
const [showEmoji, setShowEmoji] = useState(false);
const [reported, setReported] = useState(false);
const [peerTyping, setPeerTyping] = useState(false);
const emojis = profile ? ownedReactions(profile) : [];
const endRef = useRef<HTMLDivElement>(null);
const prevLen = useRef(0);
const lastTypingSent = useRef(0);
const typingClear = useRef<ReturnType<typeof setTimeout> | null>(null);
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
useEffect(() => {
@@ -43,6 +46,28 @@ export function ChatScreen() {
// Clean up the chat subscription whenever we leave (header back OR hardware back).
useEffect(() => () => closeChat(), [closeChat]);
// Show a "typing…" indicator when the peer is typing (auto-clears after a pause).
useEffect(() => {
const id = friend?.id;
if (!id) return;
const unsub = getService().onTyping((fromId) => {
if (fromId !== id) return;
setPeerTyping(true);
if (typingClear.current) clearTimeout(typingClear.current);
typingClear.current = setTimeout(() => setPeerTyping(false), 3500);
});
return () => {
unsub();
if (typingClear.current) clearTimeout(typingClear.current);
setPeerTyping(false);
};
}, [friend?.id]);
// Keep the typing bubble in view.
useEffect(() => {
if (peerTyping) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [peerTyping]);
if (!friend) {
return null;
}
@@ -56,6 +81,16 @@ export function ChatScreen() {
await sendChat(v);
};
// Throttle outgoing typing pings so we don't spam the hub on every keystroke.
const onType = (v: string) => {
setText(v);
const now = Date.now();
if (v.trim() && now - lastTypingSent.current > 1800) {
lastTypingSent.current = now;
getService().sendTyping(friend.id);
}
};
const sendEmoji = async (e: string) => {
setShowEmoji(false);
await sendChat(e);
@@ -94,8 +129,10 @@ export function ChatScreen() {
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
<div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className="text-[11px] text-teal-300">
{friend.status === "online"
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
{peerTyping
? t("chat.typing")
: friend.status === "online"
? t("friends.online")
: friend.status === "in-game"
? t("friends.inGame")
@@ -142,6 +179,17 @@ export function ChatScreen() {
{m.text}
</div>
))}
{peerTyping && (
<div className="me-auto glass rounded-2xl rounded-es-sm px-4 py-3 flex items-center gap-1.5" aria-label={t("chat.typing")}>
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 rounded-full bg-cream/60 animate-bounce"
style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
/>
))}
</div>
)}
<div ref={endRef} />
</div>
@@ -172,7 +220,7 @@ export function ChatScreen() {
<input
value={text}
onFocus={() => setShowEmoji(false)}
onChange={(e) => setText(e.target.value)}
onChange={(e) => onType(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder={t("chat.placeholder")}
className="flex-1 min-w-0 rounded-full bg-navy-900/70 gold-border px-4 py-2.5 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
+53 -16
View File
@@ -8,9 +8,17 @@ import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
online: "bg-teal-400",
offline: "bg-slate-500",
"in-game": "bg-gold-400",
};
// online first, then in-game, then offline
const statusRank = (s: PresenceStatus) => (s === "online" ? 0 : s === "in-game" ? 1 : 2);
export function RoomScreen() {
const { t } = useI18n();
const room = useOnlineStore((s) => s.room);
@@ -35,6 +43,8 @@ export function RoomScreen() {
if (!room) return null;
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
const statusLabel = (s: PresenceStatus) =>
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
const pick = async (friend: Friend) => {
if (!picker) return;
@@ -152,21 +162,39 @@ export function RoomScreen() {
onClick={(e) => e.stopPropagation()}
className="panel rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
>
<h3 className="text-lg font-black gold-text mb-3">{t("room.pickFriend")}</h3>
<h3 className="text-lg font-black gold-text mb-1">{t("room.pickFriend")}</h3>
<p className="text-[11px] text-cream/45 mb-3">{t("room.inviteHint")}</p>
<div className="space-y-2">
{friends.map((f) => (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
<span className="text-[11px] text-cream/45">
{t("common.level")} {f.level}
</span>
</button>
))}
{friends.length === 0 && (
<p className="text-center text-cream/40 text-sm py-8">{t("friends.empty")}</p>
)}
{[...friends]
.sort((a, b) => statusRank(a.status) - statusRank(b.status))
.map((f) => {
const inGame = f.status === "in-game";
return (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-semibold text-cream truncate">{f.displayName}</span>
<span className={cn("block text-[10px]", inGame ? "text-gold-300" : "text-cream/45")}>
{inGame ? `🎮 ${t("friends.inGame")}` : statusLabel(f.status)} · {t("common.level")} {f.level}
</span>
</span>
<span className="text-[10px] font-bold text-teal-300 shrink-0 inline-flex items-center gap-1">
<UserPlus className="size-3.5" />
{t("room.invite")}
</span>
</button>
);
})}
</div>
</motion.div>
</motion.div>
@@ -210,7 +238,16 @@ function SeatCard({
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span>
{seat.kind === "invited" ? (
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
<div className="flex flex-col items-center gap-1">
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
<button
onClick={onClear}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold text-rose-300/80 hover:text-rose-200 hover:bg-rose-500/10 transition"
>
<X className="size-3" />
{t("room.cancelInvite")}
</button>
</div>
) : (
role !== "you" && (
<button
+10 -2
View File
@@ -152,6 +152,7 @@ const fa: Dict = {
"chat.placeholder": "پیام بنویسید…",
"chat.send": "ارسال",
"chat.emoji": "ایموجی",
"chat.typing": "در حال نوشتن…",
"city.rewardTitle": "شهرت را انتخاب کن!",
"city.rewardSub": "{n} سکه هدیه بگیر",
"city.search": "جستجوی شهر…",
@@ -234,11 +235,13 @@ const fa: Dict = {
"room.invite": "دعوت دوست",
"room.addBot": "ربات",
"room.empty": "خالی",
"room.waiting": "در انتظار…",
"room.waiting": "در انتظار پذیرش…",
"room.cancelInvite": "لغو دعوت",
"room.start": "شروع بازی",
"room.stake": "سکه ورودی",
"room.leave": "ترک اتاق",
"room.pickFriend": "یک دوست را انتخاب کنید",
"room.inviteHint": "صندلی تا پذیرش دعوت در حالت انتظار می‌ماند",
"mm.title": "جستجوی بازیکن",
"mm.searching": "در حال یافتن حریف…",
@@ -285,6 +288,7 @@ const fa: Dict = {
"auth.invalidPhone": "شماره موبایل را درست وارد کنید",
"auth.sending": "در حال ارسال…",
"auth.sendFailed": "ارسال پیامک ناموفق بود، دوباره تلاش کنید",
"auth.rateLimited": "درخواست‌های زیاد. لطفاً {s} ثانیه دیگر تلاش کنید",
"auth.codeSent": "کد به شماره شما پیامک شد",
"auth.changeNumber": "تغییر شماره",
"auth.otherSoon": "سایر روش‌های ورود به‌زودی فعال می‌شوند",
@@ -524,6 +528,7 @@ const en: Dict = {
"chat.placeholder": "Type a message…",
"chat.send": "Send",
"chat.emoji": "Emoji",
"chat.typing": "typing…",
"city.rewardTitle": "Set your city!",
"city.rewardSub": "Earn {n} coins",
"city.search": "Search city…",
@@ -606,11 +611,13 @@ const en: Dict = {
"room.invite": "Invite friend",
"room.addBot": "Bot",
"room.empty": "Empty",
"room.waiting": "Waiting…",
"room.waiting": "Waiting to accept…",
"room.cancelInvite": "Cancel invite",
"room.start": "Start game",
"room.stake": "Entry coins",
"room.leave": "Leave room",
"room.pickFriend": "Pick a friend",
"room.inviteHint": "The seat stays pending until they accept the invite",
"mm.title": "Finding players",
"mm.searching": "Searching for opponents…",
@@ -654,6 +661,7 @@ const en: Dict = {
"auth.invalidPhone": "Enter a valid mobile number",
"auth.sending": "Sending…",
"auth.sendFailed": "Couldn't send the SMS, try again",
"auth.rateLimited": "Too many requests. Please try again in {s}s",
"auth.codeSent": "Code sent to your number",
"auth.changeNumber": "Change number",
"auth.otherSoon": "Other sign-in methods coming soon",
+19 -8
View File
@@ -678,6 +678,12 @@ export class MockOnlineService implements OnlineService {
return () => this.chatCbs.delete(cb);
}
// Offline mock has no real peers, so typing pings are no-ops.
sendTyping(): void {}
onTyping(): Unsubscribe {
return () => {};
}
/* ---------------------------- reactions ---------------------------- */
async sendReaction(reaction: string) {
@@ -780,6 +786,17 @@ export class MockOnlineService implements OnlineService {
};
}
/** The invited player "accepts" after a realistic delay — but only if the host
* hasn't cancelled the invite (seat still shows them as invited) in the meantime. */
private acceptInviteWhenPending(seat: 1 | 2 | 3, friendId: string) {
this.after(3500, () => {
const cur = this.room?.seats.find((x) => x.seat === seat);
if (cur?.kind !== "invited" || cur.player?.id !== friendId) return; // cancelled → don't seat them
this.setSeat(seat, this.friendSeat(seat, friendId, false));
this.emitRoom();
});
}
async setPartner(roomId: string, friendId: string | null) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
@@ -787,10 +804,7 @@ export class MockOnlineService implements OnlineService {
this.setSeat(2, { seat: 2, kind: "empty" });
} else {
this.setSeat(2, this.friendSeat(2, friendId, true));
this.after(1100, () => {
this.setSeat(2, this.friendSeat(2, friendId, false));
this.emitRoom();
});
this.acceptInviteWhenPending(2, friendId);
}
this.emitRoom();
return this.room;
@@ -800,10 +814,7 @@ export class MockOnlineService implements OnlineService {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, this.friendSeat(seat, friendId, true));
this.after(1100, () => {
this.setSeat(seat, this.friendSeat(seat, friendId, false));
this.emitRoom();
});
this.acceptInviteWhenPending(seat, friendId);
this.emitRoom();
return this.room;
}
+3
View File
@@ -88,6 +88,9 @@ export interface OnlineService {
sendMessage(friendId: string, text: string): Promise<ChatMessage>;
markRead(friendId: string): Promise<void>;
onChat(cb: (friendId: string, messages: ChatMessage[]) => void): Unsubscribe;
/** Tell a peer we're typing (ephemeral); subscribe to peers' typing pings. */
sendTyping(friendId: string): void;
onTyping(cb: (fromId: string) => void): Unsubscribe;
/* ----- reactions (in-game emotes) ----- */
sendReaction(reaction: string): Promise<void>;
+5
View File
@@ -59,6 +59,7 @@ export class SignalrService implements OnlineService {
private rewardCbs = new Set<(r: RewardResult) => void>();
private friendCbs = new Set<(f: Friend[]) => void>();
private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
private typingCbs = new Set<(fromId: string) => void>();
private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>();
private cachedProfile: UserProfile | null = null;
@@ -120,6 +121,8 @@ export class SignalrService implements OnlineService {
conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s)));
conn.on("reaction", (r: { seat: number; reaction: string }) =>
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
conn.on("typing", (m: { from: string }) =>
this.typingCbs.forEach((cb) => cb(m.from)));
conn.on("notification", (n: AppNotification) =>
this.notifCbs.forEach((cb) => cb(n)));
conn.on("profile", (p: UserProfile) =>
@@ -404,6 +407,8 @@ export class SignalrService implements OnlineService {
}
async markRead() { /* server marks read when messages are fetched */ }
onChat(cb: (id: string, m: ChatMessage[]) => void) { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); }
sendTyping(id: string) { this.conn?.invoke("Typing", id).catch(() => {}); }
onTyping(cb: (fromId: string) => void) { this.typingCbs.add(cb); return () => this.typingCbs.delete(cb); }
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
// Real notifications only — server hub "notification" events + app-generated