From 2e8bdf952ba45fe48904d33efd87950710c3fbd5 Mon Sep 17 00:00:00 2001 From: Kelby Hunt Date: Tue, 5 May 2026 20:22:58 -0700 Subject: [PATCH 1/3] Remove old best offer and refactor notifications - When a best offer is no longer available, the label is now cleared. - Split notification responsibilities between an in-app UI service and the native toast service. --- Models/MarketOffer.cs | 10 +++ Models/{Items.cs => WfmResponse.cs} | 0 Program.cs | 16 +--- Services/INotificationService.cs | 23 ----- Services/MarketPollingService.cs | 90 ++++++++++++------- Services/NativeNotificationService.cs | 91 ++++++++------------ Services/UserInterfaceNotificationService.cs | 80 +++++++++++++++++ Services/WarframeMarketService.cs | 10 --- ViewModels/MainWindowViewModel.cs | 21 +++-- ViewModels/TrackedItemViewModel.cs | 12 +-- WarframeMarketTracker.csproj | 2 +- 11 files changed, 212 insertions(+), 143 deletions(-) create mode 100644 Models/MarketOffer.cs rename Models/{Items.cs => WfmResponse.cs} (100%) delete mode 100644 Services/INotificationService.cs create mode 100644 Services/UserInterfaceNotificationService.cs diff --git a/Models/MarketOffer.cs b/Models/MarketOffer.cs new file mode 100644 index 0000000..72c63b7 --- /dev/null +++ b/Models/MarketOffer.cs @@ -0,0 +1,10 @@ +namespace WarframeMarketTracker.Models; + +public record MarketOffer( + string Slug, + string ItemName, + string OrderId, + int Platinum, + int TargetPlatinum, + string SellerName, + string Whisper); \ No newline at end of file diff --git a/Models/Items.cs b/Models/WfmResponse.cs similarity index 100% rename from Models/Items.cs rename to Models/WfmResponse.cs diff --git a/Program.cs b/Program.cs index 11ca029..66d6bc9 100644 --- a/Program.cs +++ b/Program.cs @@ -16,8 +16,6 @@ namespace WarframeMarketTracker; internal static class Program { public const string AppName = "Warframe Market Tracker"; - public const string NotificationChannelId = "market_alerts"; - public const string AppUserModelId = "com.warframe.market.tracker"; [STAThread] public static void Main(string[] args) @@ -89,6 +87,7 @@ public static void Main(string[] args) services.AddHostedService(); // 5. Components & UI + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddSingleton(); @@ -120,11 +119,6 @@ public static void Main(string[] args) } } - private static readonly NotificationChannel[] NotificationChannels = - [ - new(NotificationChannelId, "Warframe Market Alerts", NotificationPriority.High) - ]; - private static AppBuilder BuildAvaloniaApp(IServiceProvider services) => AppBuilder.Configure(() => new App(services)) .UsePlatformDetect() @@ -132,12 +126,6 @@ private static AppBuilder BuildAvaloniaApp(IServiceProvider services) .WithDeveloperTools() #endif .WithInterFont() - .WithAppNotifications(new AppNotificationOptions - { - Channels = NotificationChannels, - AppName = AppName, - // Required for Windows Toast notifications to work correctly - AppUserModelId = AppUserModelId - }) + .WithAppNotifications(NativeNotificationService.AppNotificationOptions) .LogToTrace(); } \ No newline at end of file diff --git a/Services/INotificationService.cs b/Services/INotificationService.cs deleted file mode 100644 index e862d00..0000000 --- a/Services/INotificationService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace WarframeMarketTracker.Services; - -public record MarketOffer( - string Slug, - string ItemName, - string OrderId, - int Platinum, - int TargetPlatinum, - string SellerName, - string Whisper); - -public interface INotificationService -{ - Task NotifyOfferAsync(MarketOffer offer); - void IgnoreOffer(string orderId); - Task CopyWhisperAsync(string whisper); - event Action? OfferAvailable; - event Action? OrderIgnored; - void Initialize(); -} \ No newline at end of file diff --git a/Services/MarketPollingService.cs b/Services/MarketPollingService.cs index aa9ab9f..ff302c7 100644 --- a/Services/MarketPollingService.cs +++ b/Services/MarketPollingService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; @@ -12,27 +13,34 @@ public partial class MarketPollingService : BackgroundService { private readonly IWarframeMarketService _api; private readonly ITrackedItemRegistry _registry; - private readonly INotificationService _notifications; + private readonly INotificationService _toast; + private readonly IUserInterfaceNotificationService _uiNotificationService; private readonly ILogger _logger; // Tracks the last price we notified about per slug — only re-notify if a lower price appears private readonly Dictionary _lastNotifiedPrice = new(); + // Tracks the order ID we last surfaced to the UI per slug — used to detect when that specific deal disappears + // (seller went offline, item sold) so we can clear the label. + private readonly Dictionary _lastNotifiedOfferId = new(); + // Orders the user chose to ignore for this session private readonly HashSet _ignoredOrderIds = new(); public MarketPollingService( IWarframeMarketService api, ITrackedItemRegistry registry, - INotificationService notifications, + INotificationService toast, + IUserInterfaceNotificationService uiNotificationService, ILogger logger) { _api = api; _registry = registry; - _notifications = notifications; + _toast = toast; + _uiNotificationService = uiNotificationService; _logger = logger; - _notifications.OrderIgnored += orderId => _ignoredOrderIds.Add(orderId); + _uiNotificationService.OrderIgnored += orderId => _ignoredOrderIds.Add(orderId); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -61,40 +69,61 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - var lowestOrder = await _api.GetLowestSellOrderAsync(item.Slug, item.TargetRank, stoppingToken); + var orders = await _api.GetOrdersBySlugAsync(item.Slug, stoppingToken); + if (item.TargetRank.HasValue) + orders = orders.Where(o => o.Rank == item.TargetRank.Value).ToList(); + + var lowestOrder = orders.FirstOrDefault(); + var hasDeal = lowestOrder != null && lowestOrder.Platinum <= item.TargetPlatinum; + var isIgnored = hasDeal && _ignoredOrderIds.Contains(lowestOrder!.Id); - if (lowestOrder != null && lowestOrder.Platinum <= item.TargetPlatinum) + if (hasDeal && !isIgnored) { - // Skip orders the user has chosen to ignore this session - if (_ignoredOrderIds.Contains(lowestOrder.Id)) - continue; + var isNewLowerPrice = !_lastNotifiedPrice.TryGetValue(item.Slug, out var lastPrice) + || lowestOrder!.Platinum < lastPrice; - // Skip if we already notified at this price or lower - if (_lastNotifiedPrice.TryGetValue(item.Slug, out var lastPrice) - && lowestOrder.Platinum >= lastPrice) + if (isNewLowerPrice) { - continue; + _lastNotifiedPrice[item.Slug] = lowestOrder!.Platinum; + _lastNotifiedOfferId[item.Slug] = lowestOrder.Id; + + LogDealFoundWithTargetPrice(item.ItemName, lowestOrder.Platinum, item.TargetPlatinum); + + var offer = new MarketOffer( + item.Slug, + item.ItemName, + lowestOrder.Id, + lowestOrder.Platinum, + item.TargetPlatinum, + lowestOrder.User.IngameName, + lowestOrder.GenerateWhisper(item.ItemName)); + + // Surface to UI first so labels appear even if the OS toast fails + _uiNotificationService.SurfaceOffer(offer); + await _toast.ShowOfferAsync(offer); + } + else if (_lastNotifiedOfferId.TryGetValue(item.Slug, out var lastId) + && lastId != lowestOrder!.Id) + { + // Same/higher price than last notification, but a different seller is now cheapest - the original deal is gone. + LogDealCleared(item.ItemName); + + _lastNotifiedOfferId.Remove(item.Slug); + _lastNotifiedPrice.Remove(item.Slug); + _uiNotificationService.ClearOffer(item.Slug); } - - _lastNotifiedPrice[item.Slug] = lowestOrder.Platinum; - - LogDealFoundWithTargetPrice(item.ItemName, lowestOrder.Platinum, item.TargetPlatinum); - - var offer = new MarketOffer( - item.Slug, - item.ItemName, - lowestOrder.Id, - lowestOrder.Platinum, - item.TargetPlatinum, - lowestOrder.User.IngameName, - lowestOrder.GenerateWhisper(item.ItemName)); - - await _notifications.NotifyOfferAsync(offer); } else { - // Price rose above target or no orders — reset so we re-notify if it drops again + // Either no qualifying deal exists, or the cheapest is the deal the user ignored. _lastNotifiedPrice.Remove(item.Slug); + + if (_lastNotifiedOfferId.Remove(item.Slug)) + { + LogDealCleared(item.ItemName); + + _uiNotificationService.ClearOffer(item.Slug); + } } } catch (Exception ex) @@ -113,4 +142,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) [LoggerMessage(LogLevel.Information, "[DEAL FOUND] {ItemName} is selling for {Platinum}p (Target: {Target}p).")] partial void LogDealFoundWithTargetPrice(string itemName, int platinum, int target); + + [LoggerMessage(LogLevel.Information, "[DEAL CLEARED] {ItemName} - previously surfaced offer is no longer available.")] + partial void LogDealCleared(string itemName); } \ No newline at end of file diff --git a/Services/NativeNotificationService.cs b/Services/NativeNotificationService.cs index bcc4efd..b21d9e1 100644 --- a/Services/NativeNotificationService.cs +++ b/Services/NativeNotificationService.cs @@ -3,35 +3,54 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Input.Platform; using Avalonia.Labs.Notifications; using Microsoft.Extensions.Logging; +using WarframeMarketTracker.Models; namespace WarframeMarketTracker.Services; +public interface INotificationService +{ + void Initialize(); + Task ShowOfferAsync(MarketOffer offer); +} + public class NativeNotificationService : INotificationService { + private const string ChannelId = "market_alerts"; + private const string AppUserModelId = "com.warframe.market.tracker"; private const string CopyActionTag = "copy"; private const string IgnoreActionTag = "ignore"; + private static readonly NotificationChannel[] Channels = + [ + new(ChannelId, "Warframe Market Alerts", NotificationPriority.High) + ]; + private static readonly NativeNotificationAction[] NotificationActions = [ new("Copy Whisper", CopyActionTag), new("Ignore Offer", IgnoreActionTag) ]; - private readonly ILogger _logger; - private readonly Dictionary _pending = new(); + public static readonly AppNotificationOptions AppNotificationOptions = new() + { + Channels = Channels, + AppName = Program.AppName, + // Required for Windows Toast notifications to work correctly + AppUserModelId = AppUserModelId, + }; - public event Action? OfferAvailable; - public event Action? OrderIgnored; + private readonly ILogger _logger; + private readonly IUserInterfaceNotificationService _uiNotificationService; + private readonly Dictionary _pendingOffers = new(); - public NativeNotificationService(ILogger logger) + public NativeNotificationService( + ILogger logger, + IUserInterfaceNotificationService uiNotificationService) { _logger = logger; + _uiNotificationService = uiNotificationService; } public void Initialize() @@ -44,15 +63,12 @@ public void Initialize() } } - public Task NotifyOfferAsync(MarketOffer offer) + public Task ShowOfferAsync(MarketOffer offer) { - // Always raise the in-app event so the UI can show a fallback even if the toast doesn't fire - OfferAvailable?.Invoke(offer); - var manager = NativeNotificationManager.Current; if (manager == null) return Task.CompletedTask; - var notification = manager.CreateNotification(Program.NotificationChannelId); + var notification = manager.CreateNotification(ChannelId); if (notification == null) return Task.CompletedTask; notification.Title = $"Deal Found: {offer.ItemName}"; @@ -60,52 +76,26 @@ public Task NotifyOfferAsync(MarketOffer offer) $"{offer.Platinum}p from {offer.SellerName}{Environment.NewLine}Target: {offer.TargetPlatinum}p"; notification.SetActions(NotificationActions); - _pending[notification.Id] = offer; + _pendingOffers[notification.Id] = offer; notification.Show(); return Task.CompletedTask; } - public void IgnoreOffer(string orderId) - { - _logger.LogInformation("User ignored order {OrderId}.", orderId); - OrderIgnored?.Invoke(orderId); - } - - public async Task CopyWhisperAsync(string whisper) - { - var clipboard = GetClipboard(); - if (clipboard == null) - { - _logger.LogWarning("Clipboard unavailable."); - return; - } - - try - { - await clipboard.SetTextAsync(whisper); - _logger.LogInformation("Whisper copied to clipboard."); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to copy whisper to clipboard."); - } - } - private void OnNotificationCompleted(object? sender, NativeNotificationCompletedEventArgs e) { - if (!e.NotificationId.HasValue || !_pending.TryGetValue(e.NotificationId.Value, out var offer)) + if (!e.NotificationId.HasValue || !_pendingOffers.TryGetValue(e.NotificationId.Value, out var offer)) return; - _pending.Remove(e.NotificationId.Value); + _pendingOffers.Remove(e.NotificationId.Value); switch (e.ActionTag) { case CopyActionTag: - _ = CopyWhisperAsync(offer.Whisper); + _ = _uiNotificationService.CopyWhisperAsync(offer.Whisper); break; case IgnoreActionTag: - IgnoreOffer(offer.OrderId); + _uiNotificationService.IgnoreOffer(offer.OrderId); break; default: var url = $"https://warframe.market/items/{offer.Slug}?type=sell"; @@ -115,17 +105,6 @@ private void OnNotificationCompleted(object? sender, NativeNotificationCompleted } } - private static IClipboard? GetClipboard() - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop - && desktop.MainWindow is { } window) - { - return TopLevel.GetTopLevel(window)?.Clipboard; - } - - return null; - } - private static void OpenUrl(string url) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/Services/UserInterfaceNotificationService.cs b/Services/UserInterfaceNotificationService.cs new file mode 100644 index 0000000..d83d0d7 --- /dev/null +++ b/Services/UserInterfaceNotificationService.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using Microsoft.Extensions.Logging; +using WarframeMarketTracker.Models; + +namespace WarframeMarketTracker.Services; + +public interface IUserInterfaceNotificationService +{ + void SurfaceOffer(MarketOffer offer); + void ClearOffer(string slug); + void IgnoreOffer(string orderId); + Task CopyWhisperAsync(string whisper); + event Action? OfferAvailable; + event Action? OfferCleared; + event Action? OrderIgnored; +} + +public class UserInterfaceNotificationService : IUserInterfaceNotificationService +{ + private readonly ILogger _logger; + + public event Action? OfferAvailable; + public event Action? OfferCleared; + public event Action? OrderIgnored; + + public UserInterfaceNotificationService(ILogger logger) + { + _logger = logger; + } + + public void SurfaceOffer(MarketOffer offer) => OfferAvailable?.Invoke(offer); + + public void ClearOffer(string slug) + { + _logger.LogInformation("Clearing stale offer for {Slug}.", slug); + OfferCleared?.Invoke(slug); + } + + public void IgnoreOffer(string orderId) + { + _logger.LogInformation("User ignored order {OrderId}.", orderId); + OrderIgnored?.Invoke(orderId); + } + + public async Task CopyWhisperAsync(string whisper) + { + var clipboard = GetClipboard(); + if (clipboard == null) + { + _logger.LogWarning("Clipboard unavailable."); + return; + } + + try + { + await clipboard.SetTextAsync(whisper); + _logger.LogInformation("Whisper copied to clipboard."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to copy whisper to clipboard."); + } + } + + private static IClipboard? GetClipboard() + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop + && desktop.MainWindow is { } window) + { + return TopLevel.GetTopLevel(window)?.Clipboard; + } + + return null; + } +} \ No newline at end of file diff --git a/Services/WarframeMarketService.cs b/Services/WarframeMarketService.cs index 14db8bd..1ba1fab 100644 --- a/Services/WarframeMarketService.cs +++ b/Services/WarframeMarketService.cs @@ -13,7 +13,6 @@ namespace WarframeMarketTracker.Services; public interface IWarframeMarketService { Task> GetOrdersBySlugAsync(string slug, CancellationToken ct = default); - Task GetLowestSellOrderAsync(string slug, int? rank = null, CancellationToken ct = default); } public class WarframeMarketService : IWarframeMarketService @@ -49,13 +48,4 @@ public async Task> GetOrdersBySlugAsync(string slug, Cancell } } - public async Task GetLowestSellOrderAsync(string slug, int? rank = null, CancellationToken ct = default) - { - var orders = await GetOrdersBySlugAsync(slug, ct); - - if (rank.HasValue) - orders = orders.Where(o => o.Rank == rank.Value).ToList(); - - return orders.FirstOrDefault(); - } } \ No newline at end of file diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 45f248c..6f1b5d3 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -18,7 +18,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly IItemCache _cache; private readonly ITrackedItemRegistry _registry; private readonly ITrackedItemStore _store; - private readonly INotificationService _notifications; + private readonly IUserInterfaceNotificationService _uiNotificationService; private readonly IServiceProvider _services; private bool _isLoading; @@ -38,17 +38,18 @@ public MainWindowViewModel( IItemCache cache, ITrackedItemRegistry registry, ITrackedItemStore store, - INotificationService notifications, + IUserInterfaceNotificationService uiNotificationService, IServiceProvider services) { _cache = cache; _registry = registry; _store = store; - _notifications = notifications; + _uiNotificationService = uiNotificationService; _services = services; TrackedItems.CollectionChanged += OnTrackedItemsChanged; - _notifications.OfferAvailable += OnOfferAvailable; + _uiNotificationService.OfferAvailable += OnOfferAvailable; + _uiNotificationService.OfferCleared += OnOfferCleared; LoadTrackedItems(); } @@ -62,6 +63,16 @@ private void OnOfferAvailable(MarketOffer offer) }); } + private void OnOfferCleared(string slug) + { + Dispatcher.UIThread.Post(() => + { + var match = TrackedItems.FirstOrDefault(vm => + string.Equals(vm.Slug, slug, StringComparison.OrdinalIgnoreCase)); + match?.ClearBestOffer(); + }); + } + [RelayCommand] private async Task OpenAbout() { @@ -99,7 +110,7 @@ private void LoadTrackedItems() private TrackedItemViewModel CreateTrackedItem() { - var vm = new TrackedItemViewModel(_cache, _registry, _notifications, item => TrackedItems.Remove(item)); + var vm = new TrackedItemViewModel(_cache, _registry, _uiNotificationService, item => TrackedItems.Remove(item)); vm.PropertyChanged += (_, e) => { if (e.PropertyName != null && PersistedProperties.Contains(e.PropertyName)) diff --git a/ViewModels/TrackedItemViewModel.cs b/ViewModels/TrackedItemViewModel.cs index 6d7914b..3f7a49d 100644 --- a/ViewModels/TrackedItemViewModel.cs +++ b/ViewModels/TrackedItemViewModel.cs @@ -12,7 +12,7 @@ public partial class TrackedItemViewModel : ViewModelBase { private readonly IItemCache _cache; private readonly ITrackedItemRegistry _registry; - private readonly INotificationService _notifications; + private readonly IUserInterfaceNotificationService _uiNotificationService; private readonly Action _removeCallback; private ItemShort? _resolvedItem; private string? _registeredKey; @@ -49,17 +49,19 @@ public partial class TrackedItemViewModel : ViewModelBase public TrackedItemViewModel( IItemCache cache, ITrackedItemRegistry registry, - INotificationService notifications, + IUserInterfaceNotificationService uiNotificationService, Action removeCallback) { _cache = cache; _registry = registry; - _notifications = notifications; + _uiNotificationService = uiNotificationService; _removeCallback = removeCallback; } public void SetBestOffer(MarketOffer offer) => BestOffer = offer; + public void ClearBestOffer() => BestOffer = null; + [RelayCommand] private void Remove() { @@ -71,14 +73,14 @@ private void Remove() private async Task CopyWhisper() { if (BestOffer is null) return; - await _notifications.CopyWhisperAsync(BestOffer.Whisper); + await _uiNotificationService.CopyWhisperAsync(BestOffer.Whisper); } [RelayCommand] private void IgnoreOffer() { if (BestOffer is null) return; - _notifications.IgnoreOffer(BestOffer.OrderId); + _uiNotificationService.IgnoreOffer(BestOffer.OrderId); BestOffer = null; } diff --git a/WarframeMarketTracker.csproj b/WarframeMarketTracker.csproj index 1089e3b..e6baca9 100644 --- a/WarframeMarketTracker.csproj +++ b/WarframeMarketTracker.csproj @@ -6,7 +6,7 @@ app.manifest true Assets\wmt-logo.ico - 1.1.0 + 1.2.0 From 016e80d3b7488a143e8d3d85636d4d669908f7bd Mon Sep 17 00:00:00 2001 From: Kelby Hunt Date: Wed, 6 May 2026 08:11:14 -0700 Subject: [PATCH 2/3] Add thumbnail UI support - Introduce a ThumbnailCache that fetches, caches to disk, and deduplicates in-memory thumbnail requests, and register dedicated HTTP clients for API and asset requests. - Added a CachedImage attached control to asynchronously bind thumb paths to auto complete box and initialize it at startup. - Added logic to auto-focus newly added rows. - Refactor WarframeMarketService and ItemCache to use IHttpClientFactory and update DI registrations accordingly. --- App.axaml.cs | 3 + Controls/CachedImage.cs | 41 +++++++++++ Models/WfmResponse.cs | 4 +- Program.cs | 48 ++++--------- Services/ItemCache.cs | 2 +- Services/ThumbnailCache.cs | 111 ++++++++++++++++++++++++++++++ Services/WarframeMarketService.cs | 4 +- ViewModels/MainWindowViewModel.cs | 11 ++- Views/MainWindow.axaml | 20 +++++- Views/MainWindow.axaml.cs | 29 +++++++- 10 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 Controls/CachedImage.cs create mode 100644 Services/ThumbnailCache.cs diff --git a/App.axaml.cs b/App.axaml.cs index 9234916..3698a5b 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; +using WarframeMarketTracker.Controls; using WarframeMarketTracker.Services; using WarframeMarketTracker.Views; @@ -38,6 +39,8 @@ public override void OnFrameworkInitializationCompleted() var notificationService = _services.GetRequiredService(); notificationService.Initialize(); + CachedImage.Initialize(_services.GetRequiredService()); + SetupTrayIcon(desktop, mainWindow); FixTrayMenuPosition(); } diff --git a/Controls/CachedImage.cs b/Controls/CachedImage.cs new file mode 100644 index 0000000..a129147 --- /dev/null +++ b/Controls/CachedImage.cs @@ -0,0 +1,41 @@ +using Avalonia; +using Avalonia.Controls; +using WarframeMarketTracker.Services; + +namespace WarframeMarketTracker.Controls; + +public class CachedImage +{ + private static IThumbnailCache? _cache; + + public static readonly AttachedProperty ThumbPathProperty = + AvaloniaProperty.RegisterAttached("ThumbPath"); + + static CachedImage() + { + ThumbPathProperty.Changed.AddClassHandler(OnThumbPathChanged); + } + + public static void Initialize(IThumbnailCache cache) => _cache = cache; + + public static string? GetThumbPath(Image obj) => obj.GetValue(ThumbPathProperty); + public static void SetThumbPath(Image obj, string? value) => obj.SetValue(ThumbPathProperty, value); + + private CachedImage() { } + + private static async void OnThumbPathChanged(Image image, AvaloniaPropertyChangedEventArgs e) + { + // Clear immediately so a recycled container doesn't briefly show the previous item's thumb + image.Source = null; + + var path = e.GetNewValue(); + if (string.IsNullOrEmpty(path) || _cache == null) return; + + var bitmap = await _cache.GetAsync(path); + if (bitmap == null) return; + + // Container may have been recycled to a different item while loading + if (GetThumbPath(image) != path) return; + image.Source = bitmap; + } +} \ No newline at end of file diff --git a/Models/WfmResponse.cs b/Models/WfmResponse.cs index af414c9..529bdbc 100644 --- a/Models/WfmResponse.cs +++ b/Models/WfmResponse.cs @@ -21,10 +21,12 @@ public record ItemShort( ) { public string EnglishName => I18N.TryGetValue("en", out var en) ? en.Name : Slug; + + public string? Thumb => I18N.TryGetValue("en", out var en) ? en.SubIcon ?? en.Icon : null; } public record ItemI18N( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("icon")] string Icon, - [property: JsonPropertyName("thumb")] string Thumb + [property: JsonPropertyName("subIcon")] string? SubIcon ); \ No newline at end of file diff --git a/Program.cs b/Program.cs index 66d6bc9..3b762a2 100644 --- a/Program.cs +++ b/Program.cs @@ -1,11 +1,9 @@ using System; -using System.Net.Http; using Avalonia; using Avalonia.Labs.Notifications; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Serilog; using WarframeMarketTracker.Services; using WarframeMarketTracker.ViewModels; @@ -41,18 +39,22 @@ public static void Main(string[] args) .UseSerilog() .ConfigureServices((_, services) => { - const string httpClientName = "WfmApi"; - const string warframeMarketEndpoint = "https://api.warframe.market/v2/"; const string userAgent = $"{nameof(WarframeMarketTracker)}/{BuildInfo.AppVersion}"; // 1. Setup named HTTP Clients - services.AddHttpClient(httpClientName, c => + services.AddHttpClient("WfmApi", c => { - c.BaseAddress = new Uri(warframeMarketEndpoint); + c.BaseAddress = new Uri("https://api.warframe.market/v2/"); c.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent); c.DefaultRequestHeaders.Accept.ParseAdd("application/json"); }); + services.AddHttpClient("WfmAssets", c => + { + c.BaseAddress = new Uri("https://warframe.market/static/assets/"); + c.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent); + }); + services.AddHttpClient("GitHub", c => { c.BaseAddress = new Uri("https://api.github.com/"); @@ -61,43 +63,23 @@ public static void Main(string[] args) c.Timeout = TimeSpan.FromSeconds(5); }); - // 2. Business Logic & API - services.AddTransient(sp => - { - var factory = sp.GetRequiredService(); - var client = factory.CreateClient(httpClientName); - var logger = sp.GetRequiredService>(); - return new WarframeMarketService(client, logger); - }); - - // 3. The Cache (must be a singleton or why else cache) - services.AddSingleton(sp => - { - var factory = sp.GetRequiredService(); - var client = factory.CreateClient(httpClientName); - return new ItemCache(client); - }); - - // 3b. Thread-safe registry for tracked items (shared between UI and poller) + // 2. Services & State + services.AddTransient(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); - // 4. Background Services + // 3. Background Services services.AddHostedService(); services.AddHostedService(); - // 5. Components & UI + // 4. Components & UI services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddSingleton(); - services.AddSingleton(sp => - { - var vm = sp.GetRequiredService(); - var window = new MainWindow { DataContext = vm }; - vm.Owner = window; - return window; - }); + services.AddSingleton(); }) .Build(); diff --git a/Services/ItemCache.cs b/Services/ItemCache.cs index a8543f5..c2ed754 100644 --- a/Services/ItemCache.cs +++ b/Services/ItemCache.cs @@ -28,7 +28,7 @@ public class ItemCache : IItemCache public int Count => Items.Count; - public ItemCache(HttpClient httpClient) => _httpClient = httpClient; + public ItemCache(IHttpClientFactory httpClientFactory) => _httpClient = httpClientFactory.CreateClient("WfmApi"); public async Task InitializeAsync(CancellationToken ct) { diff --git a/Services/ThumbnailCache.cs b/Services/ThumbnailCache.cs new file mode 100644 index 0000000..05c716d --- /dev/null +++ b/Services/ThumbnailCache.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using Microsoft.Extensions.Logging; + +namespace WarframeMarketTracker.Services; + +public interface IThumbnailCache +{ + Task GetAsync(string thumbPath, CancellationToken ct = default); +} + +public class ThumbnailCache : IThumbnailCache +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _diskRoot; + + // In-memory dedupe: concurrent callers for the same path share one Task. Completed tasks stay in the dictionary, so + // subsequent in-session lookups hit memory and skip disk decode. + private readonly Dictionary> _inFlight = new(StringComparer.Ordinal); + private readonly Lock _gate = new(); + + public ThumbnailCache(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClient = httpClientFactory.CreateClient("WfmAssets"); + _logger = logger; + _diskRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WarframeMarketTracker", "thumbs"); + Directory.CreateDirectory(_diskRoot); + } + + public Task GetAsync(string thumbPath, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(thumbPath)) + return Task.FromResult(null); + + lock (_gate) + { + if (_inFlight.TryGetValue(thumbPath, out var existing)) + return existing; + + var task = LoadAsync(thumbPath, ct); + _inFlight[thumbPath] = task; + return task; + } + } + + private async Task LoadAsync(string thumbPath, CancellationToken ct) + { + var diskPath = Path.Combine(_diskRoot, SanitizeFileName(thumbPath)); + + try + { + if (File.Exists(diskPath)) + { + try + { + await using var fs = File.OpenRead(diskPath); + return new Bitmap(fs); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Corrupt thumb on disk, refetching: {Path}", diskPath); + TryDelete(diskPath); + } + } + + var bytes = await _httpClient.GetByteArrayAsync(thumbPath, ct).ConfigureAwait(false); + + var tempPath = diskPath + ".tmp"; + await File.WriteAllBytesAsync(tempPath, bytes, ct).ConfigureAwait(false); + File.Move(tempPath, diskPath, overwrite: true); + + using var ms = new MemoryStream(bytes); + return new Bitmap(ms); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load thumb: {Path}", thumbPath); + + // Drop the failed task so a future request can retry instead of caching null forever + lock (_gate) _inFlight.Remove(thumbPath); + return null; + } + } + + private static string SanitizeFileName(string thumbPath) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(thumbPath.Length); + foreach (var c in thumbPath) + { + if (c == '/' || c == '\\') sb.Append('_'); + else if (Array.IndexOf(invalid, c) >= 0) sb.Append('_'); + else sb.Append(c); + } + return sb.ToString(); + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best-effort */ } + } +} \ No newline at end of file diff --git a/Services/WarframeMarketService.cs b/Services/WarframeMarketService.cs index 1ba1fab..addb63c 100644 --- a/Services/WarframeMarketService.cs +++ b/Services/WarframeMarketService.cs @@ -20,9 +20,9 @@ public class WarframeMarketService : IWarframeMarketService private readonly HttpClient _httpClient; private readonly ILogger _logger; - public WarframeMarketService(HttpClient httpClient, ILogger logger) + public WarframeMarketService(IHttpClientFactory httpClientFactory, ILogger logger) { - _httpClient = httpClient; + _httpClient = httpClientFactory.CreateClient("WfmApi"); _logger = logger; } diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 6f1b5d3..0649c23 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -32,7 +32,9 @@ public partial class MainWindowViewModel : ViewModelBase public ObservableCollection TrackedItems { get; } = new(); - public IEnumerable AvailableItemNames => _cache.Items.Select(i => i.EnglishName); + public IEnumerable AvailableItems => _cache.Items.OrderBy(i => i.EnglishName); + + public event Action? RowAdded; public MainWindowViewModel( IItemCache cache, @@ -85,7 +87,12 @@ private async Task OpenAbout() } [RelayCommand] - private void AddRow() => TrackedItems.Add(CreateTrackedItem()); + private void AddRow() + { + var vm = CreateTrackedItem(); + TrackedItems.Add(vm); + RowAdded?.Invoke(vm); + } private void LoadTrackedItems() { diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index b4f0289..b4dab7f 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -7,6 +7,8 @@ xmlns:local="clr-namespace:WarframeMarketTracker" xmlns:conv="using:WarframeMarketTracker.Converters" xmlns:assets="using:WarframeMarketTracker.Assets" + xmlns:ctrl="using:WarframeMarketTracker.Controls" + xmlns:m="using:WarframeMarketTracker.Models" mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="500" x:Class="WarframeMarketTracker.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -60,7 +62,7 @@ - + @@ -68,11 +70,23 @@ + Margin="0,0,15,0"> + + + + + + + + + + { + var container = TrackedItemsControl.ContainerFromItem(vm); + container?.BringIntoView(); + + var autoComplete = container?.GetVisualDescendants().OfType().FirstOrDefault(); + autoComplete?.Focus(); + }, DispatcherPriority.Loaded); + } + protected override void OnClosing(WindowClosingEventArgs e) { // Hide to tray instead of closing @@ -23,9 +46,9 @@ private void AutoCompleteBox_OnLostFocus(object? sender, RoutedEventArgs e) { if (sender is AutoCompleteBox box && box.DataContext is TrackedItemViewModel vm) { - // If the text in the box doesn't exactly match the selected item - // or any item in the source, clear it. - if (box.SelectedItem == null) + // SelectedItem is only set when the user picks from the dropdown — typed-but-not-picked names and saved-state loads leave it null. + // IsValid is the authoritative signal that the current text resolves to a real item, so use that to decide whether to clear. + if (!vm.IsValid) { box.Text = string.Empty; vm.ItemName = string.Empty; From af93223098e3b9d34cb954d68b30dc5639723a74 Mon Sep 17 00:00:00 2001 From: Kelby Hunt Date: Wed, 6 May 2026 12:53:14 -0700 Subject: [PATCH 3/3] Optimize cache & polling logic - Feature: Add DialogService and DI plumbing for UI management. - Perf: Optimize ItemCache with FrozenDictionary and TryGetByName. - Logic: Refine notification state tracking and price-drop sensitivity. - Refactor: Decouple ViewModels from direct window logic and replace LINQ lookups with cache hits. --- Program.cs | 3 ++ Services/DialogService.cs | 36 +++++++++++++++++++++++ Services/ItemCache.cs | 30 ++++++++++++++----- Services/MarketPollingService.cs | 47 +++++++++++++----------------- ViewModels/MainWindowViewModel.cs | 20 ++++--------- ViewModels/TrackedItemViewModel.cs | 4 +-- 6 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 Services/DialogService.cs diff --git a/Program.cs b/Program.cs index 3b762a2..9b31381 100644 --- a/Program.cs +++ b/Program.cs @@ -77,8 +77,11 @@ public static void Main(string[] args) // 4. Components & UI services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); + services.AddSingleton>(sp => sp.GetRequiredService); services.AddSingleton(); + services.AddSingleton(sp => new Lazy(sp.GetRequiredService)); services.AddSingleton(); }) .Build(); diff --git a/Services/DialogService.cs b/Services/DialogService.cs new file mode 100644 index 0000000..8547411 --- /dev/null +++ b/Services/DialogService.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using WarframeMarketTracker.ViewModels; +using WarframeMarketTracker.Views; + +namespace WarframeMarketTracker.Services; + +public interface IDialogService +{ + Task ShowAboutAsync(); +} + +public class DialogService : IDialogService +{ + private readonly Lazy _mainViewModel; + private readonly Func _aboutViewModelFactory; + + public DialogService( + Lazy mainViewModel, + Func aboutViewModelFactory) + { + _mainViewModel = mainViewModel; + _aboutViewModelFactory = aboutViewModelFactory; + } + + public async Task ShowAboutAsync() + { + var owner = _mainViewModel.Value.Owner; + if (owner == null) return; + + var viewModel = _aboutViewModelFactory(); + var window = new AboutWindow { DataContext = viewModel }; + _ = viewModel.CheckForUpdateAsync(); + await window.ShowDialog(owner); + } +} diff --git a/Services/ItemCache.cs b/Services/ItemCache.cs index c2ed754..5225b1c 100644 --- a/Services/ItemCache.cs +++ b/Services/ItemCache.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Net.Http.Json; @@ -16,26 +17,41 @@ public interface IItemCache int Count { get; } + bool TryGetByName(string name, [NotNullWhen(true)] out ItemShort? item); + Task InitializeAsync(CancellationToken ct); } public class ItemCache : IItemCache { private readonly HttpClient _httpClient; - private FrozenDictionary? _cache; + private FrozenDictionary? _lookup; - public IReadOnlyList Items => _cache?.Values.ToList() ?? []; + // Materialized once at hydration; the catalog is immutable for the app's lifetime, so callers + // get a stable, pre-sorted snapshot without re-allocating on every binding read. + public IReadOnlyList Items { get; private set; } = []; public int Count => Items.Count; public ItemCache(IHttpClientFactory httpClientFactory) => _httpClient = httpClientFactory.CreateClient("WfmApi"); - public async Task InitializeAsync(CancellationToken ct) + public bool TryGetByName(string name, [NotNullWhen(true)] out ItemShort? item) { - var response = await _httpClient.GetFromJsonAsync>>("items", ct); - if (response?.Data != null) + if (_lookup is not null && _lookup.TryGetValue(name, out var found)) { - _cache = response.Data.ToFrozenDictionary(i => i.EnglishName, StringComparer.OrdinalIgnoreCase); + item = found; + return true; } + item = null; + return false; + } + + public async Task InitializeAsync(CancellationToken ct) + { + var response = await _httpClient.GetFromJsonAsync>>("items", ct); + if (response?.Data == null) return; + + _lookup = response.Data.ToFrozenDictionary(i => i.EnglishName, StringComparer.OrdinalIgnoreCase); + Items = response.Data.OrderBy(i => i.EnglishName).ToArray(); } } \ No newline at end of file diff --git a/Services/MarketPollingService.cs b/Services/MarketPollingService.cs index ff302c7..21e35ce 100644 --- a/Services/MarketPollingService.cs +++ b/Services/MarketPollingService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -17,15 +18,15 @@ public partial class MarketPollingService : BackgroundService private readonly IUserInterfaceNotificationService _uiNotificationService; private readonly ILogger _logger; - // Tracks the last price we notified about per slug — only re-notify if a lower price appears - private readonly Dictionary _lastNotifiedPrice = new(); + // Tracks the offer we last surfaced per slug. Used both for re-notify dedupe (only fire on a lower price) and + // for clearing the UI label when that specific deal disappears (seller offline / item sold). + private readonly Dictionary _lastNotified = new(); - // Tracks the order ID we last surfaced to the UI per slug — used to detect when that specific deal disappears - // (seller went offline, item sold) so we can clear the label. - private readonly Dictionary _lastNotifiedOfferId = new(); + // Orders the user chose to ignore for this session. Concurrent because OrderIgnored fires on the UI thread while + // the poll loop reads from a background thread. + private readonly ConcurrentDictionary _ignoredOrderIds = new(); - // Orders the user chose to ignore for this session - private readonly HashSet _ignoredOrderIds = new(); + private sealed record NotifiedOffer(string OrderId, int Platinum); public MarketPollingService( IWarframeMarketService api, @@ -40,7 +41,7 @@ public MarketPollingService( _uiNotificationService = uiNotificationService; _logger = logger; - _uiNotificationService.OrderIgnored += orderId => _ignoredOrderIds.Add(orderId); + _uiNotificationService.OrderIgnored += orderId => _ignoredOrderIds.TryAdd(orderId, 0); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -75,17 +76,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var lowestOrder = orders.FirstOrDefault(); var hasDeal = lowestOrder != null && lowestOrder.Platinum <= item.TargetPlatinum; - var isIgnored = hasDeal && _ignoredOrderIds.Contains(lowestOrder!.Id); + var isIgnored = hasDeal && _ignoredOrderIds.ContainsKey(lowestOrder!.Id); if (hasDeal && !isIgnored) { - var isNewLowerPrice = !_lastNotifiedPrice.TryGetValue(item.Slug, out var lastPrice) - || lowestOrder!.Platinum < lastPrice; + var hadNotified = _lastNotified.TryGetValue(item.Slug, out var last); + var isNewLowerPrice = !hadNotified || lowestOrder!.Platinum < last!.Platinum; if (isNewLowerPrice) { - _lastNotifiedPrice[item.Slug] = lowestOrder!.Platinum; - _lastNotifiedOfferId[item.Slug] = lowestOrder.Id; + _lastNotified[item.Slug] = new NotifiedOffer(lowestOrder!.Id, lowestOrder.Platinum); LogDealFoundWithTargetPrice(item.ItemName, lowestOrder.Platinum, item.TargetPlatinum); @@ -102,28 +102,21 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _uiNotificationService.SurfaceOffer(offer); await _toast.ShowOfferAsync(offer); } - else if (_lastNotifiedOfferId.TryGetValue(item.Slug, out var lastId) - && lastId != lowestOrder!.Id) + else if (last!.OrderId != lowestOrder!.Id) { // Same/higher price than last notification, but a different seller is now cheapest - the original deal is gone. LogDealCleared(item.ItemName); - - _lastNotifiedOfferId.Remove(item.Slug); - _lastNotifiedPrice.Remove(item.Slug); + + _lastNotified.Remove(item.Slug); _uiNotificationService.ClearOffer(item.Slug); } } - else + else if (_lastNotified.Remove(item.Slug)) { // Either no qualifying deal exists, or the cheapest is the deal the user ignored. - _lastNotifiedPrice.Remove(item.Slug); - - if (_lastNotifiedOfferId.Remove(item.Slug)) - { - LogDealCleared(item.ItemName); - - _uiNotificationService.ClearOffer(item.Slug); - } + LogDealCleared(item.ItemName); + + _uiNotificationService.ClearOffer(item.Slug); } } catch (Exception ex) diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 0649c23..cb625bd 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -6,10 +6,8 @@ using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.DependencyInjection; using WarframeMarketTracker.Models; using WarframeMarketTracker.Services; -using WarframeMarketTracker.Views; namespace WarframeMarketTracker.ViewModels; @@ -19,7 +17,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly ITrackedItemRegistry _registry; private readonly ITrackedItemStore _store; private readonly IUserInterfaceNotificationService _uiNotificationService; - private readonly IServiceProvider _services; + private readonly IDialogService _dialogService; private bool _isLoading; private static readonly HashSet PersistedProperties = @@ -32,7 +30,7 @@ public partial class MainWindowViewModel : ViewModelBase public ObservableCollection TrackedItems { get; } = new(); - public IEnumerable AvailableItems => _cache.Items.OrderBy(i => i.EnglishName); + public IReadOnlyList AvailableItems => _cache.Items; public event Action? RowAdded; @@ -41,13 +39,13 @@ public MainWindowViewModel( ITrackedItemRegistry registry, ITrackedItemStore store, IUserInterfaceNotificationService uiNotificationService, - IServiceProvider services) + IDialogService dialogService) { _cache = cache; _registry = registry; _store = store; _uiNotificationService = uiNotificationService; - _services = services; + _dialogService = dialogService; TrackedItems.CollectionChanged += OnTrackedItemsChanged; _uiNotificationService.OfferAvailable += OnOfferAvailable; @@ -76,15 +74,7 @@ private void OnOfferCleared(string slug) } [RelayCommand] - private async Task OpenAbout() - { - if (Owner is null) return; - - var vm = _services.GetRequiredService(); - var window = new AboutWindow { DataContext = vm }; - _ = vm.CheckForUpdateAsync(); - await window.ShowDialog(Owner); - } + private Task OpenAbout() => _dialogService.ShowAboutAsync(); [RelayCommand] private void AddRow() diff --git a/ViewModels/TrackedItemViewModel.cs b/ViewModels/TrackedItemViewModel.cs index 3f7a49d..e81d6bd 100644 --- a/ViewModels/TrackedItemViewModel.cs +++ b/ViewModels/TrackedItemViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -89,8 +88,7 @@ partial void OnItemNameChanged(string value) UnregisterIfNeeded(); BestOffer = null; - _resolvedItem = _cache.Items.FirstOrDefault(i => - i.EnglishName.Equals(value, StringComparison.OrdinalIgnoreCase)); + _cache.TryGetByName(value, out _resolvedItem); IsValid = _resolvedItem != null; MarketUrl = _resolvedItem != null