219 lines
9.9 KiB
C#
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; }
|
|
}
|
|
}
|