feat: photo upload at level 3 + report a player (nudity avatar / chat insult)
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:
@@ -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()!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user