feat: OTP rate limit, private-room invite UX, in-game UI fixes
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:
@@ -16,8 +16,19 @@ public sealed class SmsOptions
|
||||
public bool DevMode { get; set; } = false;
|
||||
public string DevCode { get; set; } = "1234";
|
||||
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>
|
||||
public sealed class OtpService
|
||||
{
|
||||
@@ -26,6 +37,11 @@ public sealed class OtpService
|
||||
private readonly ILogger<OtpService> _log;
|
||||
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);
|
||||
|
||||
public OtpService(SmsOptions opts, ILogger<OtpService> log)
|
||||
@@ -38,28 +54,78 @@ public sealed class OtpService
|
||||
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>
|
||||
public async Task<(bool ok, string? devCode)> Request(string phone)
|
||||
public async Task<OtpResult> Request(string 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);
|
||||
|
||||
if (IsDev) return (true, _opts.DevCode);
|
||||
|
||||
try
|
||||
{
|
||||
await SendKavenegar(phone, code);
|
||||
return (true, null);
|
||||
return new OtpResult(true, null, null, 0);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_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>
|
||||
public bool Verify(string phone, string code)
|
||||
{
|
||||
|
||||
@@ -43,4 +43,10 @@ public sealed class GameHub : Hub
|
||||
public void RequestForfeit() => _manager.RequestForfeit(Uid);
|
||||
public void ConfirmForfeit() => _manager.ConfirmForfeit(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 });
|
||||
}
|
||||
|
||||
@@ -150,10 +150,13 @@ app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteL
|
||||
// --- phone OTP (Kavenegar SMS) + email login ---
|
||||
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
|
||||
{
|
||||
var (ok, devCode) = await otp.Request(req.Phone);
|
||||
if (!ok) return Results.BadRequest(new { error = "SMS_FAILED" });
|
||||
var r = await otp.Request(req.Phone);
|
||||
if (r.Ok)
|
||||
// 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) =>
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"ApiKey": "",
|
||||
"Template": "hokmotp",
|
||||
"DevMode": false,
|
||||
"DevCode": "1234"
|
||||
"DevCode": "1234",
|
||||
"ResendCooldownSeconds": 60,
|
||||
"MaxPerHour": 5,
|
||||
"MaxGlobalPerHour": 300
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (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 {
|
||||
setError(t("auth.sendFailed"));
|
||||
/* not JSON — keep the generic message */
|
||||
}
|
||||
setError(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => (
|
||||
{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="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
|
||||
<span className="text-[11px] text-cream/45">
|
||||
{t("common.level")} {f.level}
|
||||
<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" ? (
|
||||
<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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user