From 8c76e2f82eb27073ac7384825c2f67b220ab502f Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 20 Sep 2026 19:42:51 +0200 Subject: [PATCH] (bugfix): keep message selection and preview across list rebuilds --- CHANGELOG.md | 8 + data/eu.postclient.desktop.metainfo.xml | 5 + src/Directory.Build.props | 2 +- .../ImapMailSession.cs | 10 +- .../LocalStoreSession.cs | 10 +- .../MailSessionGate.cs | 57 +++-- .../Pop3MailSession.cs | 2 +- .../PstMailSession.cs | 10 +- .../MailMessageKey.cs | 39 +++ .../MailMessageList.cs | 70 ++++++ src/MaksIT.PostClient.Tests/ProtocolTests.cs | 38 +++ src/MaksIT.PostClient.Tests/SharedTests.cs | 59 +++++ src/MaksIT.PostClient.UI/MainWindow.axaml | 1 - src/MaksIT.PostClient.UI/MainWindow.axaml.cs | 4 +- .../ViewModels/MainViewModel.cs | 223 ++++++++++++------ .../ViewModels/RowViewModels.cs | 19 ++ 16 files changed, 450 insertions(+), 107 deletions(-) create mode 100644 src/MaksIT.PostClient.Shared/MailMessageKey.cs create mode 100644 src/MaksIT.PostClient.Shared/MailMessageList.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d5a5e..7071052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.3.7] - 2026-09-20 + +Selected mail keeps its preview while the folder list refreshes. + +### Fixed + +- Selecting a message keeps that mail and its preview when the folder list refreshes; delete and move use the row's folder, and opening mail is not stuck behind a catalog scan. + ## [0.3.6] - 2026-09-20 Folder unread and retention days sit in separate columns. Sign-in and Gmail trash retention survive a rebuild. diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index 3d6cdc6..0ad6a78 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

Selected mail keeps its preview while the folder list refreshes. Delete and move use the row's folder, and opening a message is not stuck behind a catalog scan.

+
+

Folder unread and retention days stay in separate columns. A new install keeps Gmail and Outlook sign-in. Gmail Trash retention shows on the nested folder and permanently deletes old trash.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 74fdebe..11e0238 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.6 + 0.3.7 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/ImapMailSession.cs b/src/MaksIT.PostClient.Client/ImapMailSession.cs index 5752947..7611f91 100644 --- a/src/MaksIT.PostClient.Client/ImapMailSession.cs +++ b/src/MaksIT.PostClient.Client/ImapMailSession.cs @@ -239,7 +239,7 @@ public sealed class ImapMailSession : IMailSession { string folder, uint id, CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken); + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); private async Task> GetMessageCoreAsync( string folder, @@ -350,7 +350,7 @@ public sealed class ImapMailSession : IMailSession { IReadOnlyList ids, string toFolder, CancellationToken cancellationToken = default) => - _io.RunAsync(() => MoveMessagesCoreAsync(fromFolder, ids, toFolder, cancellationToken), cancellationToken); + _io.RunAsync(() => MoveMessagesCoreAsync(fromFolder, ids, toFolder, cancellationToken), cancellationToken, interactive: true); private async Task MoveMessagesCoreAsync( string fromFolder, @@ -407,7 +407,7 @@ public sealed class ImapMailSession : IMailSession { IReadOnlyList ids, MailFlagUpdate update, CancellationToken cancellationToken = default) => - _io.RunAsync(() => SetMessageFlagsCoreAsync(folder, ids, update, cancellationToken), cancellationToken); + _io.RunAsync(() => SetMessageFlagsCoreAsync(folder, ids, update, cancellationToken), cancellationToken, interactive: true); private async Task SetMessageFlagsCoreAsync( string folder, @@ -450,7 +450,7 @@ public sealed class ImapMailSession : IMailSession { string name, string? parentFolder, CancellationToken cancellationToken = default) => - _io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken); + _io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken, interactive: true); private async Task CreateFolderCoreAsync( string name, @@ -586,7 +586,7 @@ public sealed class ImapMailSession : IMailSession { string folder, string? trashFolder, CancellationToken cancellationToken = default) => - _io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken); + _io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken, interactive: true); private async Task EmptyFolderCoreAsync( string folder, diff --git a/src/MaksIT.PostClient.Client/LocalStoreSession.cs b/src/MaksIT.PostClient.Client/LocalStoreSession.cs index cbc056c..c5fcaa5 100644 --- a/src/MaksIT.PostClient.Client/LocalStoreSession.cs +++ b/src/MaksIT.PostClient.Client/LocalStoreSession.cs @@ -144,7 +144,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { if (body is null) return Result.NotFound(null, "Message file is missing."); return Result.Ok(body); - }, cancellationToken); + }, cancellationToken, interactive: true); public Task> GetQuotaAsync(CancellationToken cancellationToken = default) { _ = cancellationToken; @@ -193,7 +193,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { } return Task.FromResult(Result.Ok()); - }, cancellationToken); + }, cancellationToken, interactive: true); public Task SetMessageFlagsAsync( string folder, @@ -211,7 +211,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { } return Task.FromResult(Result.Ok()); - }, cancellationToken); + }, cancellationToken, interactive: true); } public Task CreateFolderAsync( @@ -223,7 +223,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { var full = string.IsNullOrWhiteSpace(parentFolder) ? name : parentFolder + "/" + name; Directory.CreateDirectory(FolderDir(full)); return Task.FromResult(Result.Ok()); - }, cancellationToken); + }, cancellationToken, interactive: true); public Task RenameFolderAsync( string folder, @@ -273,7 +273,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore { } return Task.FromResult(Result.Ok()); - }, cancellationToken); + }, cancellationToken, interactive: true); public Task SetFolderSeenAsync( string folder, diff --git a/src/MaksIT.PostClient.Client/MailSessionGate.cs b/src/MaksIT.PostClient.Client/MailSessionGate.cs index d41b025..2ad30f4 100644 --- a/src/MaksIT.PostClient.Client/MailSessionGate.cs +++ b/src/MaksIT.PostClient.Client/MailSessionGate.cs @@ -3,26 +3,53 @@ namespace MaksIT.PostClient.Client; internal sealed class MailSessionGate : IDisposable { private readonly SemaphoreSlim _io = new(1, 1); + private int _interactiveWaiting; - public async Task RunAsync(Func> work, CancellationToken cancellationToken) { - await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); - await _io.WaitAsync(cancellationToken).ConfigureAwait(false); - try { - return await work().ConfigureAwait(false); - } - finally { - _io.Release(); - } - } + public Task RunAsync(Func> work, CancellationToken cancellationToken) => + RunCoreAsync(work, cancellationToken, interactive: false); - public async Task RunAsync(Func work, CancellationToken cancellationToken) { - await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); - await _io.WaitAsync(cancellationToken).ConfigureAwait(false); - try { + public Task RunAsync(Func> work, CancellationToken cancellationToken, bool interactive) => + RunCoreAsync(work, cancellationToken, interactive); + + public Task RunAsync(Func work, CancellationToken cancellationToken) => + RunCoreAsync(Wrap(work), cancellationToken, interactive: false); + + public Task RunAsync(Func work, CancellationToken cancellationToken, bool interactive) => + RunCoreAsync(Wrap(work), cancellationToken, interactive); + + private static Func> Wrap(Func work) => + async () => { await work().ConfigureAwait(false); + return 0; + }; + + private async Task RunCoreAsync( + Func> work, + CancellationToken cancellationToken, + bool interactive) { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + if (interactive) + Interlocked.Increment(ref _interactiveWaiting); + try { + while (true) { + await _io.WaitAsync(cancellationToken).ConfigureAwait(false); + if (!interactive && Volatile.Read(ref _interactiveWaiting) > 0) { + _io.Release(); + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + continue; + } + + try { + return await work().ConfigureAwait(false); + } + finally { + _io.Release(); + } + } } finally { - _io.Release(); + if (interactive) + Interlocked.Decrement(ref _interactiveWaiting); } } diff --git a/src/MaksIT.PostClient.Client/Pop3MailSession.cs b/src/MaksIT.PostClient.Client/Pop3MailSession.cs index 0917d5a..a591892 100644 --- a/src/MaksIT.PostClient.Client/Pop3MailSession.cs +++ b/src/MaksIT.PostClient.Client/Pop3MailSession.cs @@ -168,7 +168,7 @@ public sealed class Pop3MailSession : IMailSession { string folder, uint id, CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken); + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); private async Task> GetMessageCoreAsync( string folder, diff --git a/src/MaksIT.PostClient.Client/PstMailSession.cs b/src/MaksIT.PostClient.Client/PstMailSession.cs index f1bfc9f..892643b 100644 --- a/src/MaksIT.PostClient.Client/PstMailSession.cs +++ b/src/MaksIT.PostClient.Client/PstMailSession.cs @@ -123,7 +123,7 @@ public sealed class PstMailSession : IMailSession { string folder, uint id, CancellationToken cancellationToken = default) => - _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken); + _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); public Task> GetQuotaAsync(CancellationToken cancellationToken = default) { _ = cancellationToken; @@ -153,20 +153,20 @@ public sealed class PstMailSession : IMailSession { foreach (var itemId in itemIds) tx.MoveItem(itemId, dest, cancellationToken); return Task.FromResult(Result.Ok()); - }), cancellationToken); + }), cancellationToken, interactive: true); public Task SetMessageFlagsAsync( string folder, IReadOnlyList ids, MailFlagUpdate update, CancellationToken cancellationToken = default) => - _io.RunAsync(() => SetFlagsCoreAsync(folder, ids, update, cancellationToken), cancellationToken); + _io.RunAsync(() => SetFlagsCoreAsync(folder, ids, update, cancellationToken), cancellationToken, interactive: true); public Task CreateFolderAsync( string name, string? parentFolder, CancellationToken cancellationToken = default) => - _io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken); + _io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken, interactive: true); public Task RenameFolderAsync( string folder, @@ -182,7 +182,7 @@ public sealed class PstMailSession : IMailSession { string folder, string? trashFolder, CancellationToken cancellationToken = default) => - _io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken); + _io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken, interactive: true); public Task SetFolderSeenAsync( string folder, diff --git a/src/MaksIT.PostClient.Shared/MailMessageKey.cs b/src/MaksIT.PostClient.Shared/MailMessageKey.cs new file mode 100644 index 0000000..f4053ec --- /dev/null +++ b/src/MaksIT.PostClient.Shared/MailMessageKey.cs @@ -0,0 +1,39 @@ +namespace MaksIT.PostClient.Shared; + + +public readonly struct MailMessageKey : IEquatable { + public string MailboxId { get; } + + public string Folder { get; } + + public uint Id { get; } + + public MailMessageKey(string mailboxId, string folder, uint id) { + MailboxId = mailboxId ?? ""; + Folder = folder ?? ""; + Id = id; + } + + public static MailMessageKey Of(string? mailboxId, string? folder, uint id) => + new((mailboxId ?? "").Trim(), (folder ?? "").Trim(), id); + + public bool Equals(MailMessageKey other) => + Id == other.Id + && MailboxId.Equals(other.MailboxId, StringComparison.OrdinalIgnoreCase) + && Folder.Equals(other.Folder, StringComparison.OrdinalIgnoreCase); + + public override bool Equals(object? obj) => + obj is MailMessageKey other && Equals(other); + + public override int GetHashCode() => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(MailboxId), + StringComparer.OrdinalIgnoreCase.GetHashCode(Folder), + Id); + + public static bool operator ==(MailMessageKey left, MailMessageKey right) => + left.Equals(right); + + public static bool operator !=(MailMessageKey left, MailMessageKey right) => + !left.Equals(right); +} diff --git a/src/MaksIT.PostClient.Shared/MailMessageList.cs b/src/MaksIT.PostClient.Shared/MailMessageList.cs new file mode 100644 index 0000000..303d2d6 --- /dev/null +++ b/src/MaksIT.PostClient.Shared/MailMessageList.cs @@ -0,0 +1,70 @@ +namespace MaksIT.PostClient.Shared; + + +public static class MailMessageList { + public static void Merge( + IList target, + IReadOnlyList incoming, + Func uid, + Action patch) { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(incoming); + ArgumentNullException.ThrowIfNull(uid); + ArgumentNullException.ThrowIfNull(patch); + var byUid = new Dictionary(); + foreach (var row in target) + byUid[uid(row)] = row; + var keep = new HashSet(); + foreach (var row in incoming) { + var id = uid(row); + keep.Add(id); + if (byUid.TryGetValue(id, out var existing)) + patch(existing, row); + else { + target.Add(row); + byUid[id] = row; + } + } + + for (var i = target.Count - 1; i >= 0; i--) { + if (!keep.Contains(uid(target[i]))) + target.RemoveAt(i); + } + } + + public static bool CoversAll( + IReadOnlyCollection selected, + IReadOnlyCollection visible, + Func key) { + ArgumentNullException.ThrowIfNull(selected); + ArgumentNullException.ThrowIfNull(visible); + ArgumentNullException.ThrowIfNull(key); + if (visible.Count == 0 || selected.Count < visible.Count) + return false; + var vis = visible.Select(key).ToHashSet(); + var sel = selected.Select(key).ToHashSet(); + return vis.SetEquals(sel); + } + + public static IReadOnlyList Resolve( + IEnumerable keys, + IEnumerable rows, + Func key) { + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(key); + var map = new Dictionary(); + foreach (var row in rows) + map.TryAdd(key(row), row); + var list = new List(); + var seen = new HashSet(); + foreach (var item in keys) { + if (!seen.Add(item)) + continue; + if (map.TryGetValue(item, out var row)) + list.Add(row); + } + + return list; + } +} diff --git a/src/MaksIT.PostClient.Tests/ProtocolTests.cs b/src/MaksIT.PostClient.Tests/ProtocolTests.cs index db4da9b..7d9ab5c 100644 --- a/src/MaksIT.PostClient.Tests/ProtocolTests.cs +++ b/src/MaksIT.PostClient.Tests/ProtocolTests.cs @@ -496,6 +496,44 @@ public class MailSessionGateTests { SynchronizationContext.SetSynchronizationContext(previous); } } + + [Fact] + public async Task Interactive_RunsBeforeQueuedBackground() { + using var gate = new MailSessionGate(); + var token = TestContext.Current.CancellationToken; + var firstHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var order = new List(); + + var first = gate.RunAsync(async () => { + lock (order) + order.Add("first"); + firstHold.SetResult(); + await firstRelease.Task.WaitAsync(token); + return 0; + }, token); + + await firstHold.Task.WaitAsync(token); + + var queued = gate.RunAsync(() => { + lock (order) + order.Add("queued"); + return Task.FromResult(0); + }, token); + + await Task.Delay(50, token); + + var interactive = gate.RunAsync(() => { + lock (order) + order.Add("interactive"); + return Task.FromResult(0); + }, token, interactive: true); + + await Task.Delay(50, token); + firstRelease.SetResult(); + await Task.WhenAll(first, interactive, queued).WaitAsync(token); + Assert.Equal(["first", "interactive", "queued"], order); + } } diff --git a/src/MaksIT.PostClient.Tests/SharedTests.cs b/src/MaksIT.PostClient.Tests/SharedTests.cs index dd09587..379206f 100644 --- a/src/MaksIT.PostClient.Tests/SharedTests.cs +++ b/src/MaksIT.PostClient.Tests/SharedTests.cs @@ -296,6 +296,65 @@ public class MailIdTests { } +public class MailMessageKeyTests { + [Fact] + public void Of_TrimsAndIgnoresCase() { + var left = MailMessageKey.Of(" Box ", "INBOX", 12); + var right = MailMessageKey.Of("box", "inbox", 12); + Assert.Equal(left, right); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} + + +public class MailMessageListTests { + private sealed class Row { + public required uint Id { get; init; } + + public string Folder { get; init; } = "INBOX"; + + public string MailboxId { get; init; } = "box"; + + public string Subject { get; set; } = ""; + + public MailMessageKey Key => + MailMessageKey.Of(MailboxId, Folder, Id); + } + + [Fact] + public void Merge_KeepsExistingUidInstance() { + var selected = new Row { Id = 7, Subject = "open" }; + List messages = [selected, new() { Id = 8, Subject = "gone" }]; + MailMessageList.Merge( + messages, + [new Row { Id = 7, Subject = "updated" }, new Row { Id = 9, Subject = "fresh" }], + row => row.Id, + (dest, src) => dest.Subject = src.Subject); + Assert.Equal(2, messages.Count); + Assert.Same(selected, messages.Single(row => row.Id == 7)); + Assert.Equal("updated", selected.Subject); + Assert.Contains(messages, row => row.Id == 9); + Assert.DoesNotContain(messages, row => row.Id == 8); + } + + [Fact] + public void Resolve_MapsKeysOntoLiveRows() { + var stale = new Row { Id = 1 }; + var live = new Row { Id = 1 }; + var resolved = MailMessageList.Resolve([stale.Key], [live], row => row.Key); + Assert.Same(live, Assert.Single(resolved)); + } + + [Fact] + public void CoversAll_RequiresMatchingKeys() { + Row[] visible = [new() { Id = 1 }, new() { Id = 2 }]; + Row[] selected = [new() { Id = 1 }, new() { Id = 9 }]; + Assert.False(MailMessageList.CoversAll(selected, visible, row => row.Key)); + Assert.True(MailMessageList.CoversAll(visible, visible, row => row.Key)); + } +} + + public class MailChainTests { [Fact] public void Order_IndentsRepliesAndSortsByLatest() { diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml b/src/MaksIT.PostClient.UI/MainWindow.axaml index 86bfbfa..98c532f 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml @@ -265,7 +265,6 @@ _selectedKeys = []; private readonly HashSet _indexMailboxes = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogDone = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogFolders = new(StringComparer.OrdinalIgnoreCase); - private const int IndexCatalogChunk = 2_000; + private const int IndexCatalogChunk = 250; private const int IndexUiChunk = 250; private readonly StatusLineHold _indexHold; private readonly StatusLineHold _semanticHold; @@ -241,6 +242,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { public List SelectedMessages { get; } = []; + public bool IsSyncingMessageList => + _syncingList; + public List SelectedFolderNodes { get; } = []; public IReadOnlyList IncomingProtocols => @@ -1405,8 +1409,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { public void SetSelectedMessages(IEnumerable rows) { SelectedMessages.Clear(); - foreach (var row in rows) + _selectedKeys.Clear(); + foreach (var row in rows) { SelectedMessages.Add(row); + _selectedKeys.Add(row.Key); + } + if (SelectedMessages.Count > 0 && (SelectedMessage is null || !SelectedMessages.Contains(SelectedMessage))) SelectedMessage = SelectedMessages[0]; NotifyMessageCommands(); @@ -1663,32 +1671,39 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { row => row.Header.Priority = priority); } - [RelayCommand(CanExecute = nameof(CanOrganize))] + [RelayCommand(CanExecute = nameof(CanOrganize), AllowConcurrentExecutions = true)] private async Task DeleteMessagesAsync() { var rows = TargetRows(); if (rows.Count == 0) return; - if (VisibleMessages.Count > 0 - && rows.Count >= VisibleMessages.Count - && rows.Count >= Messages.Count) + if (MailMessageList.CoversAll(rows, VisibleMessages, row => row.Key)) rows = VisibleMessages.ToList(); - var box = SelectedMailbox; - var folder = SelectedFolder; - if (box is null || folder is null) - return; - if (!MailRetention.IsTrash(folder.Name, folder.FullName)) { - var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); - if (!string.IsNullOrWhiteSpace(trash) - && !trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase)) { - await MoveRowsAsync(rows, trash); - return; + foreach (var group in rows.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { + var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; + var folderName = group.Key.Folder; + var groupRows = group.ToList(); + if (box is null || string.IsNullOrWhiteSpace(folderName)) + continue; + if (!MailRetention.IsTrash(null, folderName)) { + var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); + if (!string.IsNullOrWhiteSpace(trash) + && !trash.Equals(folderName, StringComparison.OrdinalIgnoreCase)) { + await MoveIdsAsync( + TargetIds(groupRows), + trash, + fromFolder: folderName, + rows: groupRows, + sourceMailboxId: box.Id); + continue; + } } + + if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, groupRows)) + continue; + DropArchived(box.Id, folderName, TargetIds(groupRows)); + RemoveRows(groupRows); } - if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, rows)) - return; - DropArchived(box.Id, folder.FullName, TargetIds(rows)); - RemoveRows(rows); Status = "Deleted."; } @@ -1750,6 +1765,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { && SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) { Messages.Clear(); VisibleMessages.Clear(); + SelectedMessages.Clear(); + _selectedKeys.Clear(); SelectedMessage = null; ClearReading(); RefreshFolderStats(); @@ -2247,19 +2264,40 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return; if (MailFolderRole.IsNamespace(value.Name, value.FullName)) { _folderLoad?.Cancel(); + _work?.Cancel(); Messages.Clear(); VisibleMessages.Clear(); SelectedMessages.Clear(); + _selectedKeys.Clear(); + SelectedMessage = null; + ClearReading(); Status = ""; return; } + if (SelectedMessage is not null + && !SelectedMessage.Header.Folder.Equals(value.FullName, StringComparison.OrdinalIgnoreCase)) { + SelectedMessages.Clear(); + _selectedKeys.Clear(); + SelectedMessage = null; + } + + if (_reading is not null + && !_reading.Header.Folder.Equals(value.FullName, StringComparison.OrdinalIgnoreCase)) + ClearReading(); KickFolderLoad(value.FullName); } partial void OnSelectedMessageChanged(MessageRowViewModel? value) { NotifyMessageCommands(); - if (_syncingList || value is null) + if (value is null) { + if (!_syncingList) + _selectedKeys.Clear(); + return; + } + + _selectedKeys.Add(value.Key); + if (IsLoadedBody(value.Header)) return; _ = OpenMessageAsync(value); } @@ -3314,6 +3352,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { if (SelectedMailbox is null || SelectedFolder is null) { VisibleMessages.Clear(); SelectedMessages.Clear(); + _selectedKeys.Clear(); SelectedMessage = null; NotifyMessageCommands(); RestoreMessageGridSelection(); @@ -3401,12 +3440,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } private void ApplyVisibleRows(List rows) { - var keepKeys = SelectedMessages - .Select(r => (r.MailboxId, r.Header.Folder, r.Header.Id)) - .ToHashSet(); - var keepOneKey = SelectedMessage is not null - ? (SelectedMessage.MailboxId, SelectedMessage.Header.Folder, SelectedMessage.Header.Id) - : ((string MailboxId, string Folder, uint Id)?)null; + var keepKeys = _selectedKeys.Count > 0 + ? _selectedKeys.ToHashSet() + : SelectedMessages.Select(row => row.Key).ToHashSet(); + var keepOneKey = SelectedMessage?.Key; _syncingList = true; VisibleMessages.Clear(); try { @@ -3441,20 +3478,23 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } } - var keep = VisibleMessages - .Where(r => keepKeys.Contains((r.MailboxId, r.Header.Folder, r.Header.Id))) - .ToList(); + var keep = MailMessageList.Resolve(keepKeys, VisibleMessages, row => row.Key); SetSelectedMessages(keep); SelectedMessage = keepOneKey is { } key - ? VisibleMessages.FirstOrDefault(r => - r.MailboxId == key.MailboxId && r.Header.Folder == key.Folder && r.Header.Id == key.Id) + ? VisibleMessages.FirstOrDefault(r => r.Key == key) : keep.FirstOrDefault(); + if (SelectedMessage is { } selected && !IsLoadedBody(selected.Header)) + _ = OpenMessageAsync(selected); } finally { - _syncingList = false; - RefreshFolderStats(); - NotifyMessageCommands(); - RestoreMessageGridSelection(); + try { + RefreshFolderStats(); + NotifyMessageCommands(); + RestoreMessageGridSelection(); + } + finally { + _syncingList = false; + } } } @@ -3731,14 +3771,34 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { BindArchive(mailboxId, folder, _archive.ListFolder(mailboxId, folder)); private void BindArchive(string mailboxId, string folder, IReadOnlyList rows) { - Messages.Clear(); - SelectedMessages.Clear(); - SelectedMessage = null; - ClearReading(); + if (Messages.Count > 0 + && (Messages[0].MailboxId != mailboxId + || !Messages[0].Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { + Messages.Clear(); + if (SelectedMessage is not null + && (!SelectedMessage.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase) + || !SelectedMessage.Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { + SelectedMessage = null; + ClearReading(); + } + } + + var incoming = new List(rows.Count); foreach (var row in rows) { var header = MailArchiveMap.ToHeader(row); ReconcileHeader(header, folder); - Messages.Add(new MessageRowViewModel { MailboxId = mailboxId, Header = header }); + incoming.Add(new MessageRowViewModel { MailboxId = mailboxId, Header = header }); + } + + MailMessageList.Merge( + Messages, + incoming, + row => row.Header.Id, + (dest, src) => dest.ApplyHeader(src.Header)); + if (SelectedMessage is { } selected + && !Messages.Any(row => row.Key == selected.Key)) { + SelectedMessage = null; + ClearReading(); } if (TracksCertifiedReceipts(mailboxId) && MailFolderRole.Kind(null, folder) == "sent") @@ -4060,10 +4120,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { partial void OnHasReadingFilesChanged(bool value) => SaveAttachmentsZipCommand.NotifyCanExecuteChanged(); - private IReadOnlyList TargetRows() => - SelectedMessages.Count > 0 - ? SelectedMessages.ToList() - : SelectedMessage is { } one ? [one] : []; + private IReadOnlyList TargetRows() { + var keys = _selectedKeys.Count > 0 + ? _selectedKeys + : SelectedMessage is { } one ? [one.Key] : []; + return MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key); + } private IReadOnlyList TargetFolderNodes() { if (SelectedFolderNodes.Count > 0) @@ -4095,7 +4157,15 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private async Task MoveRowsAsync(IReadOnlyList rows, string destFolder) { if (rows.Count == 0) return; - await MoveIdsAsync(TargetIds(rows), destFolder, rows: rows); + foreach (var group in rows.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { + var groupRows = group.ToList(); + await MoveIdsAsync( + TargetIds(groupRows), + destFolder, + fromFolder: group.Key.Folder, + rows: groupRows, + sourceMailboxId: group.Key.MailboxId); + } } private async Task MoveIdsAsync( @@ -4296,39 +4366,46 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { MailFlagUpdate update, Action? apply, IReadOnlyList? rows = null) { - var session = ActiveSession; - if (session is not { IsConnected: true, SupportsFolders: true } || SelectedFolder is null) { - Status = Copy.Connecting; - return false; - } var targets = rows ?? TargetRows(); if (targets.Count == 0) return false; var ok = false; await RunAsync(Copy.UpdatingFlags, async token => { - var result = await session.SetMessageFlagsAsync( - SelectedFolder.FullName, - TargetIds(targets), - update, - token); - if (!result.IsSuccess) { - Status = string.Join(" ", result.Messages); - return; - } - - if (update.Seen is { } seen) - ApplyUnreadDelta(SelectedFolder, targets, seen); - if (apply is not null) { - foreach (var row in targets) { - apply(row); - row.RefreshMarks(); + foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { + var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; + var session = SessionFor(box); + if (session is not { IsConnected: true, SupportsFolders: true }) { + Status = Copy.Connecting; + return; } - if (SelectedMailbox is not null) - _archive.UpdateFlags( - SelectedMailbox.Id, - SelectedFolder.FullName, - targets.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged))); + var folder = group.Key.Folder; + var groupRows = group.ToList(); + var result = await session.SetMessageFlagsAsync( + folder, + TargetIds(groupRows), + update, + token); + if (!result.IsSuccess) { + Status = string.Join(" ", result.Messages); + return; + } + + if (update.Seen is { } seen && SelectedFolder is { } current + && current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) + ApplyUnreadDelta(current, groupRows, seen); + if (apply is not null) { + foreach (var row in groupRows) { + apply(row); + row.RefreshMarks(); + } + + if (box is not null) + _archive.UpdateFlags( + box.Id, + folder, + groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged))); + } } NotifyFolderCounts(); @@ -4344,6 +4421,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { foreach (var row in rows) { Messages.Remove(row); VisibleMessages.Remove(row); + _selectedKeys.Remove(row.Key); } if (SelectedMessage is not null && rows.Contains(SelectedMessage)) @@ -4455,6 +4533,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { return; } + _work?.Cancel(); _folderLoad?.Cancel(); _folderLoad?.Dispose(); var cts = new CancellationTokenSource(); diff --git a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs index f3f7634..b22c8b2 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs @@ -314,9 +314,27 @@ public sealed class MessageRowViewModel : ObservableObject { public FontWeight Weight => Header.IsSeen ? FontWeight.Normal : FontWeight.SemiBold; + public MailMessageKey Key => + MailMessageKey.Of(MailboxId, Header.Folder, Header.Id); + + public void ApplyHeader(MailMessageHeader header) { + ArgumentNullException.ThrowIfNull(header); + Header = header; + RefreshMarks(); + } + public void RefreshMarks() { OnPropertyChanged(nameof(Subject)); OnPropertyChanged(nameof(ChainSubject)); + OnPropertyChanged(nameof(From)); + OnPropertyChanged(nameof(When)); + OnPropertyChanged(nameof(SortDate)); + OnPropertyChanged(nameof(UnreadSort)); + OnPropertyChanged(nameof(FlagSort)); + OnPropertyChanged(nameof(PrioritySort)); + OnPropertyChanged(nameof(AttachmentSort)); + OnPropertyChanged(nameof(DeliverySort)); + OnPropertyChanged(nameof(Badge)); OnPropertyChanged(nameof(UnreadMark)); OnPropertyChanged(nameof(UnreadTip)); OnPropertyChanged(nameof(FlagMark)); @@ -328,6 +346,7 @@ public sealed class MessageRowViewModel : ObservableObject { OnPropertyChanged(nameof(DeliveryMark)); OnPropertyChanged(nameof(DeliveryLabel)); OnPropertyChanged(nameof(DeliveryTip)); + OnPropertyChanged(nameof(BadgeTip)); OnPropertyChanged(nameof(Labels)); OnPropertyChanged(nameof(Weight)); }