diff --git a/docs/news.md b/docs/news.md
new file mode 100644
index 0000000..b9e55b0
--- /dev/null
+++ b/docs/news.md
@@ -0,0 +1,3 @@
+# News & Updates
+
+Welcome to NoteBookmark! Check back here for product updates.
diff --git a/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs
new file mode 100644
index 0000000..d204146
--- /dev/null
+++ b/src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs
@@ -0,0 +1,185 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using NoteBookmark.MauiApp.Data;
+
+namespace NoteBookmark.MauiApp.Tests;
+
+public sealed class NewsFeedServiceTests : IDisposable
+{
+ private readonly string _appDataDirectory = Path.Combine(Path.GetTempPath(), $"NoteBookmark-News-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task RefreshAsync_CachesMarkdownAndReferencedImagesForOfflineRendering()
+ {
+ var handler = new StubHttpMessageHandler(request =>
+ {
+ if (request.RequestUri!.AbsolutePath.EndsWith("/docs/news.md", StringComparison.Ordinal))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("# Updates\n\n\n\n[Unsafe](javascript:alert(1))", Encoding.UTF8, "text/plain")
+ };
+ }
+
+ if (request.RequestUri.AbsolutePath.EndsWith("/docs/assets/logo.png", StringComparison.Ordinal))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
+ };
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+ var service = CreateService(handler);
+
+ await service.RefreshAsync();
+ var html = await service.GetHtmlAsync();
+
+ html.Should().Contain("
Updates
");
+ html.Should().Contain("src=\"data:image/png;base64,AQID\"");
+ html.Should().Contain("href=\"#\"");
+ File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
+ File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetHtmlAsync_WhenNoFeedIsCached_LoadsRemoteFeed()
+ {
+ var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("# Online feed", Encoding.UTF8, "text/plain")
+ });
+ var service = CreateService(handler);
+
+ (await service.GetHtmlAsync()).Should().Contain("Online feed
");
+ File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task RefreshAsync_WhenRemoteRefreshFails_KeepsCachedFeed()
+ {
+ var requestCount = 0;
+ var handler = new StubHttpMessageHandler(_ =>
+ {
+ requestCount++;
+ if (requestCount == 1)
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("# Existing feed", Encoding.UTF8, "text/plain")
+ };
+ }
+
+ throw new HttpRequestException("Network unavailable.");
+ });
+ var service = CreateService(handler);
+ await service.RefreshAsync();
+
+ var act = () => service.RefreshAsync();
+ await act.Should().ThrowAsync();
+ (await service.GetHtmlAsync()).Should().Contain("Existing feed
");
+ }
+
+ [Fact]
+ public async Task GetHtmlAsync_WhenNoFeedIsCached_ReturnsFriendlyFallback()
+ {
+ var service = CreateService(new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
+
+ (await service.GetHtmlAsync()).Should().Contain("Welcome to NoteBookmark");
+ }
+
+ [Fact]
+ public async Task RefreshAsync_CachesReferenceStyleImagesAndPathsWithParentheses()
+ {
+ var service = CreateService(CreateFeedHandler(
+ "# Updates\n\n![Logo][logo]\n\n.png)\n\n[logo]: assets/logo.png",
+ "/docs/assets/logo.png",
+ "/docs/assets/chart%281%29.png"));
+
+ await service.RefreshAsync();
+ var html = await service.GetHtmlAsync();
+
+ html.Should().NotContain("assets/");
+ html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Logo\"");
+ html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Chart\"");
+ File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
+ File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "chart(1).png")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task RefreshAsync_IgnoresImageSyntaxInsideCodeBlocks()
+ {
+ var service = CreateService(CreateFeedHandler(
+ "# Updates\n\n```markdown\n\n```\n\n``"));
+
+ await service.RefreshAsync();
+ var html = await service.GetHtmlAsync();
+
+ html.Should().Contain("");
+ Directory.Exists(Path.Combine(_appDataDirectory, "docs", "assets")).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task RefreshAsync_RaisesFeedUpdatedOnlyAfterSuccessfulRefresh()
+ {
+ var fail = false;
+ var handler = new StubHttpMessageHandler(_ => fail
+ ? throw new HttpRequestException("Network unavailable.")
+ : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("# Feed", Encoding.UTF8, "text/plain") });
+ var service = CreateService(handler);
+ var updates = 0;
+ service.FeedUpdated += (_, _) => updates++;
+
+ await service.RefreshAsync();
+ fail = true;
+ var act = () => service.RefreshAsync();
+
+ await act.Should().ThrowAsync();
+ updates.Should().Be(1);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_appDataDirectory))
+ {
+ Directory.Delete(_appDataDirectory, recursive: true);
+ }
+ }
+
+ private NewsFeedService CreateService(HttpMessageHandler handler)
+ {
+ var logger = new Mock>();
+ return new NewsFeedService(new HttpClient(handler), logger.Object, _appDataDirectory);
+ }
+
+ private static StubHttpMessageHandler CreateFeedHandler(string markdown, params string[] assetPaths) =>
+ new(request =>
+ {
+ var path = request.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/docs/news.md", StringComparison.Ordinal))
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(markdown, Encoding.UTF8, "text/plain")
+ };
+ }
+
+ return Array.Exists(assetPaths, assetPath => path.EndsWith(assetPath, StringComparison.Ordinal))
+ ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) }
+ : new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromResult(responseFactory(request));
+ }
+}
diff --git a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj
index 5477005..3ccff0a 100644
--- a/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj
+++ b/src/NoteBookmark.MauiApp.Tests/NoteBookmark.MauiApp.Tests.csproj
@@ -38,6 +38,8 @@
+
+
diff --git a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs
index b1fe4f2..08f74aa 100644
--- a/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs
+++ b/src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs
@@ -19,6 +19,7 @@ public class SyncServiceTests
private readonly Mock _localDataServiceMock;
private readonly Mock> _loggerMock;
private readonly Mock _localHtmlStorageServiceMock;
+ private readonly Mock _newsFeedServiceMock;
private readonly SyncService _sut;
public SyncServiceTests()
@@ -27,13 +28,15 @@ public SyncServiceTests()
_localDataServiceMock = new Mock();
_loggerMock = new Mock>();
_localHtmlStorageServiceMock = new Mock();
+ _newsFeedServiceMock = new Mock();
_apiClientMock.Setup(c => c.GetNote(It.IsAny())).ReturnsAsync((Note?)null);
_apiClientMock.Setup(c => c.GetPostHtmlAsync(It.IsAny())).ReturnsAsync((string?)null);
_localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List());
_localHtmlStorageServiceMock.Setup(s => s.GetCachedPostIds()).Returns(new List());
+ _newsFeedServiceMock.Setup(s => s.RefreshAsync()).Returns(Task.CompletedTask);
- _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object);
+ _sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object, _newsFeedServiceMock.Object);
SyncService.ClearInMemoryPreferences();
}
@@ -249,6 +252,19 @@ public async Task SyncAsync_ShouldNotRunConcurrently()
_localDataServiceMock.Verify(c => c.GetPendingSyncNotesAsync(), Times.Once);
}
+ [Fact]
+ public async Task SyncAsync_ShouldRefreshNewsFeed()
+ {
+ _localDataServiceMock.Setup(c => c.GetPendingSyncPostsAsync()).ReturnsAsync(new List());
+ _localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List());
+ _apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny())).ReturnsAsync(new List());
+ _apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny())).ReturnsAsync(new List());
+
+ await _sut.SyncAsync();
+
+ _newsFeedServiceMock.Verify(s => s.RefreshAsync(), Times.Once);
+ }
+
[Fact]
public async Task PullPhase_ShouldDeleteLocalPosts_WhenDeletedOnServer()
{
diff --git a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor
index bbcc34d..4e7b3e0 100644
--- a/src/NoteBookmark.MauiApp/Components/Pages/Home.razor
+++ b/src/NoteBookmark.MauiApp/Components/Pages/Home.razor
@@ -1,27 +1,48 @@
@page "/"
@inject IAuthService AuthService
@inject NavigationManager Nav
+@inject NoteBookmark.MauiApp.Data.INewsFeedService NewsFeed
@implements IDisposable
Hello, @(AuthService.Username ?? "there")!
You are signed in to NoteBookmark.
+
+
+ News & Updates
+ @((MarkupString)newsHtml)
+
@code {
+ private string newsHtml = "Welcome to NoteBookmark! Check back here for product updates.
";
+
protected override void OnInitialized()
{
- // No-op
+ NewsFeed.FeedUpdated += OnFeedUpdated;
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ {
+ return;
+ }
+
+ await LoadNewsAsync();
}
- private async Task LogoutAsync()
+ // Sync refreshes the feed on a background thread, so marshal the reload back to the renderer.
+ private void OnFeedUpdated(object? sender, EventArgs e) => _ = InvokeAsync(LoadNewsAsync);
+
+ private async Task LoadNewsAsync()
{
- await AuthService.LogoutAsync();
+ newsHtml = await NewsFeed.GetHtmlAsync();
+ StateHasChanged();
}
void IDisposable.Dispose()
{
- // Cleanup resources if needed
+ NewsFeed.FeedUpdated -= OnFeedUpdated;
}
}
-
diff --git a/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs
new file mode 100644
index 0000000..26257c5
--- /dev/null
+++ b/src/NoteBookmark.MauiApp/Data/INewsFeedService.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Threading.Tasks;
+
+namespace NoteBookmark.MauiApp.Data;
+
+public interface INewsFeedService
+{
+ /// Raised after a refresh has downloaded and cached a new copy of the feed.
+ event EventHandler? FeedUpdated;
+
+ Task RefreshAsync();
+ Task GetHtmlAsync();
+}
diff --git a/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs
new file mode 100644
index 0000000..99a1c73
--- /dev/null
+++ b/src/NoteBookmark.MauiApp/Data/NewsFeedService.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Markdig;
+using Markdig.Syntax;
+using Markdig.Syntax.Inlines;
+using Microsoft.Extensions.Logging;
+
+namespace NoteBookmark.MauiApp.Data;
+
+public sealed class NewsFeedService : INewsFeedService
+{
+ private const string NewsFeedUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/news.md";
+ private const string NewsAssetsUrl = "https://raw.githubusercontent.com/fboucher/NoteBookmark/v-next/docs/assets/";
+ private const string FallbackHtml = "Welcome to NoteBookmark! Check back here for product updates.
";
+ private static readonly Regex HtmlUrlAttributePattern = new(
+ @"(?href|src)=""(?[^""]*)""",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
+ .DisableHtml()
+ .Build();
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly string _documentsDirectory;
+ private readonly string _assetsDirectory;
+ private readonly string _newsFilePath;
+ private readonly System.Threading.SemaphoreSlim _refreshLock = new(1, 1);
+
+ public NewsFeedService(HttpClient httpClient, ILogger logger, string appDataDirectory)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ _documentsDirectory = Path.Combine(appDataDirectory, "docs");
+ _assetsDirectory = Path.Combine(_documentsDirectory, "assets");
+ _newsFilePath = Path.Combine(_documentsDirectory, "news.md");
+ }
+
+ public event EventHandler? FeedUpdated;
+
+ public async Task RefreshAsync()
+ {
+ await _refreshLock.WaitAsync();
+ try
+ {
+ var markdown = await _httpClient.GetStringAsync(NewsFeedUrl);
+ var images = GetImages(Markdown.Parse(markdown, MarkdownPipeline))
+ .Select(image => TryGetAssetRelativePath(image.Url))
+ .Where(path => path is not null)
+ .Cast()
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var downloadedAssets = new List<(string Path, byte[] Content)>();
+
+ foreach (var relativePath in images)
+ {
+ var url = NewsAssetsUrl + string.Join(
+ "/",
+ relativePath.Split('/').Select(Uri.EscapeDataString));
+ var content = await _httpClient.GetByteArrayAsync(url);
+ downloadedAssets.Add((GetAssetFilePath(relativePath), content));
+ }
+
+ foreach (var (path, content) in downloadedAssets)
+ {
+ await WriteAtomicallyAsync(path, content);
+ }
+
+ await WriteAtomicallyAsync(_newsFilePath, System.Text.Encoding.UTF8.GetBytes(markdown));
+ }
+ finally
+ {
+ _refreshLock.Release();
+ }
+
+ FeedUpdated?.Invoke(this, EventArgs.Empty);
+ }
+
+ public async Task GetHtmlAsync()
+ {
+ if (!File.Exists(_newsFilePath))
+ {
+ try
+ {
+ await RefreshAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to download the news feed; showing the welcome message.");
+ return FallbackHtml;
+ }
+
+ if (!File.Exists(_newsFilePath))
+ {
+ return FallbackHtml;
+ }
+ }
+
+ try
+ {
+ var markdown = await File.ReadAllTextAsync(_newsFilePath);
+ var document = Markdown.Parse(markdown, MarkdownPipeline);
+ foreach (var image in GetImages(document))
+ {
+ var relativePath = TryGetAssetRelativePath(image.Url);
+ if (relativePath is null)
+ {
+ continue;
+ }
+
+ var filePath = GetAssetFilePath(relativePath);
+ if (!File.Exists(filePath))
+ {
+ continue;
+ }
+
+ var imageBytes = await File.ReadAllBytesAsync(filePath);
+ image.Url = $"data:{GetImageMimeType(filePath)};base64,{Convert.ToBase64String(imageBytes)}";
+ }
+
+ var html = document.ToHtml(MarkdownPipeline);
+ return SanitizeGeneratedHtml(html);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Unable to read the cached news feed.");
+ return FallbackHtml;
+ }
+ }
+
+ private static string SanitizeGeneratedHtml(string html) =>
+ HtmlUrlAttributePattern.Replace(html, match =>
+ {
+ var attribute = match.Groups["attribute"].Value;
+ var value = WebUtility.HtmlDecode(match.Groups["value"].Value);
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
+ {
+ return match.Value;
+ }
+
+ var isAllowed = uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
+ || (attribute.Equals("href", StringComparison.OrdinalIgnoreCase)
+ && uri.Scheme.Equals("mailto", StringComparison.OrdinalIgnoreCase))
+ || (attribute.Equals("src", StringComparison.OrdinalIgnoreCase)
+ && uri.Scheme.Equals("data", StringComparison.OrdinalIgnoreCase)
+ && value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase));
+
+ return isAllowed ? match.Value : $"{attribute}=\"#\"";
+ });
+
+ private string GetAssetFilePath(string relativePath)
+ {
+ var path = Path.GetFullPath(Path.Combine(_assetsDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)));
+ var assetsDirectory = Path.GetFullPath(_assetsDirectory) + Path.DirectorySeparatorChar;
+ if (!path.StartsWith(assetsDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("News feed image path must remain under docs/assets.");
+ }
+
+ return path;
+ }
+
+ // Uses Markdig's parse tree so inline and reference-style images are found exactly as they render,
+ // and image syntax shown inside code blocks is ignored.
+ private static IEnumerable GetImages(MarkdownDocument document) =>
+ document.Descendants().Where(link => link.IsImage);
+
+ private static string? TryGetAssetRelativePath(string? imagePath)
+ {
+ if (string.IsNullOrWhiteSpace(imagePath) || Uri.TryCreate(imagePath, UriKind.Absolute, out _))
+ {
+ return null;
+ }
+
+ var path = Uri.UnescapeDataString(imagePath).Replace('\\', '/');
+ if (path.StartsWith("./", StringComparison.Ordinal))
+ {
+ path = path[2..];
+ }
+
+ if (!path.StartsWith("assets/", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var relativePath = path["assets/".Length..];
+ var segments = relativePath.Split('/');
+ if (segments.Length == 0 || segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment is "." or ".."))
+ {
+ return null;
+ }
+
+ return relativePath;
+ }
+
+ private static string GetImageMimeType(string path) =>
+ Path.GetExtension(path).ToLowerInvariant() switch
+ {
+ ".gif" => "image/gif",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ ".svg" => "image/svg+xml",
+ ".webp" => "image/webp",
+ _ => "application/octet-stream"
+ };
+
+ private static async Task WriteAtomicallyAsync(string path, byte[] content)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ var temporaryPath = path + ".tmp";
+ await File.WriteAllBytesAsync(temporaryPath, content);
+ File.Move(temporaryPath, path, overwrite: true);
+ }
+}
diff --git a/src/NoteBookmark.MauiApp/Data/SyncService.cs b/src/NoteBookmark.MauiApp/Data/SyncService.cs
index ba0a877..bb6662a 100644
--- a/src/NoteBookmark.MauiApp/Data/SyncService.cs
+++ b/src/NoteBookmark.MauiApp/Data/SyncService.cs
@@ -25,7 +25,8 @@ public class SyncService(
ISyncApiClient apiClient,
ILocalDataService localDataService,
ILogger logger,
- ILocalHtmlStorageService localHtmlStorageService) : ISyncService
+ ILocalHtmlStorageService localHtmlStorageService,
+ INewsFeedService newsFeedService) : ISyncService
{
private const string LastSyncTimestampKey = "LastSyncTimestamp";
private const int MaxConcurrentRequests = 5;
@@ -81,6 +82,7 @@ private async Task DoSyncAsync()
await PullAsync(lastSync);
await SyncHtmlAsync();
+ await RefreshNewsFeedAsync();
await SetPreferenceAsync(LastSyncTimestampKey, DateTime.UtcNow.ToString("O"));
SyncProgressChanged?.Invoke(this, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true));
@@ -411,6 +413,18 @@ private async Task SyncHtmlAsync()
}
}
+ private async Task RefreshNewsFeedAsync()
+ {
+ try
+ {
+ await newsFeedService.RefreshAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Unable to refresh the news feed; keeping the cached copy.");
+ }
+ }
+
#if NOT_MAUI
private static readonly System.Collections.Concurrent.ConcurrentDictionary _inMemoryPreferences = new();
diff --git a/src/NoteBookmark.MauiApp/MauiProgram.cs b/src/NoteBookmark.MauiApp/MauiProgram.cs
index 6988992..ec25087 100644
--- a/src/NoteBookmark.MauiApp/MauiProgram.cs
+++ b/src/NoteBookmark.MauiApp/MauiProgram.cs
@@ -39,6 +39,13 @@ public static MauiHostingApp CreateMauiApp()
sp => new NoteBookmark.MauiApp.Data.LocalHtmlStorageService(FileSystem.AppDataDirectory));
builder.Services.AddSingleton(
sp => sp.GetRequiredService());
+ // Singleton so sync and the home page share one refresh lock and one set of cache files.
+ builder.Services.AddHttpClient(nameof(NoteBookmark.MauiApp.Data.NewsFeedService));
+ builder.Services.AddSingleton(
+ sp => new NoteBookmark.MauiApp.Data.NewsFeedService(
+ sp.GetRequiredService().CreateClient(nameof(NoteBookmark.MauiApp.Data.NewsFeedService)),
+ sp.GetRequiredService>(),
+ FileSystem.AppDataDirectory));
builder.Services.AddTransient();
builder.Services.AddHttpClient(client =>
diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj
index 53cfa87..2a6125c 100644
--- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj
+++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj
@@ -90,6 +90,7 @@
+