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();
|
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)
|
public async Task<ProfileDto> Update(string uid, JsonElement patch)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreate(uid, null);
|
var p = await GetOrCreate(uid, null);
|
||||||
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
|
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()!;
|
if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
|
||||||
// Custom photo upload is gated behind level 25.
|
// Custom photo upload is gated behind level 3.
|
||||||
if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
|
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("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("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()!;
|
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();
|
return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound();
|
||||||
}).RequireAuthorization();
|
}).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.
|
// Discover players (find-friends hub): search by name + suggestions.
|
||||||
app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) =>
|
app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) =>
|
||||||
Results.Json(await s.SearchPlayers(Uid(u), q ?? ""), JsonOpts.Default)).RequireAuthorization();
|
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 QueryReq(string? Query = null, string? UserId = null);
|
||||||
record IdReq(string Id);
|
record IdReq(string Id);
|
||||||
record SendReq(string PeerId, string Text);
|
record SendReq(string PeerId, string Text);
|
||||||
|
record ReportReq(string TargetId, string? Reason, string? Details);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useEffect, useState } from "react";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
achievementProgress,
|
achievementProgress,
|
||||||
} from "@/lib/online/gamification";
|
} from "@/lib/online/gamification";
|
||||||
import { getService } from "@/lib/online/service";
|
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 { GENDER_META, SOCIAL_PLATFORMS, hasSocials, socialUrl } from "@/lib/social";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
import { RankBadge } from "./RankBadge";
|
import { RankBadge } from "./RankBadge";
|
||||||
@@ -32,6 +32,9 @@ export function PublicProfileModal() {
|
|||||||
const [tab, setTab] = useState<AchievementCategoryId>("victory");
|
const [tab, setTab] = useState<AchievementCategoryId>("victory");
|
||||||
const [send, setSend] = useState<SendState>("idle");
|
const [send, setSend] = useState<SendState>("idle");
|
||||||
const [sendMsg, setSendMsg] = useState("");
|
const [sendMsg, setSendMsg] = useState("");
|
||||||
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
|
const [reportDone, setReportDone] = useState(false);
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -40,6 +43,9 @@ export function PublicProfileModal() {
|
|||||||
setSend("idle");
|
setSend("idle");
|
||||||
setSendMsg("");
|
setSendMsg("");
|
||||||
setTab("victory");
|
setTab("victory");
|
||||||
|
setReportOpen(false);
|
||||||
|
setReportDone(false);
|
||||||
|
setReporting(false);
|
||||||
let alive = true;
|
let alive = true;
|
||||||
getService()
|
getService()
|
||||||
.getPublicProfile(userId)
|
.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 titleDef = profile?.title ? TITLES.find((x) => x.id === profile.title) : null;
|
||||||
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
||||||
|
|
||||||
@@ -203,6 +221,51 @@ export function PublicProfileModal() {
|
|||||||
</div>
|
</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 */}
|
{/* stats */}
|
||||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
<Stat label={t("profile.games")} value={profile.stats.games} />
|
<Stat label={t("profile.games")} value={profile.stats.games} />
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"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 { useEffect, useRef, useState } from "react";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
|
import { getService } from "@/lib/online/service";
|
||||||
|
import { pushNotification } from "@/lib/notification-store";
|
||||||
import { ownedReactions } from "@/lib/online/gamification";
|
import { ownedReactions } from "@/lib/online/gamification";
|
||||||
import { avatarEmoji } from "@/lib/online/types";
|
import { avatarEmoji } from "@/lib/online/types";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -23,6 +25,7 @@ export function ChatScreen() {
|
|||||||
const viewProfile = useUIStore((s) => s.viewProfile);
|
const viewProfile = useUIStore((s) => s.viewProfile);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [showEmoji, setShowEmoji] = useState(false);
|
const [showEmoji, setShowEmoji] = useState(false);
|
||||||
|
const [reported, setReported] = useState(false);
|
||||||
const emojis = profile ? ownedReactions(profile) : [];
|
const emojis = profile ? ownedReactions(profile) : [];
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLen = useRef(0);
|
const prevLen = useRef(0);
|
||||||
@@ -58,6 +61,20 @@ export function ChatScreen() {
|
|||||||
await sendChat(e);
|
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 (
|
return (
|
||||||
<main className="persian-pattern relative h-dvh w-full flex justify-center">
|
<main className="persian-pattern relative h-dvh w-full flex justify-center">
|
||||||
<div className="w-full max-w-3xl flex flex-col h-full">
|
<div className="w-full max-w-3xl flex flex-col h-full">
|
||||||
@@ -86,6 +103,20 @@ export function ChatScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* messages */}
|
{/* messages */}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { pushNotification } from "@/lib/notification-store";
|
|||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
|
|
||||||
/** Level required before a player can upload a custom profile photo. */
|
/** 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 { AVATARS, Gender, SocialVisibility } from "@/lib/online/types";
|
||||||
import { GENDER_META, SOCIAL_PLATFORMS } from "@/lib/social";
|
import { GENDER_META, SOCIAL_PLATFORMS } from "@/lib/social";
|
||||||
import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack";
|
import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack";
|
||||||
|
|||||||
+16
-2
@@ -42,7 +42,7 @@ const fa: Dict = {
|
|||||||
"achv.unlocksSticker": "استیکر",
|
"achv.unlocksSticker": "استیکر",
|
||||||
"lobby.chooseLeague": "لیگ را انتخاب کنید",
|
"lobby.chooseLeague": "لیگ را انتخاب کنید",
|
||||||
"lobby.lvl": "سطح",
|
"lobby.lvl": "سطح",
|
||||||
"profile.uploadLocked": "آپلود عکس از سطح ۲۵ فعال میشود",
|
"profile.uploadLocked": "آپلود عکس از سطح ۳ فعال میشود",
|
||||||
|
|
||||||
"forfeit.title": "تسلیم",
|
"forfeit.title": "تسلیم",
|
||||||
"forfeit.ask": "از این بازی تسلیم میشوید؟",
|
"forfeit.ask": "از این بازی تسلیم میشوید؟",
|
||||||
@@ -161,6 +161,13 @@ const fa: Dict = {
|
|||||||
"city.claimed": "جایزهٔ شهر دریافت شد ✓",
|
"city.claimed": "جایزهٔ شهر دریافت شد ✓",
|
||||||
"city.change": "تغییر",
|
"city.change": "تغییر",
|
||||||
"city.unknown": "نامشخص",
|
"city.unknown": "نامشخص",
|
||||||
|
"report.button": "گزارش تخلف",
|
||||||
|
"report.title": "علت گزارش این کاربر؟",
|
||||||
|
"report.nudity": "تصویر نامناسب (مستهجن)",
|
||||||
|
"report.insult": "توهین در گفتگو",
|
||||||
|
"report.other": "موارد دیگر",
|
||||||
|
"report.cancel": "انصراف",
|
||||||
|
"report.done": "گزارش شما ثبت شد. ممنون!",
|
||||||
"chat.empty": "گفتگو را شروع کنید",
|
"chat.empty": "گفتگو را شروع کنید",
|
||||||
"friends.message": "پیام",
|
"friends.message": "پیام",
|
||||||
|
|
||||||
@@ -398,7 +405,7 @@ const en: Dict = {
|
|||||||
"achv.unlocksSticker": "Sticker",
|
"achv.unlocksSticker": "Sticker",
|
||||||
"lobby.chooseLeague": "Choose a league",
|
"lobby.chooseLeague": "Choose a league",
|
||||||
"lobby.lvl": "Lvl",
|
"lobby.lvl": "Lvl",
|
||||||
"profile.uploadLocked": "Photo upload unlocks at level 25",
|
"profile.uploadLocked": "Photo upload unlocks at level 3",
|
||||||
|
|
||||||
"forfeit.title": "Forfeit",
|
"forfeit.title": "Forfeit",
|
||||||
"forfeit.ask": "Surrender this match?",
|
"forfeit.ask": "Surrender this match?",
|
||||||
@@ -521,6 +528,13 @@ const en: Dict = {
|
|||||||
"city.claimed": "City reward claimed ✓",
|
"city.claimed": "City reward claimed ✓",
|
||||||
"city.change": "Change",
|
"city.change": "Change",
|
||||||
"city.unknown": "Unknown",
|
"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",
|
"chat.empty": "Start the conversation",
|
||||||
"friends.message": "Message",
|
"friends.message": "Message",
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
PublicProfile,
|
PublicProfile,
|
||||||
SocialLinks,
|
SocialLinks,
|
||||||
SocialVisibility,
|
SocialVisibility,
|
||||||
|
ReportReason,
|
||||||
RewardResult,
|
RewardResult,
|
||||||
Room,
|
Room,
|
||||||
RoomSeat,
|
RoomSeat,
|
||||||
@@ -589,6 +590,20 @@ export class MockOnlineService implements OnlineService {
|
|||||||
return () => this.friendCbs.delete(cb);
|
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 ------------------------------ */
|
/* ------------------------------- chat ------------------------------ */
|
||||||
|
|
||||||
private saveChats() {
|
private saveChats() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
PublicProfile,
|
PublicProfile,
|
||||||
|
ReportReason,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
MatchmakingState,
|
MatchmakingState,
|
||||||
RewardResult,
|
RewardResult,
|
||||||
@@ -78,6 +79,8 @@ export interface OnlineService {
|
|||||||
declineRequest(id: string): Promise<void>;
|
declineRequest(id: string): Promise<void>;
|
||||||
removeFriend(id: string): Promise<void>;
|
removeFriend(id: string): Promise<void>;
|
||||||
onFriends(cb: (friends: Friend[]) => void): Unsubscribe;
|
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 ----- */
|
/* ----- chat ----- */
|
||||||
listConversations(): Promise<Conversation[]>;
|
listConversations(): Promise<Conversation[]>;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
PublicProfile,
|
PublicProfile,
|
||||||
|
ReportReason,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
MatchmakingState,
|
MatchmakingState,
|
||||||
RewardResult,
|
RewardResult,
|
||||||
@@ -377,6 +378,10 @@ export class SignalrService implements OnlineService {
|
|||||||
async acceptRequest(id: string) { await this.send<unknown>("POST", "/api/friends/accept", { id }); }
|
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 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 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); }
|
onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); }
|
||||||
|
|
||||||
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
|
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export interface UserProfile {
|
|||||||
createdAt: number;
|
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
|
* 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,
|
* row in the leaderboard / friends list. No private fields (coins, phone,
|
||||||
|
|||||||
Reference in New Issue
Block a user