using System.Text.Json; using System.Text.Json.Serialization; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using Microsoft.AspNetCore.Components; namespace DeepDrftPublic.Client.Services; /// /// Records share events from SharePopover (Phase 16 §1b / §2.1). After a successful clipboard /// write the popover calls ; this tracker applies the per-(target,channel) /// debounce — at most one event per target+channel per per session — and /// fires the event via navigator.sendBeacon to the proxied api/event/share route. /// /// /// 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 beacon send is fire-and-forget; the current anonId (wave 16.3) is read synchronously from /// the warmed cache and omitted when null. /// /// 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 BeaconJson = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly BeaconInterop _beacon; private readonly IAnonIdProvider _anonId; private readonly string _shareUrl; private readonly Dictionary _lastSent = new(); public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation) { _beacon = beacon; _anonId = anonId; _shareUrl = $"{navigation.BaseUri}api/event/share"; } /// /// 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. /// public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel) => RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow); /// /// Debounce-aware record with an injectable so the 60s window is testable /// without wall-clock waits. The parameterless overload above passes . /// 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, }, BeaconJson); // Fire-and-forget — a dropped share telemetry event is acceptable. _ = _beacon.SendAsync(_shareUrl, json); return true; } }