Phase 20C Implement Companion detection and communication readiness.
This commit is contained in:
parent
9b1f3e9a8d
commit
6f41605f08
@ -19,6 +19,7 @@ public sealed class WordCompanionHostController(ICurrentUserService currentUser,
|
||||
{
|
||||
IsAuthenticated = currentUser.IsAuthenticated,
|
||||
IsAdmin = isAdmin,
|
||||
UserID = currentUser.UserId,
|
||||
DisplayName = currentUser.DisplayName,
|
||||
LoginUrl = Url.Action("Login", "Account", new { returnUrl = "/word-companion" }) ?? "/Account/Login?returnUrl=%2Fword-companion"
|
||||
});
|
||||
|
||||
@ -1,9 +1,68 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace PlotLine.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class WordCompanionFollowHub : Hub
|
||||
public sealed class WordCompanionFollowHub(IWordCompanionPresenceService presence) : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (TryGetUserId(out var userId))
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public async Task<WordCompanionPresenceStatus> WatchCompanionPresence()
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
|
||||
return await presence.GetStatusAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<WordCompanionPresenceStatus> RegisterCompanion(WordCompanionPresenceRegistration registration)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
if (registration.UserID.HasValue && registration.UserID.Value != userId)
|
||||
{
|
||||
throw new HubException("The Companion is signed in as a different user.");
|
||||
}
|
||||
|
||||
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<WordCompanionPresenceStatus> CompanionHeartbeat(WordCompanionPresenceHeartbeat heartbeat)
|
||||
{
|
||||
var userId = RequireUserId();
|
||||
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
|
||||
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
return status;
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var status = await presence.DisconnectAsync(Context.ConnectionId);
|
||||
if (status is not null)
|
||||
{
|
||||
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private int RequireUserId()
|
||||
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");
|
||||
|
||||
private bool TryGetUserId(out int userId)
|
||||
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
|
||||
|
||||
private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}";
|
||||
}
|
||||
|
||||
44
PlotLine/Models/WordCompanionPresenceModels.cs
Normal file
44
PlotLine/Models/WordCompanionPresenceModels.cs
Normal file
@ -0,0 +1,44 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public static class WordCompanionPresenceStatuses
|
||||
{
|
||||
public const string Connecting = "Connecting";
|
||||
public const string Connected = "Connected";
|
||||
public const string Offline = "Offline";
|
||||
}
|
||||
|
||||
public sealed class WordCompanionPresenceRegistration
|
||||
{
|
||||
public int? UserID { get; init; }
|
||||
public string? CompanionVersion { get; init; }
|
||||
public string? MachineName { get; init; }
|
||||
public bool DocumentOpen { get; init; }
|
||||
public string? CurrentDocumentName { get; init; }
|
||||
public int? LinkedProjectID { get; init; }
|
||||
public int? LinkedBookID { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WordCompanionPresenceHeartbeat
|
||||
{
|
||||
public string? CompanionVersion { get; init; }
|
||||
public string? MachineName { get; init; }
|
||||
public bool DocumentOpen { get; init; }
|
||||
public string? CurrentDocumentName { get; init; }
|
||||
public int? LinkedProjectID { get; init; }
|
||||
public int? LinkedBookID { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WordCompanionPresenceStatus
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
public bool IsConnected { get; init; }
|
||||
public string Status { get; init; } = WordCompanionPresenceStatuses.Offline;
|
||||
public string? CompanionVersion { get; init; }
|
||||
public string? MachineName { get; init; }
|
||||
public bool DocumentOpen { get; init; }
|
||||
public string? CurrentDocumentName { get; init; }
|
||||
public int? LinkedProjectID { get; init; }
|
||||
public int? LinkedBookID { get; init; }
|
||||
public DateTime? ConnectedUtc { get; init; }
|
||||
public DateTime? LastHeartbeatUtc { get; init; }
|
||||
}
|
||||
@ -170,6 +170,8 @@ public class Program
|
||||
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
|
||||
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
|
||||
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
|
||||
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
|
||||
@ -26,6 +26,7 @@ public sealed class OnboardingService(
|
||||
IBookRepository books,
|
||||
IBookService bookService,
|
||||
IProjectActivityService activity,
|
||||
IWordCompanionPresenceService companionPresence,
|
||||
ICurrentUserService currentUser) : IOnboardingService
|
||||
{
|
||||
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
||||
@ -62,6 +63,7 @@ public sealed class OnboardingService(
|
||||
var selectedBook = state.BookID.HasValue
|
||||
? await books.GetAsync(state.BookID.Value)
|
||||
: null;
|
||||
var presence = await companionPresence.GetStatusAsync(RequireUserId());
|
||||
|
||||
return new OnboardingWizardViewModel
|
||||
{
|
||||
@ -73,6 +75,7 @@ public sealed class OnboardingService(
|
||||
BookID = state.BookID,
|
||||
SelectedProjectName = selectedProject?.ProjectName,
|
||||
SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook),
|
||||
CompanionPresence = ToPresenceViewModel(presence),
|
||||
ProjectOptions = projectOptions,
|
||||
BookOptions = bookOptions,
|
||||
ProjectForm = new ProjectOnboardingForm
|
||||
@ -113,6 +116,22 @@ public sealed class OnboardingService(
|
||||
}
|
||||
|
||||
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
||||
if (state is not null && IsMicrosoftWordState(state))
|
||||
{
|
||||
var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value);
|
||||
return new OnboardingNudgeViewModel
|
||||
{
|
||||
ShouldShow = true,
|
||||
IsCompanionPresence = true,
|
||||
CompanionConnected = status.IsConnected,
|
||||
Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion",
|
||||
Description = status.IsConnected
|
||||
? "Your Word Companion is ready for this story setup."
|
||||
: "Open Word, start the Companion, and sign in with your PlotDirector account.",
|
||||
ButtonText = status.IsConnected ? "View setup" : "Connect Companion"
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.OnboardingCompletedUtc is not null)
|
||||
{
|
||||
return new OnboardingNudgeViewModel { ShouldShow = false };
|
||||
@ -137,8 +156,15 @@ public sealed class OnboardingService(
|
||||
}
|
||||
|
||||
public async Task<bool> ShouldShowWordCompanionHeaderAsync()
|
||||
=> currentUser.UserId.HasValue
|
||||
&& await onboarding.ShouldShowWordCompanionHeaderAsync(currentUser.UserId.Value);
|
||||
{
|
||||
if (!currentUser.UserId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
||||
return state is not null && IsMicrosoftWordState(state);
|
||||
}
|
||||
|
||||
public async Task MoveToNextStepAsync()
|
||||
{
|
||||
@ -391,6 +417,20 @@ public sealed class OnboardingService(
|
||||
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
|
||||
private static bool IsMicrosoftWordState(UserOnboardingState state)
|
||||
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|
||||
|| state.UsesWordCompanion == true;
|
||||
|
||||
private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status)
|
||||
=> new()
|
||||
{
|
||||
IsConnected = status.IsConnected,
|
||||
Status = status.Status,
|
||||
CompanionVersion = status.CompanionVersion,
|
||||
CurrentDocumentName = status.CurrentDocumentName,
|
||||
LastHeartbeatUtc = status.LastHeartbeatUtc
|
||||
};
|
||||
|
||||
private static string? CleanOptional(string? value)
|
||||
{
|
||||
var cleaned = Clean(value);
|
||||
|
||||
32
PlotLine/Services/WordCompanionPresenceMonitor.cs
Normal file
32
PlotLine/Services/WordCompanionPresenceMonitor.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PlotLine.Hubs;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class WordCompanionPresenceMonitor(
|
||||
IWordCompanionPresenceService presence,
|
||||
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
var offlineStatuses = await presence.MarkStaleOfflineAsync();
|
||||
foreach (var status in offlineStatuses)
|
||||
{
|
||||
await hub.Clients
|
||||
.Group(PresenceGroup(status.UserID))
|
||||
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}";
|
||||
}
|
||||
173
PlotLine/Services/WordCompanionPresenceService.cs
Normal file
173
PlotLine/Services/WordCompanionPresenceService.cs
Normal file
@ -0,0 +1,173 @@
|
||||
using System.Collections.Concurrent;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IWordCompanionPresenceService
|
||||
{
|
||||
Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration);
|
||||
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
|
||||
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
|
||||
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
||||
Task<bool> IsConnectedAsync(int userId);
|
||||
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
|
||||
}
|
||||
|
||||
public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
||||
{
|
||||
private static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(55);
|
||||
private readonly ConcurrentDictionary<int, PresenceRecord> recordsByUser = new();
|
||||
private readonly ConcurrentDictionary<string, int> usersByConnection = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var record = new PresenceRecord
|
||||
{
|
||||
UserID = userId,
|
||||
ConnectionID = connectionId,
|
||||
Status = WordCompanionPresenceStatuses.Connected,
|
||||
CompanionVersion = Clean(registration.CompanionVersion),
|
||||
MachineName = Clean(registration.MachineName),
|
||||
DocumentOpen = registration.DocumentOpen,
|
||||
CurrentDocumentName = Clean(registration.CurrentDocumentName),
|
||||
LinkedProjectID = Positive(registration.LinkedProjectID),
|
||||
LinkedBookID = Positive(registration.LinkedBookID),
|
||||
ConnectedUtc = now,
|
||||
LastHeartbeatUtc = now
|
||||
};
|
||||
|
||||
recordsByUser.AddOrUpdate(userId, record, (_, _) => record);
|
||||
usersByConnection[connectionId] = userId;
|
||||
return Task.FromResult(ToStatus(record, now));
|
||||
}
|
||||
|
||||
public Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var record = recordsByUser.AddOrUpdate(
|
||||
userId,
|
||||
_ => new PresenceRecord
|
||||
{
|
||||
UserID = userId,
|
||||
ConnectionID = connectionId,
|
||||
ConnectedUtc = now
|
||||
},
|
||||
(_, existing) =>
|
||||
{
|
||||
existing.ConnectionID = connectionId;
|
||||
return existing;
|
||||
});
|
||||
|
||||
record.Status = WordCompanionPresenceStatuses.Connected;
|
||||
record.CompanionVersion = Clean(heartbeat.CompanionVersion) ?? record.CompanionVersion;
|
||||
record.MachineName = Clean(heartbeat.MachineName) ?? record.MachineName;
|
||||
record.DocumentOpen = heartbeat.DocumentOpen;
|
||||
record.CurrentDocumentName = Clean(heartbeat.CurrentDocumentName);
|
||||
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
|
||||
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
|
||||
record.LastHeartbeatUtc = now;
|
||||
|
||||
usersByConnection[connectionId] = userId;
|
||||
return Task.FromResult(ToStatus(record, now));
|
||||
}
|
||||
|
||||
public Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId)
|
||||
{
|
||||
if (!usersByConnection.TryRemove(connectionId, out var userId)
|
||||
|| !recordsByUser.TryGetValue(userId, out var record)
|
||||
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<WordCompanionPresenceStatus?>(null);
|
||||
}
|
||||
|
||||
record.Status = WordCompanionPresenceStatuses.Offline;
|
||||
record.ConnectionID = string.Empty;
|
||||
return Task.FromResult<WordCompanionPresenceStatus?>(ToStatus(record, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
public Task<WordCompanionPresenceStatus> GetStatusAsync(int userId)
|
||||
{
|
||||
if (!recordsByUser.TryGetValue(userId, out var record))
|
||||
{
|
||||
return Task.FromResult(new WordCompanionPresenceStatus { UserID = userId });
|
||||
}
|
||||
|
||||
return Task.FromResult(ToStatus(record, DateTime.UtcNow));
|
||||
}
|
||||
|
||||
public async Task<bool> IsConnectedAsync(int userId)
|
||||
=> (await GetStatusAsync(userId)).IsConnected;
|
||||
|
||||
public Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var changed = new List<WordCompanionPresenceStatus>();
|
||||
foreach (var record in recordsByUser.Values)
|
||||
{
|
||||
if (!string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
|
||||
|| !record.LastHeartbeatUtc.HasValue
|
||||
|| now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
record.Status = WordCompanionPresenceStatuses.Offline;
|
||||
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
|
||||
{
|
||||
usersByConnection.TryRemove(record.ConnectionID, out _);
|
||||
record.ConnectionID = string.Empty;
|
||||
}
|
||||
|
||||
changed.Add(ToStatus(record, now));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WordCompanionPresenceStatus>>(changed);
|
||||
}
|
||||
|
||||
private static WordCompanionPresenceStatus ToStatus(PresenceRecord record, DateTime now)
|
||||
{
|
||||
var connected = string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
|
||||
&& record.LastHeartbeatUtc.HasValue
|
||||
&& now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout;
|
||||
|
||||
return new WordCompanionPresenceStatus
|
||||
{
|
||||
UserID = record.UserID,
|
||||
IsConnected = connected,
|
||||
Status = connected ? WordCompanionPresenceStatuses.Connected : WordCompanionPresenceStatuses.Offline,
|
||||
CompanionVersion = record.CompanionVersion,
|
||||
MachineName = record.MachineName,
|
||||
DocumentOpen = record.DocumentOpen,
|
||||
CurrentDocumentName = record.CurrentDocumentName,
|
||||
LinkedProjectID = record.LinkedProjectID,
|
||||
LinkedBookID = record.LinkedBookID,
|
||||
ConnectedUtc = record.ConnectedUtc,
|
||||
LastHeartbeatUtc = record.LastHeartbeatUtc
|
||||
};
|
||||
}
|
||||
|
||||
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 sealed class PresenceRecord
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
public string ConnectionID { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = WordCompanionPresenceStatuses.Offline;
|
||||
public string? CompanionVersion { get; set; }
|
||||
public string? MachineName { get; set; }
|
||||
public bool DocumentOpen { get; set; }
|
||||
public string? CurrentDocumentName { get; set; }
|
||||
public int? LinkedProjectID { get; set; }
|
||||
public int? LinkedBookID { get; set; }
|
||||
public DateTime? ConnectedUtc { get; set; }
|
||||
public DateTime? LastHeartbeatUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ public sealed class OnboardingWizardViewModel
|
||||
public string? SelectedProjectName { get; set; }
|
||||
public string? SelectedBookTitle { get; set; }
|
||||
public bool IsMicrosoftWordPath => string.Equals(WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal);
|
||||
public CompanionPresenceViewModel CompanionPresence { get; set; } = new();
|
||||
public IReadOnlyList<OnboardingOptionViewModel> Options { get; set; } = [];
|
||||
public IReadOnlyList<OnboardingProjectOptionViewModel> ProjectOptions { get; set; } = [];
|
||||
public IReadOnlyList<OnboardingBookOptionViewModel> BookOptions { get; set; } = [];
|
||||
@ -98,4 +99,15 @@ public sealed class OnboardingNudgeViewModel
|
||||
public string ButtonText { get; init; } = "Start setup";
|
||||
public string Title { get; init; } = "Set up PlotDirector around the way you write";
|
||||
public string Description { get; init; } = "Answer a few quick questions so PlotDirector can guide your first project.";
|
||||
public bool IsCompanionPresence { get; init; }
|
||||
public bool CompanionConnected { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CompanionPresenceViewModel
|
||||
{
|
||||
public bool IsConnected { get; init; }
|
||||
public string Status { get; init; } = "Offline";
|
||||
public string? CompanionVersion { get; init; }
|
||||
public string? CurrentDocumentName { get; init; }
|
||||
public DateTime? LastHeartbeatUtc { get; init; }
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ public sealed class WordCompanionHostViewModel
|
||||
{
|
||||
public bool IsAuthenticated { get; init; }
|
||||
public bool IsAdmin { get; init; }
|
||||
public int? UserID { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public string LoginUrl { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@ -236,8 +236,8 @@
|
||||
<p class="eyebrow">Ready for the next phase</p>
|
||||
@if (Model.IsMicrosoftWordPath)
|
||||
{
|
||||
<h1 id="onboarding-title">Next: connect the Word Companion</h1>
|
||||
<p>In the next phase, PlotDirector will guide you through opening the Word Companion and linking it to @Model.SelectedBookTitle.</p>
|
||||
<h1 id="onboarding-title">Connect your Word Companion</h1>
|
||||
<p>Open Microsoft Word, open your manuscript, start the PlotDirector Word Companion, and sign in using your PlotDirector account.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -245,6 +245,36 @@
|
||||
<p>In a later phase, PlotDirector will let you upload a .docx export from your writing tool and connect it to @Model.SelectedBookTitle.</p>
|
||||
}
|
||||
</div>
|
||||
@if (Model.IsMicrosoftWordPath)
|
||||
{
|
||||
<section class="onboarding-companion-card @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
|
||||
data-word-companion-presence
|
||||
data-word-companion-status="@Model.CompanionPresence.Status"
|
||||
aria-label="Word Companion connection">
|
||||
<div class="onboarding-companion-mark" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="onboarding-companion-body">
|
||||
<p class="eyebrow">Word Companion</p>
|
||||
<h2 data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")</h2>
|
||||
<p data-word-companion-connected-copy>
|
||||
@(Model.CompanionPresence.IsConnected
|
||||
? "Your Companion is connected. Continue when you are ready."
|
||||
: "Once connected this page will update automatically. You stay in control before continuing.")
|
||||
</p>
|
||||
<dl class="onboarding-companion-details">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd data-word-companion-version>@(Model.CompanionPresence.CompanionVersion ?? "Waiting")</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Document</dt>
|
||||
<dd data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<dl class="onboarding-summary">
|
||||
<div>
|
||||
<dt>Writing journey</dt>
|
||||
@ -272,7 +302,7 @@
|
||||
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Go to Project Overview</a>
|
||||
}
|
||||
<form asp-controller="Onboarding" asp-action="Finish" method="post">
|
||||
<button class="btn btn-primary" type="submit">Finish setup for now</button>
|
||||
<button class="btn btn-primary" type="submit">@(Model.IsMicrosoftWordPath ? "Continue" : "Finish setup for now")</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -35,11 +35,13 @@
|
||||
|
||||
@if (Model.OnboardingNudge.ShouldShow)
|
||||
{
|
||||
<section class="onboarding-dashboard-nudge" aria-label="PlotDirector setup">
|
||||
<section class="onboarding-dashboard-nudge @(Model.OnboardingNudge.CompanionConnected ? "is-connected" : string.Empty)"
|
||||
data-word-companion-presence="@(Model.OnboardingNudge.IsCompanionPresence.ToString().ToLowerInvariant())"
|
||||
aria-label="PlotDirector setup">
|
||||
<div>
|
||||
<p class="eyebrow">First setup</p>
|
||||
<h2>Set up PlotDirector around the way you write</h2>
|
||||
<p>Answer a few quick questions so PlotDirector can guide your first project.</p>
|
||||
<h2 data-word-companion-dashboard-title>@Model.OnboardingNudge.Title</h2>
|
||||
<p data-word-companion-dashboard-description>@Model.OnboardingNudge.Description</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.OnboardingNudge.ButtonText</a>
|
||||
</section>
|
||||
|
||||
@ -158,6 +158,13 @@
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
|
||||
@if (showWordCompanionHeader)
|
||||
{
|
||||
<span class="word-companion-presence-pill"
|
||||
data-word-companion-presence
|
||||
data-word-companion-status="Offline"
|
||||
title="Word Companion status">
|
||||
<span class="word-companion-presence-dot" data-word-companion-status-dot></span>
|
||||
<span data-word-companion-status-label>Offline</span>
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm word-companion-follow-toggle" type="button" data-word-companion-follow-toggle aria-pressed="false">
|
||||
Follow Word Companion
|
||||
</button>
|
||||
@ -312,6 +319,10 @@
|
||||
<environment exclude="Production">
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
</environment>
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<script src="~/js/word-companion-presence.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
|
||||
@ -13,7 +13,11 @@
|
||||
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="word-companion-shell" data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()" data-admin="@Model.IsAdmin.ToString().ToLowerInvariant()">
|
||||
<main class="word-companion-shell"
|
||||
data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()"
|
||||
data-admin="@Model.IsAdmin.ToString().ToLowerInvariant()"
|
||||
data-user-id="@Model.UserID"
|
||||
data-companion-version="20C">
|
||||
<header class="word-companion-header">
|
||||
<div>
|
||||
<h1>PlotDirector</h1>
|
||||
@ -546,6 +550,10 @@
|
||||
</dialog>
|
||||
}
|
||||
</main>
|
||||
@if (Model.IsAuthenticated)
|
||||
{
|
||||
<script src="https://cdn.jsdelivr.net/npm/@@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"></script>
|
||||
}
|
||||
<script src="~/js/word-companion-host.js" asp-append-version="true"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -151,6 +151,98 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-companion-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1.1rem;
|
||||
align-items: center;
|
||||
margin: 1rem 0 1.25rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid rgba(31, 42, 68, .12);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(182, 138, 70, .12));
|
||||
}
|
||||
|
||||
.onboarding-companion-mark {
|
||||
position: relative;
|
||||
width: 4.25rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: rgba(47, 111, 99, .12);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.onboarding-companion-mark::before,
|
||||
.onboarding-companion-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: .55rem;
|
||||
border: 2px solid rgba(47, 111, 99, .28);
|
||||
border-radius: 50%;
|
||||
animation: onboardingPulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.onboarding-companion-mark::after {
|
||||
animation-delay: .6s;
|
||||
}
|
||||
|
||||
.onboarding-companion-mark span {
|
||||
width: 1.25rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #b68a46;
|
||||
box-shadow: 0 0 0 .45rem rgba(182, 138, 70, .18);
|
||||
}
|
||||
|
||||
.onboarding-companion-card.is-connected .onboarding-companion-mark span,
|
||||
.word-companion-presence-pill.is-connected .word-companion-presence-dot {
|
||||
background: #2f6f63;
|
||||
}
|
||||
|
||||
.onboarding-companion-card.is-connected .onboarding-companion-mark::before,
|
||||
.onboarding-companion-card.is-connected .onboarding-companion-mark::after {
|
||||
animation-play-state: paused;
|
||||
border-color: rgba(47, 111, 99, .18);
|
||||
}
|
||||
|
||||
.onboarding-companion-body h2 {
|
||||
margin: 0 0 .35rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.onboarding-companion-body p:not(.eyebrow) {
|
||||
margin: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.onboarding-companion-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.onboarding-companion-details div {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(31, 42, 68, .1);
|
||||
border-radius: 8px;
|
||||
padding: .75rem;
|
||||
background: rgba(255, 255, 255, .56);
|
||||
}
|
||||
|
||||
.onboarding-companion-details dt {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.onboarding-companion-details dd {
|
||||
margin: .15rem 0 0;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.onboarding-summary div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -192,6 +284,47 @@
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.onboarding-dashboard-nudge.is-connected {
|
||||
border-color: rgba(47, 111, 99, .28);
|
||||
background: rgba(47, 111, 99, .12);
|
||||
}
|
||||
|
||||
.word-companion-presence-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
min-height: 31px;
|
||||
border: 1px solid rgba(31, 42, 68, .16);
|
||||
border-radius: 999px;
|
||||
padding: .25rem .7rem;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
color: var(--bs-body-color);
|
||||
font-size: .875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.word-companion-presence-dot {
|
||||
width: .58rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #b68a46;
|
||||
}
|
||||
|
||||
.word-companion-presence-pill.is-offline .word-companion-presence-dot {
|
||||
background: #8b949e;
|
||||
}
|
||||
|
||||
@keyframes onboardingPulse {
|
||||
0% {
|
||||
opacity: .75;
|
||||
transform: scale(.76);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.28);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.onboarding-choice-grid,
|
||||
.onboarding-option-grid--software {
|
||||
@ -199,8 +332,14 @@
|
||||
}
|
||||
|
||||
.onboarding-dashboard-nudge,
|
||||
.onboarding-companion-card,
|
||||
.onboarding-summary div {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.onboarding-companion-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,6 +165,8 @@
|
||||
const diagnosticHeading1 = document.querySelector("[data-diagnostic-heading1]");
|
||||
const diagnosticHeading2 = document.querySelector("[data-diagnostic-heading2]");
|
||||
const isAuthenticated = shell?.dataset.authenticated === "true";
|
||||
const userId = Number.parseInt(shell?.dataset.userId || "", 10);
|
||||
const companionVersion = String(shell?.dataset.companionVersion || "20C").trim();
|
||||
let projects = [];
|
||||
let books = [];
|
||||
let officeReady = false;
|
||||
@ -242,6 +244,8 @@
|
||||
let lastLinkedSceneTitle = "";
|
||||
let lastLinkedChapterTitle = "";
|
||||
let runtimeLastDebugAt = 0;
|
||||
let companionPresenceConnection = null;
|
||||
let companionPresenceHeartbeatTimer = null;
|
||||
let currentSceneLinks = {
|
||||
characters: [],
|
||||
assets: [],
|
||||
@ -1182,6 +1186,93 @@
|
||||
return books.find((book) => book.bookId === bookId) || null;
|
||||
};
|
||||
|
||||
const selectedPresenceProjectId = () => {
|
||||
const projectId = firstRunDocumentBinding?.projectId
|
||||
|| selectedFirstRunProject()?.projectId
|
||||
|| selectedProject()?.projectId
|
||||
|| readStoredId(storageKeys.projectId);
|
||||
return Number.isInteger(projectId) && projectId > 0 ? projectId : null;
|
||||
};
|
||||
|
||||
const selectedPresenceBookId = () => {
|
||||
const bookId = firstRunDocumentBinding?.bookId
|
||||
|| selectedFirstRunBook()?.bookId
|
||||
|| selectedBook()?.bookId
|
||||
|| readStoredId(storageKeys.bookId);
|
||||
return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
|
||||
};
|
||||
|
||||
const currentDocumentName = () => {
|
||||
const url = String(window.Office?.context?.document?.url || "").trim();
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lastSegment = url.split(/[\\/]/).filter(Boolean).pop() || "";
|
||||
try {
|
||||
return decodeURIComponent(lastSegment);
|
||||
} catch {
|
||||
return lastSegment;
|
||||
}
|
||||
};
|
||||
|
||||
const companionPresencePayload = () => ({
|
||||
userID: Number.isInteger(userId) && userId > 0 ? userId : null,
|
||||
companionVersion,
|
||||
machineName: "",
|
||||
documentOpen: !!wordHostAvailable,
|
||||
currentDocumentName: currentDocumentName(),
|
||||
linkedProjectID: selectedPresenceProjectId(),
|
||||
linkedBookID: selectedPresenceBookId()
|
||||
});
|
||||
|
||||
const sendCompanionHeartbeat = async () => {
|
||||
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await companionPresenceConnection.invoke("CompanionHeartbeat", companionPresencePayload());
|
||||
};
|
||||
|
||||
const refreshCompanionPresence = () => {
|
||||
sendCompanionHeartbeat().catch((error) => {
|
||||
console.debug("Word Companion heartbeat unavailable.", error);
|
||||
});
|
||||
};
|
||||
|
||||
const startCompanionPresence = async () => {
|
||||
if (!isAuthenticated || !window.signalR || companionPresenceConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
companionPresenceConnection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/word-companion-follow")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
companionPresenceConnection.onreconnected(refreshCompanionPresence);
|
||||
companionPresenceConnection.onclose(() => {
|
||||
if (companionPresenceHeartbeatTimer) {
|
||||
window.clearInterval(companionPresenceHeartbeatTimer);
|
||||
companionPresenceHeartbeatTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
await companionPresenceConnection.start();
|
||||
await companionPresenceConnection.invoke("RegisterCompanion", companionPresencePayload());
|
||||
companionPresenceHeartbeatTimer = window.setInterval(refreshCompanionPresence, 25000);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (companionPresenceHeartbeatTimer) {
|
||||
window.clearInterval(companionPresenceHeartbeatTimer);
|
||||
companionPresenceHeartbeatTimer = null;
|
||||
}
|
||||
if (companionPresenceConnection) {
|
||||
companionPresenceConnection.stop().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const setFirstRunStatus = (message) => setText(firstRunStatus, message);
|
||||
|
||||
const updateFirstRunButtons = () => {
|
||||
@ -5177,6 +5268,7 @@
|
||||
resetStructurePreview();
|
||||
resetFirstRunPreview("Select a Book to begin.");
|
||||
await loadBooks(projectId, null);
|
||||
refreshCompanionPresence();
|
||||
});
|
||||
|
||||
bookSelect?.addEventListener("change", () => {
|
||||
@ -5194,6 +5286,7 @@
|
||||
resetFirstRunPreview(selectedFirstRunBook()
|
||||
? "Ready to scan manuscript structure."
|
||||
: "Select a Book to begin.");
|
||||
refreshCompanionPresence();
|
||||
});
|
||||
|
||||
firstRunProjectSelect?.addEventListener("change", async () => {
|
||||
@ -5206,6 +5299,7 @@
|
||||
writeStoredId(storageKeys.bookId, null);
|
||||
resetFirstRunPreview("Select a Book to begin.");
|
||||
await loadBooks(projectId, null);
|
||||
refreshCompanionPresence();
|
||||
});
|
||||
|
||||
firstRunBookSelect?.addEventListener("change", () => {
|
||||
@ -5219,6 +5313,7 @@
|
||||
resetFirstRunPreview(selectedFirstRunBook()
|
||||
? "Ready to scan manuscript structure."
|
||||
: "Select a Book to begin.");
|
||||
refreshCompanionPresence();
|
||||
});
|
||||
|
||||
firstRunNewBookTitle?.addEventListener("input", updateFirstRunButtons);
|
||||
@ -5320,6 +5415,9 @@
|
||||
ready: "No",
|
||||
wordApi: "No"
|
||||
});
|
||||
startCompanionPresence().catch((error) => {
|
||||
console.debug("Word Companion presence unavailable.", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5328,6 +5426,7 @@
|
||||
setOfficeStatus("Word Companion ready");
|
||||
await projectsLoadPromise;
|
||||
await initialiseFirstRunBinding();
|
||||
await startCompanionPresence();
|
||||
if (!wordHostAvailable) {
|
||||
setDocumentMessage("Word document access is unavailable.");
|
||||
setSceneTrackingState("Manual");
|
||||
|
||||
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
File diff suppressed because one or more lines are too long
80
PlotLine/wwwroot/js/word-companion-presence.js
Normal file
80
PlotLine/wwwroot/js/word-companion-presence.js
Normal file
@ -0,0 +1,80 @@
|
||||
(() => {
|
||||
const roots = [...document.querySelectorAll("[data-word-companion-presence]")]
|
||||
.filter((node) => node.dataset.wordCompanionPresence !== "false");
|
||||
if (roots.length === 0 || !window.signalR) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelFor = (status) => (status?.isConnected || status?.IsConnected ? "Connected" : status?.status || status?.Status || "Offline");
|
||||
const field = (status, camel, pascal) => status?.[camel] ?? status?.[pascal] ?? null;
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) {
|
||||
return "No heartbeat yet";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "No heartbeat yet" : `Last heartbeat ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
};
|
||||
|
||||
const applyStatus = (status) => {
|
||||
const connected = !!field(status, "isConnected", "IsConnected");
|
||||
const version = field(status, "companionVersion", "CompanionVersion") || "Waiting";
|
||||
const documentName = field(status, "currentDocumentName", "CurrentDocumentName") || "No document reported yet";
|
||||
const label = connected ? "Connected" : labelFor(status);
|
||||
const title = [
|
||||
`Word Companion: ${label}`,
|
||||
version && version !== "Waiting" ? `Version ${version}` : "",
|
||||
documentName && documentName !== "No document reported yet" ? documentName : "",
|
||||
formatTime(field(status, "lastHeartbeatUtc", "LastHeartbeatUtc"))
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
for (const root of roots) {
|
||||
root.classList.toggle("is-connected", connected);
|
||||
root.classList.toggle("is-offline", !connected);
|
||||
root.dataset.wordCompanionStatus = label;
|
||||
root.title = title;
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-word-companion-status-label]").forEach((node) => {
|
||||
node.textContent = label;
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-waiting-status]").forEach((node) => {
|
||||
node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection...";
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-connected-copy]").forEach((node) => {
|
||||
node.textContent = connected
|
||||
? "Your Companion is connected. Continue when you are ready."
|
||||
: "Open Word and start the Companion. This page will update automatically.";
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-version]").forEach((node) => {
|
||||
node.textContent = version;
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-document]").forEach((node) => {
|
||||
node.textContent = documentName;
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-dashboard-title]").forEach((node) => {
|
||||
node.textContent = connected ? "Word Companion Connected" : "Connect your Word Companion";
|
||||
});
|
||||
document.querySelectorAll("[data-word-companion-dashboard-description]").forEach((node) => {
|
||||
node.textContent = connected
|
||||
? "Your Word Companion is ready for this story setup."
|
||||
: "Open Word, start the Companion, and sign in with your PlotDirector account.";
|
||||
});
|
||||
};
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/hubs/word-companion-follow")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on("WordCompanionPresenceChanged", applyStatus);
|
||||
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
|
||||
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
|
||||
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
|
||||
|
||||
connection.start()
|
||||
.then(() => connection.invoke("WatchCompanionPresence"))
|
||||
.then(applyStatus)
|
||||
.catch(() => applyStatus({ status: "Offline", isConnected: false }));
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user