diff --git a/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs b/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs index 56fab82..473fd1b 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Helpers/BlazorTestContextExtensions.cs @@ -17,6 +17,7 @@ public static BunitContext AddFluentUI(this BunitContext ctx) { ctx.JSInterop.Mode = JSRuntimeMode.Loose; ctx.Services.AddFluentUIComponents(); + ctx.Services.AddScoped(); return ctx; } diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 774d266..7699cbb 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -19,6 +19,7 @@ }); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Register server-side AI settings provider (direct database access, unmasked) builder.Services.AddScoped(); diff --git a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj index d0eb9dc..2c68b45 100644 --- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj +++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs b/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs new file mode 100644 index 0000000..1585967 --- /dev/null +++ b/src/NoteBookmark.MauiApp.Tests/UrlLauncherTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using Microsoft.JSInterop; +using Moq; +using NoteBookmark.MauiApp.Data; +using NoteBookmark.SharedUI; +using Xunit; + +namespace NoteBookmark.MauiApp.Tests; + +public class UrlLauncherTests +{ + [Fact] + public async Task JsUrlLauncher_WithNullOrWhitespaceUrl_DoesNotInvokeJs() + { + var jsMock = new Mock(); + var launcher = new JsUrlLauncher(jsMock.Object); + + await launcher.OpenUrlAsync(null); + await launcher.OpenUrlAsync(" "); + + jsMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task JsUrlLauncher_WithValidUrl_InvokesWindowOpen() + { + var jsMock = new Mock(); + jsMock.Setup(x => x.InvokeAsync("open", It.IsAny())) + .ReturnsAsync(null!); + + var launcher = new JsUrlLauncher(jsMock.Object); + + await launcher.OpenUrlAsync("https://example.com"); + + jsMock.Verify(x => x.InvokeAsync("open", It.Is(args => + args.Length == 2 && (string)args[0] == "https://example.com" && (string)args[1] == "_blank" + )), Times.Once); + } + + [Fact] + public async Task MauiUrlLauncher_WithNullOrInvalidUrl_DoesNotThrow() + { + var launcher = new MauiUrlLauncher(); + + var act1 = async () => await launcher.OpenUrlAsync(null); + var act2 = async () => await launcher.OpenUrlAsync("not-a-valid-url"); + var act3 = async () => await launcher.OpenUrlAsync("https://example.com"); + + await act1.Should().NotThrowAsync(); + await act2.Should().NotThrowAsync(); + await act3.Should().NotThrowAsync(); + } +} diff --git a/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs b/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs new file mode 100644 index 0000000..b8fb62f --- /dev/null +++ b/src/NoteBookmark.MauiApp/Data/MauiUrlLauncher.cs @@ -0,0 +1,48 @@ +#if !NOT_MAUI +using Microsoft.Maui.ApplicationModel.DataTransfer; +#endif +using NoteBookmark.SharedUI; + +namespace NoteBookmark.MauiApp.Data; + +public class MauiUrlLauncher : IUrlLauncher +{ +#if !NOT_MAUI + private readonly IBrowser _browser; + + public MauiUrlLauncher(IBrowser? browser = null) + { + _browser = browser ?? Browser.Default; + } +#else + public MauiUrlLauncher() + { + } +#endif + + public async Task OpenUrlAsync(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return; + } + + try + { +#if !NOT_MAUI + await _browser.OpenAsync(uri, BrowserLaunchMode.SystemPreferred); +#else + await Task.CompletedTask; +#endif + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to launch URL in MAUI browser: {ex.Message}"); + } + } +} diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs index 486f68c..3c74ed6 100644 --- a/src/NoteBookmark.MauiApp/MauiProgram.cs +++ b/src/NoteBookmark.MauiApp/MauiProgram.cs @@ -31,6 +31,7 @@ public static MauiHostingApp CreateMauiApp() #endif builder.Services.AddSingleton(Microsoft.Maui.Networking.Connectivity.Current); + builder.Services.AddSingleton(); // Data Layer builder.Services.AddSingleton(); diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor index b451b65..7d375ee 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Posts.razor @@ -9,6 +9,7 @@ @inject IDialogService DialogService @inject NavigationManager Navigation @inject ILocalHtmlCache localHtmlCache +@inject IUrlLauncher urlLauncher @implements IDisposable Posts @@ -130,7 +131,7 @@ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } private async Task CreateNoteForPost(string postId) diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor b/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor index 776e7de..307d729 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Summaries.razor @@ -5,6 +5,7 @@ @inject IDataService client @inject NavigationManager Navigation @inject IJSRuntime jsRuntime +@inject IUrlLauncher urlLauncher Summaries @@ -49,6 +50,6 @@ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor index b620153..ec4fafa 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/SummaryEditor.razor @@ -10,6 +10,7 @@ @inject IJSRuntime jsRuntime @inject IToastService toastService @inject SummaryService aiService +@inject IUrlLauncher urlLauncher

SummaryEditor

@@ -138,7 +139,7 @@ else{ private async Task OpenUrlInNewWindow(string? url) { - await jsRuntime.InvokeVoidAsync("open", url, "_blank"); + await urlLauncher.OpenUrlAsync(url); } private void AddExtraNote(string category) @@ -273,5 +274,4 @@ else{ isGenarating = false; } } - } diff --git a/src/NoteBookmark.SharedUI/IUrlLauncher.cs b/src/NoteBookmark.SharedUI/IUrlLauncher.cs new file mode 100644 index 0000000..b73d036 --- /dev/null +++ b/src/NoteBookmark.SharedUI/IUrlLauncher.cs @@ -0,0 +1,6 @@ +namespace NoteBookmark.SharedUI; + +public interface IUrlLauncher +{ + Task OpenUrlAsync(string? url); +} diff --git a/src/NoteBookmark.SharedUI/JsUrlLauncher.cs b/src/NoteBookmark.SharedUI/JsUrlLauncher.cs new file mode 100644 index 0000000..209d65e --- /dev/null +++ b/src/NoteBookmark.SharedUI/JsUrlLauncher.cs @@ -0,0 +1,30 @@ +using Microsoft.JSInterop; + +namespace NoteBookmark.SharedUI; + +public class JsUrlLauncher : IUrlLauncher +{ + private readonly IJSRuntime _jsRuntime; + + public JsUrlLauncher(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task OpenUrlAsync(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + try + { + await _jsRuntime.InvokeVoidAsync("open", url, "_blank"); + } + catch + { + // Ignore JS interop exceptions when opening URL fails + } + } +}