diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index fab7b18..c373be9 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -54,13 +54,28 @@ public class ProfileService await _db.SaveChangesAsync(); } + /// + /// Record a moderation report (inappropriate avatar / insulting chat). Stored + /// in the write-only ledger as kind="report" so no schema change is needed; + /// Ref encodes "{targetId}|{reason}|{details}". + /// + public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details) + { + if (string.IsNullOrWhiteSpace(targetId)) return; + var safeReason = reason is "nudity" or "insult" or "other" ? reason : "other"; + var safeDetails = (details ?? "").Replace("\n", " ").Trim(); + var @ref = $"{targetId}|{safeReason}|{safeDetails}"; + if (@ref.Length > 480) @ref = @ref[..480]; + await Ledger(reporterUid, "report", 0, @ref); + } + public async Task Update(string uid, JsonElement patch) { var p = await GetOrCreate(uid, null); if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!; if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!; - // Custom photo upload is gated behind level 25. - if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString(); + // Custom photo upload is gated behind level 3. + if (p.Level >= 3 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString(); if (patch.TryGetProperty("title", out var ti) && ti.ValueKind == JsonValueKind.String) p.Title = ti.GetString(); if (patch.TryGetProperty("cardFront", out var cf) && cf.ValueKind == JsonValueKind.String) p.CardFront = cf.GetString()!; if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!; diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index dd3b12d..eecb0c1 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -181,6 +181,13 @@ app.MapGet("/api/profile/{id}/public", async (string id, ClaimsPrincipal u, Soci return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound(); }).RequireAuthorization(); +// Report a player (inappropriate avatar / insulting chat). +app.MapPost("/api/report", async (ClaimsPrincipal u, ProfileService svc, ReportReq req) => +{ + await svc.ReportUser(Uid(u), req.TargetId, req.Reason, req.Details); + return Results.Json(new { ok = true }, JsonOpts.Default); +}).RequireAuthorization(); + // Discover players (find-friends hub): search by name + suggestions. app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) => Results.Json(await s.SearchPlayers(Uid(u), q ?? ""), JsonOpts.Default)).RequireAuthorization(); @@ -308,3 +315,4 @@ record IabVerifyReq(string Store, string ProductId, string Token); record QueryReq(string? Query = null, string? UserId = null); record IdReq(string Id); record SendReq(string PeerId, string Text); +record ReportReq(string TargetId, string? Reason, string? Details); diff --git a/src/components/online/PublicProfileModal.tsx b/src/components/online/PublicProfileModal.tsx index a3e98ca..969b1f6 100644 --- a/src/components/online/PublicProfileModal.tsx +++ b/src/components/online/PublicProfileModal.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Check, Clock, Coins, Crown, Loader2, UserPlus, X } from "lucide-react"; +import { Check, Clock, Coins, Crown, Flag, Loader2, UserPlus, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useUIStore } from "@/lib/ui-store"; import { useOnlineStore } from "@/lib/online-store"; @@ -13,7 +13,7 @@ import { achievementProgress, } from "@/lib/online/gamification"; import { getService } from "@/lib/online/service"; -import type { AchievementCategoryId, PublicProfile } from "@/lib/online/types"; +import type { AchievementCategoryId, PublicProfile, ReportReason } from "@/lib/online/types"; import { GENDER_META, SOCIAL_PLATFORMS, hasSocials, socialUrl } from "@/lib/social"; import { Avatar } from "./Avatar"; import { RankBadge } from "./RankBadge"; @@ -32,6 +32,9 @@ export function PublicProfileModal() { const [tab, setTab] = useState("victory"); const [send, setSend] = useState("idle"); const [sendMsg, setSendMsg] = useState(""); + const [reportOpen, setReportOpen] = useState(false); + const [reportDone, setReportDone] = useState(false); + const [reporting, setReporting] = useState(false); useEffect(() => { if (!userId) return; @@ -40,6 +43,9 @@ export function PublicProfileModal() { setSend("idle"); setSendMsg(""); setTab("victory"); + setReportOpen(false); + setReportDone(false); + setReporting(false); let alive = true; getService() .getPublicProfile(userId) @@ -67,6 +73,18 @@ export function PublicProfileModal() { } }; + const submitReport = async (reason: ReportReason) => { + if (!profile || reporting) return; + setReporting(true); + try { + await getService().reportUser(profile.id, reason); + } finally { + setReporting(false); + } + setReportOpen(false); + setReportDone(true); + }; + const titleDef = profile?.title ? TITLES.find((x) => x.id === profile.title) : null; const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null; @@ -203,6 +221,51 @@ export function PublicProfileModal() { )} + {/* report a player (nudity avatar / insulting chat) */} + {!profile.isYou && ( +
+ {reportDone ? ( +
+ {t("report.done")} +
+ ) : !reportOpen ? ( + + ) : ( +
+
{t("report.title")}
+
+ {([ + ["nudity", "🔞", t("report.nudity")], + ["insult", "💬", t("report.insult")], + ["other", "⚠️", t("report.other")], + ] as const).map(([reason, icon, label]) => ( + + ))} +
+ +
+ )} +
+ )} + {/* stats */}
diff --git a/src/components/screens/ChatScreen.tsx b/src/components/screens/ChatScreen.tsx index 2db26a4..bb5b72b 100644 --- a/src/components/screens/ChatScreen.tsx +++ b/src/components/screens/ChatScreen.tsx @@ -1,12 +1,14 @@ "use client"; -import { ChevronLeft, ChevronRight, MessageCircle, Send, Smile } from "lucide-react"; +import { ChevronLeft, ChevronRight, Flag, MessageCircle, Send, Smile } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useOnlineStore } from "@/lib/online-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { sound } from "@/lib/sound"; +import { getService } from "@/lib/online/service"; +import { pushNotification } from "@/lib/notification-store"; import { ownedReactions } from "@/lib/online/gamification"; import { avatarEmoji } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -23,6 +25,7 @@ export function ChatScreen() { const viewProfile = useUIStore((s) => s.viewProfile); const [text, setText] = useState(""); const [showEmoji, setShowEmoji] = useState(false); + const [reported, setReported] = useState(false); const emojis = profile ? ownedReactions(profile) : []; const endRef = useRef(null); const prevLen = useRef(0); @@ -58,6 +61,20 @@ export function ChatScreen() { await sendChat(e); }; + const reportFriend = async () => { + if (reported || !friend) return; + setReported(true); + await getService().reportUser(friend.id, "insult"); + pushNotification({ + kind: "system", + titleFa: "گزارش ثبت شد", + titleEn: "Report submitted", + bodyFa: "از کمک شما برای حفظ محیط سالم ممنونیم.", + bodyEn: "Thanks for helping keep the game friendly.", + icon: "🚩", + }); + }; + return (
@@ -86,6 +103,20 @@ export function ChatScreen() {
+ + {/* report this player for insulting chat */} + {/* messages */} diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index 9091c2f..3387d4e 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -28,7 +28,7 @@ import { pushNotification } from "@/lib/notification-store"; import { sound } from "@/lib/sound"; /** Level required before a player can upload a custom profile photo. */ -const PHOTO_UPLOAD_MIN_LEVEL = 25; +const PHOTO_UPLOAD_MIN_LEVEL = 3; import { AVATARS, Gender, SocialVisibility } from "@/lib/online/types"; import { GENDER_META, SOCIAL_PLATFORMS } from "@/lib/social"; import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack"; diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 3b20978..a0a9264 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -42,7 +42,7 @@ const fa: Dict = { "achv.unlocksSticker": "استیکر", "lobby.chooseLeague": "لیگ را انتخاب کنید", "lobby.lvl": "سطح", - "profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال می‌شود", + "profile.uploadLocked": "آپلود عکس از سطح ۳ فعال می‌شود", "forfeit.title": "تسلیم", "forfeit.ask": "از این بازی تسلیم می‌شوید؟", @@ -161,6 +161,13 @@ const fa: Dict = { "city.claimed": "جایزهٔ شهر دریافت شد ✓", "city.change": "تغییر", "city.unknown": "نامشخص", + "report.button": "گزارش تخلف", + "report.title": "علت گزارش این کاربر؟", + "report.nudity": "تصویر نامناسب (مستهجن)", + "report.insult": "توهین در گفتگو", + "report.other": "موارد دیگر", + "report.cancel": "انصراف", + "report.done": "گزارش شما ثبت شد. ممنون!", "chat.empty": "گفتگو را شروع کنید", "friends.message": "پیام", @@ -398,7 +405,7 @@ const en: Dict = { "achv.unlocksSticker": "Sticker", "lobby.chooseLeague": "Choose a league", "lobby.lvl": "Lvl", - "profile.uploadLocked": "Photo upload unlocks at level 25", + "profile.uploadLocked": "Photo upload unlocks at level 3", "forfeit.title": "Forfeit", "forfeit.ask": "Surrender this match?", @@ -521,6 +528,13 @@ const en: Dict = { "city.claimed": "City reward claimed ✓", "city.change": "Change", "city.unknown": "Unknown", + "report.button": "Report", + "report.title": "Why are you reporting this player?", + "report.nudity": "Inappropriate photo (nudity)", + "report.insult": "Insulting chat", + "report.other": "Something else", + "report.cancel": "Cancel", + "report.done": "Report submitted. Thank you!", "chat.empty": "Start the conversation", "friends.message": "Message", diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 4a61139..b6299a0 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -47,6 +47,7 @@ import { PublicProfile, SocialLinks, SocialVisibility, + ReportReason, RewardResult, Room, RoomSeat, @@ -589,6 +590,20 @@ export class MockOnlineService implements OnlineService { return () => this.friendCbs.delete(cb); } + async reportUser(targetId: string, reason: ReportReason, details?: string) { + // Dev mock: just record it locally for moderation; the real backend persists. + try { + const key = "hokm.reports"; + const raw = typeof window !== "undefined" ? localStorage.getItem(key) : null; + const list: unknown[] = raw ? JSON.parse(raw) : []; + list.push({ targetId, reason, details: details ?? "", at: this.profile?.id ?? "me" }); + if (typeof window !== "undefined") localStorage.setItem(key, JSON.stringify(list)); + } catch { + /* ignore */ + } + return { ok: true }; + } + /* ------------------------------- chat ------------------------------ */ private saveChats() { diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 6f33009..aae6a0c 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -16,6 +16,7 @@ import { LeaderboardEntry, PlayerSummary, PublicProfile, + ReportReason, MatchSummary, MatchmakingState, RewardResult, @@ -78,6 +79,8 @@ export interface OnlineService { declineRequest(id: string): Promise; removeFriend(id: string): Promise; onFriends(cb: (friends: Friend[]) => void): Unsubscribe; + /** Report a player for an inappropriate avatar (nudity) or insulting chat. */ + reportUser(targetId: string, reason: ReportReason, details?: string): Promise<{ ok: boolean }>; /* ----- chat ----- */ listConversations(): Promise; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index b6e0f30..7cfe9e1 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -22,6 +22,7 @@ import { LeaderboardEntry, PlayerSummary, PublicProfile, + ReportReason, MatchSummary, MatchmakingState, RewardResult, @@ -377,6 +378,10 @@ export class SignalrService implements OnlineService { async acceptRequest(id: string) { await this.send("POST", "/api/friends/accept", { id }); } async declineRequest(id: string) { await this.send("POST", "/api/friends/decline", { id }); } async removeFriend(id: string) { await this.send("POST", "/api/friends/remove", { id }); } + async reportUser(targetId: string, reason: ReportReason, details?: string) { + await this.send("POST", "/api/report", { targetId, reason, details }); + return { ok: true }; + } onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); } createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); } diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 2abd81e..1e28d39 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -91,6 +91,9 @@ export interface UserProfile { createdAt: number; } +/** Why a player is being reported. */ +export type ReportReason = "nudity" | "insult" | "other"; + /** * A public-facing view of another player — what you may see by tapping their * row in the leaderboard / friends list. No private fields (coins, phone,