mobile: fullscreen (immersive Android + PWA) + auto-hide reported nudity avatars
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 23s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m3s

Fullscreen on mobile:
- Android (Capacitor): MainActivity now runs edge-to-edge and hides the status +
  navigation bars (immersive, transient-on-swipe), re-asserted on focus.
- PWA: manifest display -> "fullscreen" with display_override fallback chain;
  viewport gains viewport-fit: cover for proper safe-area/edge-to-edge handling.

Moderation auto-hide:
- ProfileService.ReportUser now de-dupes nudity reports per reporter and, once
  NudityHideThreshold (3) distinct players flag a target's avatar as nudity,
  auto-removes their custom photo (reverts to default avatar). Counted from the
  ledger, so still no schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 19:32:49 +03:30
parent 6641741669
commit 857287fa84
4 changed files with 72 additions and 4 deletions
@@ -54,19 +54,52 @@ public class ProfileService
await _db.SaveChangesAsync();
}
/// <summary>Distinct players who must flag an avatar as nudity before it auto-hides.</summary>
public const int NudityHideThreshold = 3;
/// <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}".
/// Ref encodes "{targetId}|{reason}|{details}". Once enough *distinct* players
/// flag a target's avatar as nudity, the custom photo is auto-removed.
/// </summary>
public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details)
{
if (string.IsNullOrWhiteSpace(targetId)) return;
if (string.IsNullOrWhiteSpace(targetId) || targetId == reporterUid) 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];
var nudityPrefix = targetId + "|nudity";
// De-dupe nudity reports so a single player can't nuke an avatar alone.
if (safeReason == "nudity")
{
var already = await _db.Ledger.AnyAsync(
l => l.Kind == "report" && l.UserId == reporterUid && l.Ref!.StartsWith(nudityPrefix));
if (already) return;
}
await Ledger(reporterUid, "report", 0, @ref);
// Auto-hide a custom avatar once enough distinct players flag it.
if (safeReason == "nudity")
{
var reporters = await _db.Ledger
.Where(l => l.Kind == "report" && l.Ref!.StartsWith(nudityPrefix))
.Select(l => l.UserId)
.Distinct()
.CountAsync();
if (reporters >= NudityHideThreshold)
{
var target = await GetOrCreate(targetId, null);
if (!string.IsNullOrEmpty(target.AvatarImage))
{
target.AvatarImage = null; // revert to their default avatar
await SaveInternal(target);
}
}
}
}
public async Task<ProfileDto> Update(string uid, JsonElement patch)