mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-09-29 19:08:09 +02:00
(bugfix): keep message selection and preview across list rebuilds
This commit is contained in:
parent
06936e2bbd
commit
8c76e2f82e
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
39
src/MaksIT.PostClient.Shared/MailMessageKey.cs
Normal file
39
src/MaksIT.PostClient.Shared/MailMessageKey.cs
Normal 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);
|
||||
}
|
||||
70
src/MaksIT.PostClient.Shared/MailMessageList.cs
Normal file
70
src/MaksIT.PostClient.Shared/MailMessageList.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -265,7 +265,6 @@
|
||||
</Border>
|
||||
<DataGrid x:Name="MessagesGrid"
|
||||
ItemsSource="{Binding VisibleMessages}"
|
||||
SelectedItem="{Binding SelectedMessage, Mode=OneWayToSource}"
|
||||
SelectionMode="Extended"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user