feat: photo upload at level 3 + report a player (nudity avatar / chat insult)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m58s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s

Photo upload:
- Lower the custom profile-photo gate from level 25 to level 3 (client const +
  i18n hint + server gate in ProfileService.Update). The level-25 "Expert" title
  is unrelated and unchanged.

Report a player:
- New ReportReason type + service.reportUser(targetId, reason, details?).
- Report entry points: a "گزارش تخلف" button + reason picker (nudity / insult /
  other) in the public-profile modal, and a flag button in the chat header
  (reports the peer for an insulting chat) with a confirmation toast.
- Mock records reports to localStorage; SignalR POSTs /api/report.
- Server: POST /api/report → ProfileService.ReportUser stores the report in the
  write-only ledger (kind="report", ref="{targetId}|{reason}|{details}") so no
  schema change is needed (server uses EnsureCreated, not migrations).
- i18n: report.* keys (fa + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 19:12:02 +03:30
parent 8033023a1f
commit 6641741669
10 changed files with 165 additions and 8 deletions
@@ -54,13 +54,28 @@ public class ProfileService
await _db.SaveChangesAsync();
}
/// <summary>
/// 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}".
/// </summary>
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<ProfileDto> 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()!;
+8
View File
@@ -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);
+65 -2
View File
@@ -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<AchievementCategoryId>("victory");
const [send, setSend] = useState<SendState>("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() {
</div>
)}
{/* report a player (nudity avatar / insulting chat) */}
{!profile.isYou && (
<div className="mt-3">
{reportDone ? (
<div className="text-center text-[11px] text-teal-300 flex items-center justify-center gap-1">
<Check className="size-3.5" /> {t("report.done")}
</div>
) : !reportOpen ? (
<button
onClick={() => setReportOpen(true)}
className="mx-auto flex items-center gap-1 text-[11px] text-cream/40 hover:text-rose-300 transition"
>
<Flag className="size-3" /> {t("report.button")}
</button>
) : (
<div className="rounded-xl bg-navy-900/60 border border-rose-500/25 p-2.5">
<div className="text-[11px] text-cream/70 mb-2 text-center">{t("report.title")}</div>
<div className="grid gap-1.5">
{([
["nudity", "🔞", t("report.nudity")],
["insult", "💬", t("report.insult")],
["other", "⚠️", t("report.other")],
] as const).map(([reason, icon, label]) => (
<button
key={reason}
disabled={reporting}
onClick={() => submitReport(reason)}
className="flex items-center gap-2 rounded-lg bg-navy-900/70 hover:bg-rose-500/15 px-3 py-2 text-sm text-cream/85 disabled:opacity-60 text-start transition"
>
<span>{icon}</span>
{label}
</button>
))}
</div>
<button
onClick={() => setReportOpen(false)}
className="mt-2 w-full text-[11px] text-cream/40 hover:text-cream transition"
>
{t("report.cancel")}
</button>
</div>
)}
</div>
)}
{/* stats */}
<div className="mt-4 grid grid-cols-3 gap-2">
<Stat label={t("profile.games")} value={profile.stats.games} />
+32 -1
View File
@@ -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<HTMLDivElement>(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 (
<main className="persian-pattern relative h-dvh w-full flex justify-center">
<div className="w-full max-w-3xl flex flex-col h-full">
@@ -86,6 +103,20 @@ export function ChatScreen() {
</div>
</div>
</button>
{/* report this player for insulting chat */}
<button
onClick={reportFriend}
disabled={reported}
title={t("report.insult")}
aria-label={t("report.button")}
className={cn(
"tap grid place-items-center rounded-full shrink-0 transition",
reported ? "text-teal-300" : "text-cream/40 hover:text-rose-300 hover:bg-navy-800/80"
)}
>
<Flag className="size-4" />
</button>
</header>
{/* messages */}
+1 -1
View File
@@ -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";
+16 -2
View File
@@ -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",
+15
View File
@@ -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() {
+3
View File
@@ -16,6 +16,7 @@ import {
LeaderboardEntry,
PlayerSummary,
PublicProfile,
ReportReason,
MatchSummary,
MatchmakingState,
RewardResult,
@@ -78,6 +79,8 @@ export interface OnlineService {
declineRequest(id: string): Promise<void>;
removeFriend(id: string): Promise<void>;
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<Conversation[]>;
+5
View File
@@ -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<unknown>("POST", "/api/friends/accept", { id }); }
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
async removeFriend(id: string) { await this.send<unknown>("POST", "/api/friends/remove", { id }); }
async reportUser(targetId: string, reason: ReportReason, details?: string) {
await this.send<unknown>("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); }
+3
View File
@@ -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,