feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services.
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
public interface IAiGenerateQuizService
|
||||
{
|
||||
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Commands.Sync;
|
||||
|
||||
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
||||
@@ -1,5 +0,0 @@
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
|
||||
namespace NexusReader.Application.Queries.Quiz;
|
||||
|
||||
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
|
||||
@@ -1,20 +0,0 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Messaging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
namespace NexusReader.Application.Queries.Quiz;
|
||||
|
||||
internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler<GetQuizQuestionsQuery, QuizDto>
|
||||
{
|
||||
private readonly IAiGenerateQuizService _aiService;
|
||||
|
||||
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
|
||||
{
|
||||
_aiService = aiService;
|
||||
}
|
||||
|
||||
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,14 @@ public class NexusUser : IdentityUser
|
||||
/// Collection of quiz results completed by the user.
|
||||
/// </summary>
|
||||
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
|
||||
|
||||
/// <summary>
|
||||
/// ID of the last page read by the user.
|
||||
/// </summary>
|
||||
public string? LastReadPageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the last reading progress update.
|
||||
/// </summary>
|
||||
public DateTime? LastReadAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ public static class DependencyInjection
|
||||
}));
|
||||
|
||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
||||
services.AddTransient<IEpubService, EpubService>();
|
||||
|
||||
services.AddAuthorizationCore(options =>
|
||||
@@ -75,6 +74,11 @@ public static class DependencyInjection
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
using NexusReader.Infrastructure.RealTime;
|
||||
|
||||
namespace NexusReader.Infrastructure.Handlers;
|
||||
|
||||
public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReadingProgressCommand, Result>
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IHubContext<SyncHub> _hubContext;
|
||||
|
||||
public UpdateReadingProgressCommandHandler(
|
||||
AppDbContext context,
|
||||
IHubContext<SyncHub> hubContext)
|
||||
{
|
||||
_context = context;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Fail("User not found.");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
user.LastReadPageId = request.PageId;
|
||||
user.LastReadAt = now;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Broadcast to other devices
|
||||
await _hubContext.Clients
|
||||
.Group($"User_{request.UserId}")
|
||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
|
||||
@@ -16,6 +16,12 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
{
|
||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||
entity.Property(u => u.LastReadAt).IsRequired(false);
|
||||
});
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
|
||||
namespace NexusReader.Infrastructure.RealTime;
|
||||
|
||||
[Authorize]
|
||||
public class SyncHub : Hub
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SyncHub(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task UpdateProgress(string pageId)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, $"User_{userId}");
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"User_{userId}");
|
||||
}
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Quiz;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService
|
||||
{
|
||||
public async Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 2000ms delay to highlight Skeleton loader visually
|
||||
await Task.Delay(2000, cancellationToken);
|
||||
|
||||
var fakeQuiz = new List<QuizQuestionDto>
|
||||
{
|
||||
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
|
||||
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
|
||||
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
|
||||
};
|
||||
|
||||
return Result.Ok(new QuizDto(fakeQuiz));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Mobile.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Application;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Maui;
|
||||
|
||||
@@ -39,6 +41,14 @@ public static class MauiProgram
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@inject IReaderNavigationService NavigationService
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject ISyncService SyncService
|
||||
|
||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||
@if (ViewModel == null)
|
||||
@@ -77,6 +78,11 @@
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await SyncService.InitializeAsync();
|
||||
}
|
||||
|
||||
if (ViewModel != null && !_isJsInitialized)
|
||||
{
|
||||
_isJsInitialized = true;
|
||||
@@ -109,6 +115,23 @@
|
||||
public void HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
Coordinator.OnBlockReached(blockId, content);
|
||||
|
||||
// Debounce sync update (simple version: every 5 seconds or on a timer)
|
||||
_ = SyncService.UpdateProgressAsync(blockId);
|
||||
}
|
||||
|
||||
private void HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
{
|
||||
// For now, let's just scroll to the node if it's in the current view,
|
||||
// or just log it. Usually, we should prompt the user.
|
||||
Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}");
|
||||
|
||||
// Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet,
|
||||
// but we can assume incoming syncs are from other active devices)
|
||||
_ = InvokeAsync(async () => {
|
||||
await ScrollToNodeAsync(blockId);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
@@ -196,5 +219,6 @@
|
||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface ISyncService
|
||||
{
|
||||
Task<Result> InitializeAsync();
|
||||
Task<Result> UpdateProgressAsync(string pageId);
|
||||
event Action<string, DateTime> OnProgressReceived;
|
||||
Task DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class SyncService : ISyncService, IAsyncDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly IPlatformService _platformService;
|
||||
private HubConnection? _hubConnection;
|
||||
private bool _isInitialized;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public event Action<string, DateTime>? OnProgressReceived;
|
||||
|
||||
public SyncService(
|
||||
HttpClient httpClient,
|
||||
INativeStorageService storageService,
|
||||
IPlatformService platformService)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_storageService = storageService;
|
||||
_platformService = platformService;
|
||||
}
|
||||
|
||||
public async Task<Result> InitializeAsync()
|
||||
{
|
||||
if (_isInitialized) return Result.Ok();
|
||||
|
||||
var tokenResult = await _storageService.GetSecureString("nexus_auth_token");
|
||||
if (tokenResult.IsFailed) return Result.Fail("Not authenticated");
|
||||
|
||||
var baseUrl = _httpClient.BaseAddress?.ToString() ?? "http://localhost:5000/";
|
||||
var hubUrl = new Uri(new Uri(baseUrl), "synchub").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => Task.FromResult<string?>(tokenResult.Value);
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", (pageId, timestamp) =>
|
||||
{
|
||||
OnProgressReceived?.Invoke(pageId, timestamp);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
_isInitialized = true;
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string? _lastSentPageId;
|
||||
|
||||
public async Task<Result> UpdateProgressAsync(string pageId)
|
||||
{
|
||||
if (pageId == _lastSentPageId) return Result.Ok();
|
||||
|
||||
// Proper trailing-edge debounce
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(2000, token);
|
||||
|
||||
if (!_isInitialized) await InitializeAsync();
|
||||
|
||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
|
||||
_lastSentPageId = pageId;
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}");
|
||||
}
|
||||
}, token);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_debounceCts?.Cancel();
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
await DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Application;
|
||||
using NexusReader.Infrastructure;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
@@ -19,6 +18,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
// Identity & Auth Services
|
||||
builder.Services.AddOptions();
|
||||
@@ -34,6 +34,5 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
||||
builder.Services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -31,6 +31,8 @@ builder.Services.AddServerSideBlazor()
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
});
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
||||
@@ -41,6 +43,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
{
|
||||
@@ -181,6 +184,7 @@ app.UseAntiforgery();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapStaticAssets();
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
// API endpoint for WASM client to fetch EPUB content
|
||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
||||
|
||||
Reference in New Issue
Block a user