PlotDirector/PlotLine/Services/WritingWorkspacePresenceService.cs

219 lines
9.9 KiB
C#

using System.Collections.Concurrent;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWritingWorkspacePresenceService
{
Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus);
Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus);
Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser);
Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role);
}
public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService
{
private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45);
private readonly ConcurrentDictionary<string, WindowRecord> windowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> windowIdsByConnection = new(StringComparer.Ordinal);
public Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windowId = Clean(registration.BrowserWindowID);
if (windowId is null)
{
windowId = Guid.NewGuid().ToString("N");
}
var record = windowsById.AddOrUpdate(
WindowKey(userId, windowId),
_ => new WindowRecord
{
BrowserWindowID = windowId,
UserID = userId,
ConnectedUtc = now
},
(_, existing) => existing);
Apply(record, connectionId, registration.ProjectID, registration.BookID, registration.CurrentRoute, registration.WritingRole, registration.DisplayLabel, now);
windowIdsByConnection[connectionId] = WindowKey(userId, windowId);
return Task.FromResult(BuildStatus(userId, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus));
}
public Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus)
{
var registration = new WritingWindowRegistration
{
BrowserWindowID = heartbeat.BrowserWindowID,
ProjectID = heartbeat.ProjectID,
BookID = heartbeat.BookID,
CurrentRoute = heartbeat.CurrentRoute,
WritingRole = heartbeat.WritingRole,
DisplayLabel = heartbeat.DisplayLabel
};
return RegisterAsync(userId, connectionId, registration, wordStatus);
}
public Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus)
{
if (!windowIdsByConnection.TryRemove(connectionId, out var key)
|| !windowsById.TryGetValue(key, out var record)
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
{
return Task.FromResult<WritingWorkspaceStatus?>(null);
}
record.IsActive = false;
record.ConnectionID = string.Empty;
var status = BuildStatus(record.UserID, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus ?? new WordCompanionPresenceStatus { UserID = record.UserID });
return Task.FromResult<WritingWorkspaceStatus?>(status);
}
public Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
=> Task.FromResult(BuildStatus(userId, scope, wordStatus));
public async Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser)
{
var now = DateTime.UtcNow;
var changedScopes = new Dictionary<string, (int UserID, int? ProjectID, int? BookID)>(StringComparer.Ordinal);
foreach (var record in windowsById.Values)
{
if (!record.IsActive || now - record.LastSeenUtc <= ActiveTimeout)
{
continue;
}
record.IsActive = false;
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
{
windowIdsByConnection.TryRemove(record.ConnectionID, out _);
record.ConnectionID = string.Empty;
}
changedScopes[ScopeKey(record.UserID, record.ProjectID, record.BookID)] = (record.UserID, record.ProjectID, record.BookID);
}
var statuses = new List<WritingWorkspaceStatus>();
foreach (var scope in changedScopes.Values)
{
var wordStatus = await wordStatusForUser(scope.UserID);
statuses.Add(BuildStatus(scope.UserID, new WritingWorkspaceScope { ProjectID = scope.ProjectID, BookID = scope.BookID }, wordStatus));
}
return statuses;
}
public Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role)
{
var normalRole = WritingWorkspaceRoles.Normalise(role);
var now = DateTime.UtcNow;
var record = ActiveWindows(userId, scope, now)
.Where(window => string.Equals(window.WritingRole, normalRole, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
return Task.FromResult(record?.ConnectionID);
}
private static void Apply(WindowRecord record, string connectionId, int? projectId, int? bookId, string? route, string? role, string? displayLabel, DateTime now)
{
record.ConnectionID = connectionId;
record.ProjectID = Positive(projectId);
record.BookID = Positive(bookId);
record.CurrentRoute = Clean(route) ?? "/";
record.WritingRole = WritingWorkspaceRoles.Normalise(role);
record.DisplayLabel = Clean(displayLabel);
record.IsActive = true;
record.LastSeenUtc = now;
}
private WritingWorkspaceStatus BuildStatus(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windows = ActiveWindows(userId, scope, now)
.Select(ToPresence)
.OrderBy(window => window.WritingRole)
.ThenByDescending(window => window.LastSeenUtc)
.ToList();
return new WritingWorkspaceStatus
{
UserID = userId,
ProjectID = Positive(scope.ProjectID) ?? Positive(wordStatus.LinkedProjectID),
BookID = Positive(scope.BookID) ?? Positive(wordStatus.LinkedBookID),
WordCompanionConnected = wordStatus.IsConnected,
WordDocumentOpen = wordStatus.DocumentOpen,
WordProjectID = wordStatus.LinkedProjectID,
WordBookID = wordStatus.LinkedBookID,
CurrentStoryPosition = wordStatus.CurrentStoryPosition,
Windows = windows,
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
Intent = LatestRole(windows, WritingWorkspaceRoles.Intent)
};
}
private IEnumerable<WindowRecord> ActiveWindows(int userId, WritingWorkspaceScope scope, DateTime now)
{
var projectId = Positive(scope.ProjectID);
var bookId = Positive(scope.BookID);
return windowsById.Values.Where(record =>
record.UserID == userId
&& record.IsActive
&& now - record.LastSeenUtc <= ActiveTimeout
&& (!projectId.HasValue || record.ProjectID == projectId)
&& (!bookId.HasValue || record.BookID == bookId));
}
private static WritingWindowPresence? LatestRole(IReadOnlyList<WritingWindowPresence> windows, string role)
=> windows
.Where(window => string.Equals(window.WritingRole, role, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
private static WritingWindowPresence ToPresence(WindowRecord record)
=> new()
{
BrowserWindowID = record.BrowserWindowID,
UserID = record.UserID,
ProjectID = record.ProjectID,
BookID = record.BookID,
CurrentRoute = record.CurrentRoute,
WritingRole = record.WritingRole,
DisplayLabel = record.DisplayLabel,
IsActive = record.IsActive,
ConnectedUtc = record.ConnectedUtc,
LastSeenUtc = record.LastSeenUtc
};
private static string? Clean(string? value)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
private static int? Positive(int? value)
=> value is > 0 ? value.Value : null;
private static string WindowKey(int userId, string windowId) => $"{userId}:{windowId}";
private static string ScopeKey(int userId, int? projectId, int? bookId) => $"{userId}:{projectId ?? 0}:{bookId ?? 0}";
private sealed class WindowRecord
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public string ConnectionID { get; set; } = string.Empty;
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public string CurrentRoute { get; set; } = "/";
public string WritingRole { get; set; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; set; }
public bool IsActive { get; set; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; set; }
}
}