Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/BetterMail.App/MainWindowViewModel.MessageActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ internal async Task DeleteDraftQuickAsync(LocalDraft draft)
var current = await _store.GetLocalDraftAsync(draft.Id);
if (current is null || current.IsQueued) return;
await _store.QueueDraftDeletionAsync(current);
// A refresh started before this commit must not resurrect its stale snapshot.
Interlocked.Increment(ref _draftRefreshVersion);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh collections after invalidating a pending snapshot

When quick deletion overlaps another draft operation—such as the background reconciliation started during initialization or a composer save—this increment can invalidate that operation's already-read post-change snapshot. RefreshDraftsAsync then returns without applying anything, while DeleteDraftQuickAsync removes only its own draft; if the subsequent sync is blocked processing the remote deletion, unrelated imported/saved drafts or outbox changes remain invisible until a later refresh. Apply a fresh post-deletion snapshot or otherwise preserve the unrelated changes instead of only invalidating the pending refresh.

Useful? React with 👍 / 👎.

var action = (await _store.GetMailActionsAsync()).FirstOrDefault(item => item.Kind == MailActionKind.DeleteDraft && item.ItemId == current.Id);
if (action is not null) ShowQueuedAction(action);
await feedback;
Expand Down
14 changes: 12 additions & 2 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1875,19 +1875,29 @@ DraftSyncStatus.UnsupportedAttachment or
}
}

private int _draftRefreshVersion;

private async Task RefreshDraftsAsync()
{
if (_store is null)
{
return;
}
var version = Interlocked.Increment(ref _draftRefreshVersion);
var drafts = await _store.GetLocalDraftSummariesAsync();
Replace(Drafts, drafts.Where(static draft => !draft.IsQueued));
Replace(Outbox, drafts.Where(static draft => draft.IsQueued && !draft.SendAccepted));
if (!TryApplyDraftSnapshot(version, drafts)) return;
await RefreshBusyActionsAsync();
RebuildVisibleDrafts();
}

private bool TryApplyDraftSnapshot(int version, IReadOnlyList<LocalDraft> drafts)
{
if (version != Volatile.Read(ref _draftRefreshVersion)) return false;
Replace(Drafts, drafts.Where(static draft => !draft.IsQueued));
Replace(Outbox, drafts.Where(static draft => draft.IsQueued && !draft.SendAccepted));
return true;
}

private async Task PrimeNewMailNotificationsAsync()
{
if (_store is null)
Expand Down
4 changes: 4 additions & 0 deletions tests/BetterMail.Tests/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,15 @@ public async Task DraftQuickDeletionDoesNotWaitForOtherDraftsToSync()
var second = first with { Id = "second", ProviderDraftId = "remote-second" };
await store.SaveLocalDraftAsync(first, token); await store.SaveLocalDraftAsync(second, token);
vm.Drafts.Add(first); vm.Drafts.Add(second);
// Model a draft snapshot captured before deletion but delivered after both deletions.
var staleVersion = (int)typeof(MainWindowViewModel).GetField("_draftRefreshVersion", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(vm)!;
var deleting = vm.DeleteDraftQuickAsync(first);
Assert.True(vm.IsDraftDeletionPending(first));
await deleting.WaitAsync(TimeSpan.FromSeconds(5), token);
await vm.DeleteDraftQuickAsync(second).WaitAsync(TimeSpan.FromSeconds(5), token);
Assert.False(provider.DeleteRelease.Task.IsCompleted);
var applied = (bool)typeof(MainWindowViewModel).GetMethod("TryApplyDraftSnapshot", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.Invoke(vm, [staleVersion, new LocalDraft[] { first, second }])!;
Assert.False(applied);
Assert.DoesNotContain(vm.Drafts, draft => draft.Id == first.Id || draft.Id == second.Id);
Assert.True(await store.IsDraftPendingDeletionAsync(first.Id, token));
Assert.True(await store.IsDraftPendingDeletionAsync(second.Id, token));
Expand Down
Loading