diff --git a/server/src/Hokm.Server/Auth/OtpService.cs b/server/src/Hokm.Server/Auth/OtpService.cs index 391eda8..4db5716 100644 --- a/server/src/Hokm.Server/Auth/OtpService.cs +++ b/server/src/Hokm.Server/Auth/OtpService.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text.Json; namespace Hokm.Server.Auth; @@ -147,10 +148,36 @@ public sealed class OtpService $"?receptor={Uri.EscapeDataString(phone)}" + $"&token={Uri.EscapeDataString(code)}" + $"&template={Uri.EscapeDataString(_opts.Template)}"; - var resp = await Http.GetAsync(url); - var body = await resp.Content.ReadAsStringAsync(); - if (!resp.IsSuccessStatusCode) - throw new InvalidOperationException($"Kavenegar {(int)resp.StatusCode}: {body}"); + + // Bound the call so a hung/slow Kavenegar can't freeze the login request. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12)); + var resp = await Http.GetAsync(url, cts.Token); + var body = await resp.Content.ReadAsStringAsync(cts.Token); + + // Kavenegar replies HTTP 200 with {"return":{"status":200,"message":...}}. + // status 200 = queued/sent; anything else (411 receptor, 418 credit, + // 422 template, 424 template-params…) means it did NOT send. + int? apiStatus = null; + string? apiMessage = null; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("return", out var ret)) + { + if (ret.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Number) + apiStatus = st.GetInt32(); + if (ret.TryGetProperty("message", out var msg)) apiMessage = msg.GetString(); + } + } + catch { /* non-JSON body */ } + + if (!resp.IsSuccessStatusCode || (apiStatus.HasValue && apiStatus != 200)) + { + _log.LogWarning("Kavenegar send FAILED http={Http} apiStatus={Api} message={Msg} body={Body}", + (int)resp.StatusCode, apiStatus, apiMessage, body); + throw new InvalidOperationException($"Kavenegar http={(int)resp.StatusCode} status={apiStatus} msg={apiMessage}"); + } + _log.LogInformation("Kavenegar OTP sent to {Phone} (status {Status})", phone, apiStatus ?? 200); } /// Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes). diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 632bd62..943327b 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -53,6 +53,9 @@ public sealed class GameRoom : IDisposable private int? _forfeitPendingTeam; private string? _forfeitRequester; private Timer? _forfeitTimer; + // Per-user cooldown so a player can't spam surrender requests at their teammate. + private const int ForfeitCooldownSeconds = 45; + private readonly Dictionary _forfeitNextAllowed = new(); public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant(); @@ -198,12 +201,16 @@ public sealed class GameRoom : IDisposable if (seat is null) return; int team = seat.Value % 2; var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value); - // No human teammate to ask → forfeit immediately. + // No human teammate to ask → forfeit immediately (no cooldown needed). if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected) { FinalizeForfeit(team); return; } + // Rate-limit repeated asks at a human teammate (anti-nag). + if (_forfeitNextAllowed.TryGetValue(userId, out var until) && DateTime.UtcNow < until) + return; + _forfeitNextAllowed[userId] = DateTime.UtcNow.AddSeconds(ForfeitCooldownSeconds); _forfeitPendingTeam = team; _forfeitRequester = userId; var requester = Seats.First(s => s.UserId == userId); diff --git a/src/components/screens/FriendsScreen.tsx b/src/components/screens/FriendsScreen.tsx index ccba1cd..ba58f57 100644 --- a/src/components/screens/FriendsScreen.tsx +++ b/src/components/screens/FriendsScreen.tsx @@ -6,6 +6,7 @@ import { Clock, Loader2, MessageCircle, + Pin, Search, Sparkles, UserMinus, @@ -336,16 +337,49 @@ function DiscoverRow({ player }: { player: PlayerSummary }) { /* ------------------------------ Messages tab ----------------------------- */ +const PINNED_KEY = "hokm.pinnedChats"; +const MAX_PINNED = 3; + function MessagesTab() { const { t } = useI18n(); const openChat = useOnlineStore((s) => s.openChat); const go = useUIStore((s) => s.go); const [convs, setConvs] = useState(null); + const [pinned, setPinned] = useState([]); + const [pinMsg, setPinMsg] = useState(""); useEffect(() => { getService().listConversations().then(setConvs).catch(() => setConvs([])); + try { + const raw = localStorage.getItem(PINNED_KEY); + if (raw) setPinned(JSON.parse(raw)); + } catch { + /* ignore */ + } }, []); + const savePinned = (next: string[]) => { + setPinned(next); + try { + localStorage.setItem(PINNED_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + }; + + const togglePin = (id: string) => { + if (pinned.includes(id)) { + savePinned(pinned.filter((x) => x !== id)); + return; + } + if (pinned.length >= MAX_PINNED) { + setPinMsg(t("chat.pinLimit").replace("{n}", String(MAX_PINNED))); + setTimeout(() => setPinMsg(""), 2200); + return; + } + savePinned([...pinned, id]); + }; + const open = async (c: Conversation) => { await openChat(c.friend); go("chat"); @@ -362,37 +396,62 @@ function MessagesTab() { if (convs == null) return
; + // Pinned conversations float to the top (stable order otherwise). + const sorted = [...convs].sort( + (a, b) => (pinned.includes(a.friend.id) ? 0 : 1) - (pinned.includes(b.friend.id) ? 0 : 1) + ); + return (
+ {pinMsg &&
{pinMsg}
} {convs.length === 0 && (
} text={t("messages.empty")} />
)} - {convs.map((c) => ( - +
+ > + + - - ))} + ); + })} ); } diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 671ca3a..a098097 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -8,6 +8,7 @@ import { Sticker } from "@/components/online/Sticker"; import { Avatar } from "@/components/online/Avatar"; import { CoinsPill } from "@/components/online/CoinsPill"; import { useSessionStore } from "@/lib/session-store"; +import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; import { sound } from "@/lib/sound"; @@ -330,10 +331,12 @@ function DetailSheet({ onClose: () => void; }) { const { t, locale } = useI18n(); + const go = useUIStore((s) => s.go); const name = locale === "fa" ? item.nameFa : item.nameEn; const desc = locale === "fa" ? item.descFa : item.descEn; const locked = !owned && !!reqLabel; const canAfford = coins >= item.price; + const needCoins = !owned && !locked && !canAfford; return ( )} - {/* buy */} - + {/* buy — when short on coins, offer a CTA to the buy-coins page */} + {needCoins ? ( + <> +

+ {t("shop.needMore").replace("{n}", (item.price - coins).toLocaleString())} +

+ + + ) : ( + + )}
); diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 8f8fbc7..2366756 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -152,6 +152,9 @@ const fa: Dict = { "chat.placeholder": "پیام بنویسید…", "chat.send": "ارسال", "chat.emoji": "ایموجی", + "chat.pin": "سنجاق", + "chat.unpin": "برداشتن سنجاق", + "chat.pinLimit": "حداکثر {n} گفتگو را می‌توانید سنجاق کنید", "chat.typing": "در حال نوشتن…", "city.rewardTitle": "شهرت را انتخاب کن!", "city.rewardSub": "{n} سکه هدیه بگیر", @@ -260,6 +263,8 @@ const fa: Dict = { "shop.title": "فروشگاه", "shop.buy": "خرید", "shop.owned": "موجود", + "shop.getCoins": "شارژ سکه", + "shop.needMore": "{n} سکه کم دارید", "shop.luxury": "ویژه", "shop.avatars": "آواتارها", "shop.themes": "تم‌ها", @@ -528,6 +533,9 @@ const en: Dict = { "chat.placeholder": "Type a message…", "chat.send": "Send", "chat.emoji": "Emoji", + "chat.pin": "Pin", + "chat.unpin": "Unpin", + "chat.pinLimit": "You can pin up to {n} chats", "chat.typing": "typing…", "city.rewardTitle": "Set your city!", "city.rewardSub": "Earn {n} coins", @@ -633,6 +641,8 @@ const en: Dict = { "shop.title": "Shop", "shop.buy": "Buy", "shop.owned": "Owned", + "shop.getCoins": "Get coins", + "shop.needMore": "You need {n} more coins", "shop.luxury": "Luxury", "shop.avatars": "Avatars", "shop.themes": "Themes", diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index b851e6f..c75f24a 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -80,11 +80,21 @@ export class SignalrService implements OnlineService { /* ------------------------------ helpers ---------------------------- */ private async api(path: string, body: unknown): Promise { - const res = await fetch(`${SERVER}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + // Bound the request so a hung/lost response (CDN, network) surfaces an error + // instead of freezing the UI (e.g. the OTP "sending…" button forever). + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 20000); + let res: Response; + try { + res = await fetch(`${SERVER}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } if (!res.ok) throw new Error(await res.text()); return (await res.json()) as T; }