(bugfix): keep message selection and preview across list rebuilds

This commit is contained in:
Maksym Sadovnychyy 2026-09-20 19:42:51 +02:00
parent 06936e2bbd
commit 8c76e2f82e
16 changed files with 450 additions and 107 deletions

View File

@ -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.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.3.7" date="2026-09-20">
<description>
<p>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.</p>
</description>
</release>
<release version="0.3.6" date="2026-09-20">
<description>
<p>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.</p>

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.3.6</Version>
<Version>0.3.7</Version>
<Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors>

View File

@ -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<Result<MailMessageBody>> GetMessageCoreAsync(
string folder,
@ -350,7 +350,7 @@ public sealed class ImapMailSession : IMailSession {
IReadOnlyList<uint> 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<Result> MoveMessagesCoreAsync(
string fromFolder,
@ -407,7 +407,7 @@ public sealed class ImapMailSession : IMailSession {
IReadOnlyList<uint> 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<Result> 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<Result> 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<Result> EmptyFolderCoreAsync(
string folder,

View File

@ -144,7 +144,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
if (body is null)
return Result<MailMessageBody>.NotFound(null, "Message file is missing.");
return Result<MailMessageBody>.Ok(body);
}, cancellationToken);
}, cancellationToken, interactive: true);
public Task<Result<MailboxQuota?>> 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<Result> SetMessageFlagsAsync(
string folder,
@ -211,7 +211,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
}
return Task.FromResult(Result.Ok());
}, cancellationToken);
}, cancellationToken, interactive: true);
}
public Task<Result> 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<Result> RenameFolderAsync(
string folder,
@ -273,7 +273,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
}
return Task.FromResult(Result.Ok());
}, cancellationToken);
}, cancellationToken, interactive: true);
public Task<Result> SetFolderSeenAsync(
string folder,

View File

@ -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<T> RunAsync<T>(Func<Task<T>> 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<T> RunAsync<T>(Func<Task<T>> work, CancellationToken cancellationToken) =>
RunCoreAsync(work, cancellationToken, interactive: false);
public async Task RunAsync(Func<Task> work, CancellationToken cancellationToken) {
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
await _io.WaitAsync(cancellationToken).ConfigureAwait(false);
try {
public Task<T> RunAsync<T>(Func<Task<T>> work, CancellationToken cancellationToken, bool interactive) =>
RunCoreAsync(work, cancellationToken, interactive);
public Task RunAsync(Func<Task> work, CancellationToken cancellationToken) =>
RunCoreAsync(Wrap(work), cancellationToken, interactive: false);
public Task RunAsync(Func<Task> work, CancellationToken cancellationToken, bool interactive) =>
RunCoreAsync(Wrap(work), cancellationToken, interactive);
private static Func<Task<int>> Wrap(Func<Task> work) =>
async () => {
await work().ConfigureAwait(false);
return 0;
};
private async Task<T> RunCoreAsync<T>(
Func<Task<T>> 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);
}
}

View File

@ -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<Result<MailMessageBody>> GetMessageCoreAsync(
string folder,

View File

@ -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<Result<MailboxQuota?>> 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<Result> SetMessageFlagsAsync(
string folder,
IReadOnlyList<uint> 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<Result> 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<Result> 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<Result> SetFolderSeenAsync(
string folder,

View File

@ -0,0 +1,39 @@
namespace MaksIT.PostClient.Shared;
public readonly struct MailMessageKey : IEquatable<MailMessageKey> {
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);
}

View File

@ -0,0 +1,70 @@
namespace MaksIT.PostClient.Shared;
public static class MailMessageList {
public static void Merge<T>(
IList<T> target,
IReadOnlyList<T> incoming,
Func<T, uint> uid,
Action<T, T> patch) {
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(uid);
ArgumentNullException.ThrowIfNull(patch);
var byUid = new Dictionary<uint, T>();
foreach (var row in target)
byUid[uid(row)] = row;
var keep = new HashSet<uint>();
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<T>(
IReadOnlyCollection<T> selected,
IReadOnlyCollection<T> visible,
Func<T, MailMessageKey> 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<T> Resolve<T>(
IEnumerable<MailMessageKey> keys,
IEnumerable<T> rows,
Func<T, MailMessageKey> key) {
ArgumentNullException.ThrowIfNull(keys);
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(key);
var map = new Dictionary<MailMessageKey, T>();
foreach (var row in rows)
map.TryAdd(key(row), row);
var list = new List<T>();
var seen = new HashSet<MailMessageKey>();
foreach (var item in keys) {
if (!seen.Add(item))
continue;
if (map.TryGetValue(item, out var row))
list.Add(row);
}
return list;
}
}

View File

@ -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<string>();
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);
}
}

View File

@ -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<Row> 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() {

View File

@ -265,7 +265,6 @@
</Border>
<DataGrid x:Name="MessagesGrid"
ItemsSource="{Binding VisibleMessages}"
SelectedItem="{Binding SelectedMessage, Mode=OneWayToSource}"
SelectionMode="Extended"
AutoGenerateColumns="False"
IsReadOnly="True"

View File

@ -369,7 +369,7 @@ public partial class MainWindow : Window {
}
private void OnMessagesSelectionChanged(object? sender, SelectionChangedEventArgs e) {
if (_syncingGridSelection)
if (_syncingGridSelection || DataContext is MainViewModel { IsSyncingMessageList: true })
return;
SyncSelectionFromGrid();
}
@ -399,7 +399,7 @@ public partial class MainWindow : Window {
}
private void OnMessagesPointerReleased(object? sender, PointerReleasedEventArgs e) {
if (_dragging || _syncingGridSelection)
if (_dragging || _syncingGridSelection || DataContext is MainViewModel { IsSyncingMessageList: true })
return;
SyncSelectionFromGrid();
}

View File

@ -33,10 +33,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private int _searchGen;
private int _indexPause;
private bool _syncingList;
private readonly HashSet<MailMessageKey> _selectedKeys = [];
private readonly HashSet<string> _indexMailboxes = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _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<MessageRowViewModel> SelectedMessages { get; } = [];
public bool IsSyncingMessageList =>
_syncingList;
public List<FolderNodeViewModel> SelectedFolderNodes { get; } = [];
public IReadOnlyList<ChoiceRow> IncomingProtocols =>
@ -1405,8 +1409,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
public void SetSelectedMessages(IEnumerable<MessageRowViewModel> 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<MessageRowViewModel> 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<MailArchiveHeader> 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<MessageRowViewModel>(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<MessageRowViewModel> TargetRows() =>
SelectedMessages.Count > 0
? SelectedMessages.ToList()
: SelectedMessage is { } one ? [one] : [];
private IReadOnlyList<MessageRowViewModel> TargetRows() {
var keys = _selectedKeys.Count > 0
? _selectedKeys
: SelectedMessage is { } one ? [one.Key] : [];
return MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key);
}
private IReadOnlyList<FolderNodeViewModel> TargetFolderNodes() {
if (SelectedFolderNodes.Count > 0)
@ -4095,7 +4157,15 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private async Task MoveRowsAsync(IReadOnlyList<MessageRowViewModel> 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<MessageRowViewModel>? apply,
IReadOnlyList<MessageRowViewModel>? 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();

View File

@ -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));
}