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
+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