From 27c6d121834abef08332a4d7b33a53f9636db9c9 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 12 Apr 2026 09:19:47 +0100 Subject: [PATCH] Stop grid jumping: event-driven refresh instead of 5-second timer --- src/SpamGuard/Services/ActivityLog.cs | 71 +++++++++++++++++++- src/SpamGuard/Tray/ActivityLogForm.cs | 15 +++-- src/SpamGuard/Tray/TrayApplicationContext.cs | 28 +++++--- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/SpamGuard/Services/ActivityLog.cs b/src/SpamGuard/Services/ActivityLog.cs index 4915972..d0c0bc0 100644 --- a/src/SpamGuard/Services/ActivityLog.cs +++ b/src/SpamGuard/Services/ActivityLog.cs @@ -1,5 +1,6 @@ namespace SpamGuard.Services; +using System.Text.Json; using SpamGuard.Models; public sealed class ActivityLog @@ -7,13 +8,21 @@ public sealed class ActivityLog private readonly List _entries = new(); private readonly object _lock = new(); private readonly int _maxEntries; + private readonly string? _filePath; + + public event Action? EntryChanged; public int TodayChecked => GetTodayCount(_ => true); public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam); - public ActivityLog(int maxEntries = 500) + public ActivityLog(int maxEntries = 500, string? dataDirectory = null) { _maxEntries = maxEntries; + if (dataDirectory != null) + { + _filePath = Path.Combine(dataDirectory, "activity-log.json"); + LoadFromDisk(); + } } public void Add(ActivityEntry entry) @@ -24,6 +33,8 @@ public sealed class ActivityLog if (_entries.Count > _maxEntries) _entries.RemoveAt(0); } + SaveToDisk(); + EntryChanged?.Invoke(); } public List GetRecent(int count = 100) @@ -37,6 +48,29 @@ public sealed class ActivityLog } } + public ActivityEntry? GetByIndex(int displayIndex, int displayCount) + { + lock (_lock) + { + var ordered = _entries + .OrderByDescending(e => e.Timestamp) + .Take(displayCount) + .ToList(); + return displayIndex >= 0 && displayIndex < ordered.Count ? ordered[displayIndex] : null; + } + } + + public void UpdateEntry(ActivityEntry entry, Verdict newVerdict, UserOverride userOverride) + { + lock (_lock) + { + entry.Verdict = newVerdict; + entry.Override = userOverride; + } + SaveToDisk(); + EntryChanged?.Invoke(); + } + private int GetTodayCount(Func predicate) { var today = DateTime.UtcNow.Date; @@ -45,4 +79,39 @@ public sealed class ActivityLog return _entries.Count(e => e.Timestamp.Date == today && predicate(e)); } } + + private void SaveToDisk() + { + if (_filePath == null) return; + lock (_lock) + { + var json = JsonSerializer.Serialize(_entries, new JsonSerializerOptions { WriteIndented = true }); + var tempPath = _filePath + ".tmp"; + File.WriteAllText(tempPath, json); + File.Move(tempPath, _filePath, overwrite: true); + } + } + + private void LoadFromDisk() + { + if (_filePath == null || !File.Exists(_filePath)) return; + try + { + var json = File.ReadAllText(_filePath); + var entries = JsonSerializer.Deserialize>(json); + if (entries != null) + { + lock (_lock) + { + _entries.AddRange(entries); + while (_entries.Count > _maxEntries) + _entries.RemoveAt(0); + } + } + } + catch + { + // Corrupted file -- start fresh + } + } } diff --git a/src/SpamGuard/Tray/ActivityLogForm.cs b/src/SpamGuard/Tray/ActivityLogForm.cs index 85d2643..d8c9e8e 100644 --- a/src/SpamGuard/Tray/ActivityLogForm.cs +++ b/src/SpamGuard/Tray/ActivityLogForm.cs @@ -125,6 +125,9 @@ public sealed class ActivityLogForm : Form Controls.Add(_grid); Controls.Add(_toolbar); + // Subscribe to data changes — refresh only when something actually changes + _activityLog.EntryChanged += RefreshData; + RefreshData(); } @@ -162,7 +165,6 @@ public sealed class ActivityLogForm : Form await monitor.MoveToSpamAsync(entry.Uid); RecordOverride(entry, "spam"); _activityLog.UpdateEntry(entry, Verdict.Spam, UserOverride.MarkedSpam); - RefreshData(); } catch (Exception ex) { @@ -186,7 +188,6 @@ public sealed class ActivityLogForm : Form _trustedSenders.Save(); RecordOverride(entry, "legitimate"); _activityLog.UpdateEntry(entry, Verdict.Legitimate, UserOverride.MarkedLegitimate); - RefreshData(); } catch (Exception ex) { @@ -290,7 +291,7 @@ public sealed class ActivityLogForm : Form ); var row = _grid.Rows[rowIndex]; - row.Tag = entry; // attach entry for reliable retrieval + row.Tag = entry; row.DefaultCellStyle.ForeColor = entry.Verdict switch { @@ -308,7 +309,6 @@ public sealed class ActivityLogForm : Form System.Drawing.FontStyle.Bold); } - // Match by Uid (preferred) or timestamp fallback for Uid==0 entries if (restoreIndex < 0) { if (selectedUid != 0 && entry.Uid == selectedUid) @@ -345,4 +345,11 @@ public sealed class ActivityLogForm : Form base.OnFormClosing(e); } } + + protected override void Dispose(bool disposing) + { + if (disposing) + _activityLog.EntryChanged -= RefreshData; + base.Dispose(disposing); + } } diff --git a/src/SpamGuard/Tray/TrayApplicationContext.cs b/src/SpamGuard/Tray/TrayApplicationContext.cs index 9897594..8123533 100644 --- a/src/SpamGuard/Tray/TrayApplicationContext.cs +++ b/src/SpamGuard/Tray/TrayApplicationContext.cs @@ -4,22 +4,31 @@ namespace SpamGuard.Tray; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SpamGuard.Services; +using SpamGuard.State; public sealed class TrayApplicationContext : ApplicationContext { private readonly NotifyIcon _notifyIcon; private readonly System.Windows.Forms.Timer _refreshTimer; private readonly ActivityLog _activityLog; - private readonly InboxMonitorService _monitor; + private readonly IReadOnlyList _monitors; + private readonly TrustedSenderStore _trustedSenders; + private readonly BlockedDomainStore _blockedDomains; + private readonly OverrideStore _overrideStore; private readonly IHost _host; private ActivityLogForm? _logForm; private readonly ToolStripMenuItem _pauseMenuItem; + private bool AllPaused => _monitors.All(m => m.IsPaused); + public TrayApplicationContext(IHost host) { _host = host; _activityLog = host.Services.GetRequiredService(); - _monitor = host.Services.GetRequiredService(); + _monitors = host.Services.GetServices().ToList(); + _trustedSenders = host.Services.GetRequiredService(); + _blockedDomains = host.Services.GetRequiredService(); + _overrideStore = host.Services.GetRequiredService(); _pauseMenuItem = new ToolStripMenuItem("Pause", null, OnPauseResume); @@ -39,6 +48,7 @@ public sealed class TrayApplicationContext : ApplicationContext _notifyIcon.DoubleClick += OnViewLog; + // Timer only updates tray tooltip and icon — form refresh is event-driven _refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 }; _refreshTimer.Tick += OnRefreshTick; _refreshTimer.Start(); @@ -51,8 +61,7 @@ public sealed class TrayApplicationContext : ApplicationContext var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today"; _notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip; - // Update icon based on state - if (!_monitor.IsPaused) + if (!AllPaused) { var recent = _activityLog.GetRecent(1); var hasRecentError = recent.Count > 0 @@ -61,14 +70,15 @@ public sealed class TrayApplicationContext : ApplicationContext _notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green; } - _logForm?.RefreshData(); + // Form refresh removed — ActivityLogForm subscribes to ActivityLog.EntryChanged directly } private void OnViewLog(object? sender, EventArgs e) { if (_logForm == null || _logForm.IsDisposed) { - _logForm = new ActivityLogForm(_activityLog); + _logForm = new ActivityLogForm( + _activityLog, _monitors, _trustedSenders, _blockedDomains, _overrideStore); } _logForm.Show(); _logForm.BringToFront(); @@ -76,15 +86,15 @@ public sealed class TrayApplicationContext : ApplicationContext private void OnPauseResume(object? sender, EventArgs e) { - if (_monitor.IsPaused) + if (AllPaused) { - _monitor.Resume(); + foreach (var m in _monitors) m.Resume(); _pauseMenuItem.Text = "Pause"; _notifyIcon.Icon = IconGenerator.Green; } else { - _monitor.Pause(); + foreach (var m in _monitors) m.Pause(); _pauseMenuItem.Text = "Resume"; _notifyIcon.Icon = IconGenerator.Yellow; }