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
+73 -7
View File
@@ -16,8 +16,19 @@ public sealed class SmsOptions
public bool DevMode { get; set; } = false; public bool DevMode { get; set; } = false;
public string DevCode { get; set; } = "1234"; public string DevCode { get; set; } = "1234";
public int TtlSeconds { get; set; } = 120; public int TtlSeconds { get; set; } = 120;
/* --- Rate limiting (applies to real SMS sends only; dev mode is unlimited) --- */
/// <summary>Minimum seconds between two OTP sends to the same phone (resend cooldown).</summary>
public int ResendCooldownSeconds { get; set; } = 60;
/// <summary>Max OTP sends to one phone per rolling hour. 0 disables.</summary>
public int MaxPerHour { get; set; } = 5;
/// <summary>Server-wide OTP-send backstop per rolling hour (SMS-bomb / cost cap). 0 disables.</summary>
public int MaxGlobalPerHour { get; set; } = 300;
} }
/// <summary>Result of an OTP request, including a rate-limit retry hint.</summary>
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary> /// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
public sealed class OtpService public sealed class OtpService
{ {
@@ -26,6 +37,11 @@ public sealed class OtpService
private readonly ILogger<OtpService> _log; private readonly ILogger<OtpService> _log;
private readonly ConcurrentDictionary<string, Entry> _codes = new(); private readonly ConcurrentDictionary<string, Entry> _codes = new();
// Rate-limit logs (singleton service → fields persist across requests).
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
private readonly object _globalLock = new();
private readonly List<DateTime> _globalLog = new();
private readonly record struct Entry(string Code, DateTime Expires, int Tries); private readonly record struct Entry(string Code, DateTime Expires, int Tries);
public OtpService(SmsOptions opts, ILogger<OtpService> log) public OtpService(SmsOptions opts, ILogger<OtpService> log)
@@ -38,28 +54,78 @@ public sealed class OtpService
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey); public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
/// <summary>Generate a code, store it, and send the SMS. Returns devCode only in dev mode.</summary> /// <summary>Generate a code, store it, and send the SMS. Returns devCode only in dev mode.</summary>
public async Task<(bool ok, string? devCode)> Request(string phone) public async Task<OtpResult> Request(string phone)
{ {
phone = Normalize(phone); phone = Normalize(phone);
if (string.IsNullOrWhiteSpace(phone)) return (false, null); if (string.IsNullOrWhiteSpace(phone)) return new OtpResult(false, null, "INVALID_PHONE", 0);
var code = IsDev ? _opts.DevCode : Random.Shared.Next(10000, 100000).ToString(); // Dev mode never sends an SMS (fixed code) → no cost, no rate limit.
if (IsDev)
{
_codes[phone] = new Entry(_opts.DevCode, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
return new OtpResult(true, _opts.DevCode, null, 0);
}
// Real SMS: enforce per-phone cooldown + hourly cap + a global backstop.
var limited = CheckAndRecordRate(phone);
if (limited is { } lim) return lim;
var code = Random.Shared.Next(10000, 100000).ToString();
_codes[phone] = new Entry(code, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0); _codes[phone] = new Entry(code, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
if (IsDev) return (true, _opts.DevCode);
try try
{ {
await SendKavenegar(phone, code); await SendKavenegar(phone, code);
return (true, null); return new OtpResult(true, null, null, 0);
} }
catch (Exception e) catch (Exception e)
{ {
_log.LogWarning(e, "OTP send failed for {Phone}", phone); _log.LogWarning(e, "OTP send failed for {Phone}", phone);
return (false, null); return new OtpResult(false, null, "SMS_FAILED", 0);
} }
} }
/// <summary>
/// Records an OTP-send attempt against the rate limits. Returns a RATE_LIMITED
/// result (with retry-after seconds) when over a limit, or null when allowed.
/// </summary>
private OtpResult? CheckAndRecordRate(string phone)
{
var now = DateTime.UtcNow;
var hour = TimeSpan.FromHours(1);
var log = _sendLog.GetOrAdd(phone, _ => new List<DateTime>());
lock (log)
{
log.RemoveAll(t => now - t >= hour);
if (log.Count > 0)
{
var since = now - log[^1];
var cooldown = TimeSpan.FromSeconds(_opts.ResendCooldownSeconds);
if (since < cooldown)
return new OtpResult(false, null, "RATE_LIMITED", (int)Math.Ceiling((cooldown - since).TotalSeconds));
}
if (_opts.MaxPerHour > 0 && log.Count >= _opts.MaxPerHour)
{
var retry = (int)Math.Ceiling((hour - (now - log[0])).TotalSeconds);
return new OtpResult(false, null, "RATE_LIMITED", Math.Max(1, retry));
}
log.Add(now); // reserve the slot
}
if (_opts.MaxGlobalPerHour > 0)
{
lock (_globalLock)
{
_globalLog.RemoveAll(t => now - t >= hour);
if (_globalLog.Count >= _opts.MaxGlobalPerHour)
return new OtpResult(false, null, "RATE_LIMITED", 60);
_globalLog.Add(now);
}
}
return null;
}
/// <summary>Verify a submitted code (single-use, time-boxed, max 5 tries).</summary> /// <summary>Verify a submitted code (single-use, time-boxed, max 5 tries).</summary>
public bool Verify(string phone, string code) public bool Verify(string phone, string code)
{ {
+6
View File
@@ -43,4 +43,10 @@ public sealed class GameHub : Hub
public void RequestForfeit() => _manager.RequestForfeit(Uid); public void RequestForfeit() => _manager.RequestForfeit(Uid);
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid); public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
public void DeclineForfeit() => _manager.DeclineForfeit(Uid); public void DeclineForfeit() => _manager.DeclineForfeit(Uid);
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
public Task Typing(string peerId) =>
string.IsNullOrWhiteSpace(peerId)
? Task.CompletedTask
: Clients.User(peerId).SendAsync("typing", new { from = Uid });
} }
+6 -3
View File
@@ -150,10 +150,13 @@ app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteL
// --- phone OTP (Kavenegar SMS) + email login --- // --- phone OTP (Kavenegar SMS) + email login ---
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) => app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
{ {
var (ok, devCode) = await otp.Request(req.Phone); var r = await otp.Request(req.Phone);
if (!ok) return Results.BadRequest(new { error = "SMS_FAILED" }); if (r.Ok)
// devCode is only populated in dev mode (no API key); null in production. // devCode is only populated in dev mode (no API key); null in production.
return Results.Json(new { sent = true, phone = req.Phone, devCode }); return Results.Json(new { sent = true, phone = req.Phone, devCode = r.DevCode });
if (r.Error == "RATE_LIMITED")
return Results.Json(new { error = "RATE_LIMITED", retryAfter = r.RetryAfterSeconds }, statusCode: 429);
return Results.BadRequest(new { error = r.Error ?? "SMS_FAILED" });
}); });
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, OtpService otp, TokenService tokens, ProfileService profiles) => app.MapPost("/api/auth/otp/verify", async (OtpVerify req, OtpService otp, TokenService tokens, ProfileService profiles) =>
+4 -1
View File
@@ -37,6 +37,9 @@
"ApiKey": "", "ApiKey": "",
"Template": "hokmotp", "Template": "hokmotp",
"DevMode": false, "DevMode": false,
"DevCode": "1234" "DevCode": "1234",
"ResendCooldownSeconds": 60,
"MaxPerHour": 5,
"MaxGlobalPerHour": 300
} }
} }
+15 -15
View File
@@ -118,12 +118,12 @@ export function GameTable({
{/* Top HUD */} {/* 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"> <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 /> <Scoreboard />
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<SpeedBadge /> <SpeedBadge />
{trump && <TrumpBadge trump={trump} />} {trump && <TrumpBadge trump={trump} />}
<button <button
onClick={toggleAll} 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")} title={t("settings.audio")}
> >
{muted ? ( {muted ? (
@@ -135,7 +135,7 @@ export function GameTable({
{onForfeit && ( {onForfeit && (
<button <button
onClick={() => setAskFf(true)} 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")} title={t("forfeit.title")}
> >
<Flag className="size-4 text-rose-300/90" /> <Flag className="size-4 text-rose-300/90" />
@@ -143,7 +143,7 @@ export function GameTable({
)} )}
<button <button
onClick={exit} 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")} title={t("hud.quit")}
> >
<LogOut className="size-4 text-cream/80" /> <LogOut className="size-4 text-cream/80" />
@@ -234,23 +234,23 @@ function Scoreboard() {
const game = useGameStore((s) => s.game); const game = useGameStore((s) => s.game);
const { t } = useI18n(); const { t } = useI18n();
return ( 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 <ScoreCol
label={t("team.us")} label={t("team.us")}
tricks={game.roundTricks[0]} tricks={game.roundTricks[0]}
score={game.matchScore[0]} score={game.matchScore[0]}
accent="text-teal-400" 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 <ScoreCol
label={t("team.them")} label={t("team.them")}
tricks={game.roundTricks[1]} tricks={game.roundTricks[1]}
score={game.matchScore[1]} score={game.matchScore[1]}
accent="text-rose-400" 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="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-[10px] text-cream/50">{t("home.target")}</div> <div className="text-[9px] sm:text-[10px] text-cream/50 leading-none">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div> <div className="gold-text font-bold text-center text-sm sm:text-base leading-tight">{game.targetScore}</div>
</div> </div>
</div> </div>
); );
@@ -269,10 +269,10 @@ function ScoreCol({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="text-center min-w-10 sm:min-w-14"> <div className="text-center min-w-8 sm:min-w-14 shrink-0">
<div className={cn("text-[11px] sm:text-xs font-semibold", accent)}>{label}</div> <div className={cn("text-[10px] sm:text-xs font-semibold truncate", accent)}>{label}</div>
<div className="text-lg sm:text-2xl font-black leading-none">{score}</div> <div className="text-base sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5"> <div className="text-[9px] sm:text-[10px] text-cream/45 mt-0.5 truncate">
{t("score.tricks")}: {tricks} {t("score.tricks")}: {tricks}
</div> </div>
</div> </div>
@@ -439,8 +439,8 @@ function TrickArea({
}) { }) {
const { front } = useCardSkins(); const { front } = useCardSkins();
return ( return (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0">
<div className="relative size-1"> <div className="absolute left-1/2 top-1/2 size-0">
<AnimatePresence> <AnimatePresence>
{trick.map((pc) => { {trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale }; const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
+10 -1
View File
@@ -78,8 +78,17 @@ function PhoneForm({ onDone }: { onDone: () => void }) {
const res = await requestOtp(phone.trim()); const res = await requestOtp(phone.trim());
setDevCode(res.devCode ?? null); setDevCode(res.devCode ?? null);
setSent(true); // advance to the code step regardless of dev/prod setSent(true); // advance to the code step regardless of dev/prod
} 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 { } catch {
setError(t("auth.sendFailed")); /* not JSON — keep the generic message */
}
setError(msg);
} finally { } finally {
setBusy(false); setBusy(false);
} }
+51 -3
View File
@@ -26,9 +26,12 @@ export function ChatScreen() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const [reported, setReported] = useState(false); const [reported, setReported] = useState(false);
const [peerTyping, setPeerTyping] = useState(false);
const emojis = profile ? ownedReactions(profile) : []; const emojis = profile ? ownedReactions(profile) : [];
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const prevLen = useRef(0); const prevLen = useRef(0);
const lastTypingSent = useRef(0);
const typingClear = useRef<ReturnType<typeof setTimeout> | null>(null);
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft; const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
useEffect(() => { useEffect(() => {
@@ -43,6 +46,28 @@ export function ChatScreen() {
// Clean up the chat subscription whenever we leave (header back OR hardware back). // Clean up the chat subscription whenever we leave (header back OR hardware back).
useEffect(() => () => closeChat(), [closeChat]); 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) { if (!friend) {
return null; return null;
} }
@@ -56,6 +81,16 @@ export function ChatScreen() {
await sendChat(v); 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) => { const sendEmoji = async (e: string) => {
setShowEmoji(false); setShowEmoji(false);
await sendChat(e); await sendChat(e);
@@ -94,8 +129,10 @@ export function ChatScreen() {
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span> <span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div> <div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className="text-[11px] text-teal-300"> <div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
{friend.status === "online" {peerTyping
? t("chat.typing")
: friend.status === "online"
? t("friends.online") ? t("friends.online")
: friend.status === "in-game" : friend.status === "in-game"
? t("friends.inGame") ? t("friends.inGame")
@@ -142,6 +179,17 @@ export function ChatScreen() {
{m.text} {m.text}
</div> </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 ref={endRef} />
</div> </div>
@@ -172,7 +220,7 @@ export function ChatScreen() {
<input <input
value={text} value={text}
onFocus={() => setShowEmoji(false)} onFocus={() => setShowEmoji(false)}
onChange={(e) => setText(e.target.value)} onChange={(e) => onType(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()} onKeyDown={(e) => e.key === "Enter" && send()}
placeholder={t("chat.placeholder")} 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" 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"
+44 -7
View File
@@ -8,9 +8,17 @@ import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store"; import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n"; 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"; 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() { export function RoomScreen() {
const { t } = useI18n(); const { t } = useI18n();
const room = useOnlineStore((s) => s.room); const room = useOnlineStore((s) => s.room);
@@ -35,6 +43,8 @@ export function RoomScreen() {
if (!room) return null; if (!room) return null;
const seat = (n: number) => room.seats.find((s) => s.seat === n)!; 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) => { const pick = async (friend: Friend) => {
if (!picker) return; if (!picker) return;
@@ -152,21 +162,39 @@ export function RoomScreen() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="panel rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto" 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"> <div className="space-y-2">
{friends.map((f) => ( {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 <button
key={f.id} key={f.id}
onClick={() => pick(f)} 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" 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="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</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 className="text-[11px] text-cream/45"> </span>
{t("common.level")} {f.level} <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> </span>
</button> </button>
))} );
})}
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -210,7 +238,16 @@ function SeatCard({
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>} {seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span> </span>
{seat.kind === "invited" ? ( {seat.kind === "invited" ? (
<div className="flex flex-col items-center gap-1">
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span> <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" && ( role !== "you" && (
<button <button
+10 -2
View File
@@ -152,6 +152,7 @@ const fa: Dict = {
"chat.placeholder": "پیام بنویسید…", "chat.placeholder": "پیام بنویسید…",
"chat.send": "ارسال", "chat.send": "ارسال",
"chat.emoji": "ایموجی", "chat.emoji": "ایموجی",
"chat.typing": "در حال نوشتن…",
"city.rewardTitle": "شهرت را انتخاب کن!", "city.rewardTitle": "شهرت را انتخاب کن!",
"city.rewardSub": "{n} سکه هدیه بگیر", "city.rewardSub": "{n} سکه هدیه بگیر",
"city.search": "جستجوی شهر…", "city.search": "جستجوی شهر…",
@@ -234,11 +235,13 @@ const fa: Dict = {
"room.invite": "دعوت دوست", "room.invite": "دعوت دوست",
"room.addBot": "ربات", "room.addBot": "ربات",
"room.empty": "خالی", "room.empty": "خالی",
"room.waiting": "در انتظار…", "room.waiting": "در انتظار پذیرش…",
"room.cancelInvite": "لغو دعوت",
"room.start": "شروع بازی", "room.start": "شروع بازی",
"room.stake": "سکه ورودی", "room.stake": "سکه ورودی",
"room.leave": "ترک اتاق", "room.leave": "ترک اتاق",
"room.pickFriend": "یک دوست را انتخاب کنید", "room.pickFriend": "یک دوست را انتخاب کنید",
"room.inviteHint": "صندلی تا پذیرش دعوت در حالت انتظار می‌ماند",
"mm.title": "جستجوی بازیکن", "mm.title": "جستجوی بازیکن",
"mm.searching": "در حال یافتن حریف…", "mm.searching": "در حال یافتن حریف…",
@@ -285,6 +288,7 @@ const fa: Dict = {
"auth.invalidPhone": "شماره موبایل را درست وارد کنید", "auth.invalidPhone": "شماره موبایل را درست وارد کنید",
"auth.sending": "در حال ارسال…", "auth.sending": "در حال ارسال…",
"auth.sendFailed": "ارسال پیامک ناموفق بود، دوباره تلاش کنید", "auth.sendFailed": "ارسال پیامک ناموفق بود، دوباره تلاش کنید",
"auth.rateLimited": "درخواست‌های زیاد. لطفاً {s} ثانیه دیگر تلاش کنید",
"auth.codeSent": "کد به شماره شما پیامک شد", "auth.codeSent": "کد به شماره شما پیامک شد",
"auth.changeNumber": "تغییر شماره", "auth.changeNumber": "تغییر شماره",
"auth.otherSoon": "سایر روش‌های ورود به‌زودی فعال می‌شوند", "auth.otherSoon": "سایر روش‌های ورود به‌زودی فعال می‌شوند",
@@ -524,6 +528,7 @@ const en: Dict = {
"chat.placeholder": "Type a message…", "chat.placeholder": "Type a message…",
"chat.send": "Send", "chat.send": "Send",
"chat.emoji": "Emoji", "chat.emoji": "Emoji",
"chat.typing": "typing…",
"city.rewardTitle": "Set your city!", "city.rewardTitle": "Set your city!",
"city.rewardSub": "Earn {n} coins", "city.rewardSub": "Earn {n} coins",
"city.search": "Search city…", "city.search": "Search city…",
@@ -606,11 +611,13 @@ const en: Dict = {
"room.invite": "Invite friend", "room.invite": "Invite friend",
"room.addBot": "Bot", "room.addBot": "Bot",
"room.empty": "Empty", "room.empty": "Empty",
"room.waiting": "Waiting…", "room.waiting": "Waiting to accept…",
"room.cancelInvite": "Cancel invite",
"room.start": "Start game", "room.start": "Start game",
"room.stake": "Entry coins", "room.stake": "Entry coins",
"room.leave": "Leave room", "room.leave": "Leave room",
"room.pickFriend": "Pick a friend", "room.pickFriend": "Pick a friend",
"room.inviteHint": "The seat stays pending until they accept the invite",
"mm.title": "Finding players", "mm.title": "Finding players",
"mm.searching": "Searching for opponents…", "mm.searching": "Searching for opponents…",
@@ -654,6 +661,7 @@ const en: Dict = {
"auth.invalidPhone": "Enter a valid mobile number", "auth.invalidPhone": "Enter a valid mobile number",
"auth.sending": "Sending…", "auth.sending": "Sending…",
"auth.sendFailed": "Couldn't send the SMS, try again", "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.codeSent": "Code sent to your number",
"auth.changeNumber": "Change number", "auth.changeNumber": "Change number",
"auth.otherSoon": "Other sign-in methods coming soon", "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); return () => this.chatCbs.delete(cb);
} }
// Offline mock has no real peers, so typing pings are no-ops.
sendTyping(): void {}
onTyping(): Unsubscribe {
return () => {};
}
/* ---------------------------- reactions ---------------------------- */ /* ---------------------------- reactions ---------------------------- */
async sendReaction(reaction: string) { 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) { async setPartner(roomId: string, friendId: string | null) {
void roomId; void roomId;
if (!this.room) throw new Error("NO_ROOM"); if (!this.room) throw new Error("NO_ROOM");
@@ -787,10 +804,7 @@ export class MockOnlineService implements OnlineService {
this.setSeat(2, { seat: 2, kind: "empty" }); this.setSeat(2, { seat: 2, kind: "empty" });
} else { } else {
this.setSeat(2, this.friendSeat(2, friendId, true)); this.setSeat(2, this.friendSeat(2, friendId, true));
this.after(1100, () => { this.acceptInviteWhenPending(2, friendId);
this.setSeat(2, this.friendSeat(2, friendId, false));
this.emitRoom();
});
} }
this.emitRoom(); this.emitRoom();
return this.room; return this.room;
@@ -800,10 +814,7 @@ export class MockOnlineService implements OnlineService {
void roomId; void roomId;
if (!this.room) throw new Error("NO_ROOM"); if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, this.friendSeat(seat, friendId, true)); this.setSeat(seat, this.friendSeat(seat, friendId, true));
this.after(1100, () => { this.acceptInviteWhenPending(seat, friendId);
this.setSeat(seat, this.friendSeat(seat, friendId, false));
this.emitRoom();
});
this.emitRoom(); this.emitRoom();
return this.room; return this.room;
} }
+3
View File
@@ -88,6 +88,9 @@ export interface OnlineService {
sendMessage(friendId: string, text: string): Promise<ChatMessage>; sendMessage(friendId: string, text: string): Promise<ChatMessage>;
markRead(friendId: string): Promise<void>; markRead(friendId: string): Promise<void>;
onChat(cb: (friendId: string, messages: ChatMessage[]) => void): Unsubscribe; 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) ----- */ /* ----- reactions (in-game emotes) ----- */
sendReaction(reaction: string): Promise<void>; 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 rewardCbs = new Set<(r: RewardResult) => void>();
private friendCbs = new Set<(f: Friend[]) => void>(); private friendCbs = new Set<(f: Friend[]) => void>();
private chatCbs = new Set<(id: string, m: ChatMessage[]) => 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 forfeitCbs = new Set<(r: ForfeitRequest | null) => void>();
private cachedProfile: UserProfile | null = null; 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("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s)));
conn.on("reaction", (r: { seat: number; reaction: string }) => conn.on("reaction", (r: { seat: number; reaction: string }) =>
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction))); 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) => conn.on("notification", (n: AppNotification) =>
this.notifCbs.forEach((cb) => cb(n))); this.notifCbs.forEach((cb) => cb(n)));
conn.on("profile", (p: UserProfile) => conn.on("profile", (p: UserProfile) =>
@@ -404,6 +407,8 @@ export class SignalrService implements OnlineService {
} }
async markRead() { /* server marks read when messages are fetched */ } 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); } 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 { onNotification(cb: (n: AppNotification) => void): Unsubscribe {
// Real notifications only — server hub "notification" events + app-generated // Real notifications only — server hub "notification" events + app-generated