2af0d8650b
Route normal play closes (end/switch/stop) and all shares through a same-origin HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon for the tab-unload edge. Rename the JS module off telemetry/beacon to session/ lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
84 lines
3.8 KiB
C#
84 lines
3.8 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.AspNetCore.Components;
|
|
|
|
namespace DeepDrftPublic.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
|
|
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
|
|
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
|
|
/// fires the event via the first-party <see cref="IEventPoster"/> POST to the proxied <c>api/event/share</c>
|
|
/// route. A share is always a user-interaction close with the page alive (never a tab-unload), so it uses
|
|
/// the fetch transport unconditionally — there is no <c>sendBeacon</c> arm here (telemetry
|
|
/// transport-resilience).
|
|
///
|
|
/// <para>
|
|
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
|
|
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
|
|
/// The POST is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from the
|
|
/// warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ShareTracker
|
|
{
|
|
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
|
|
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
|
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
|
|
|
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
|
|
// change the integer enum encoding the 16.1 contract relies on.
|
|
private static readonly JsonSerializerOptions EventJson =
|
|
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
|
|
|
private readonly IEventPoster _poster;
|
|
private readonly IAnonIdProvider _anonId;
|
|
private readonly string _shareUrl;
|
|
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
|
|
|
public ShareTracker(IEventPoster poster, IAnonIdProvider anonId, NavigationManager navigation)
|
|
{
|
|
_poster = poster;
|
|
_anonId = anonId;
|
|
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
|
|
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
|
|
/// debounce without reaching into the beacon.
|
|
/// </summary>
|
|
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
|
|
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
|
|
|
|
/// <summary>
|
|
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
|
|
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
|
|
/// </summary>
|
|
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(targetKey))
|
|
return false;
|
|
|
|
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
|
|
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
|
|
return false;
|
|
|
|
_lastSent[dedupeKey] = now;
|
|
|
|
var json = JsonSerializer.Serialize(new ShareEventDto
|
|
{
|
|
TargetType = targetType,
|
|
TargetKey = targetKey,
|
|
Channel = channel,
|
|
AnonId = _anonId.Current,
|
|
}, EventJson);
|
|
|
|
// Fire-and-forget first-party POST — a dropped share telemetry event is acceptable.
|
|
_ = _poster.PostAsync(_shareUrl, json);
|
|
return true;
|
|
}
|
|
}
|