mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-09-30 03:18: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]
|
## [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
|
## [0.3.6] - 2026-09-20
|
||||||
|
|
||||||
Folder unread and retention days sit in separate columns. Sign-in and Gmail trash retention survive a rebuild.
|
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>
|
<display_length compare="ge">768</display_length>
|
||||||
</requires>
|
</requires>
|
||||||
<releases>
|
<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">
|
<release version="0.3.6" date="2026-09-20">
|
||||||
<description>
|
<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>
|
<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>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Version>0.3.6</Version>
|
<Version>0.3.7</Version>
|
||||||
<Product>Postclient</Product>
|
<Product>Postclient</Product>
|
||||||
<AssemblyTitle>Postclient</AssemblyTitle>
|
<AssemblyTitle>Postclient</AssemblyTitle>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
|
|||||||
@ -239,7 +239,7 @@ public sealed class ImapMailSession : IMailSession {
|
|||||||
string folder,
|
string folder,
|
||||||
uint id,
|
uint id,
|
||||||
CancellationToken cancellationToken = default) =>
|
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(
|
private async Task<Result<MailMessageBody>> GetMessageCoreAsync(
|
||||||
string folder,
|
string folder,
|
||||||
@ -350,7 +350,7 @@ public sealed class ImapMailSession : IMailSession {
|
|||||||
IReadOnlyList<uint> ids,
|
IReadOnlyList<uint> ids,
|
||||||
string toFolder,
|
string toFolder,
|
||||||
CancellationToken cancellationToken = default) =>
|
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(
|
private async Task<Result> MoveMessagesCoreAsync(
|
||||||
string fromFolder,
|
string fromFolder,
|
||||||
@ -407,7 +407,7 @@ public sealed class ImapMailSession : IMailSession {
|
|||||||
IReadOnlyList<uint> ids,
|
IReadOnlyList<uint> ids,
|
||||||
MailFlagUpdate update,
|
MailFlagUpdate update,
|
||||||
CancellationToken cancellationToken = default) =>
|
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(
|
private async Task<Result> SetMessageFlagsCoreAsync(
|
||||||
string folder,
|
string folder,
|
||||||
@ -450,7 +450,7 @@ public sealed class ImapMailSession : IMailSession {
|
|||||||
string name,
|
string name,
|
||||||
string? parentFolder,
|
string? parentFolder,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken);
|
_io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken, interactive: true);
|
||||||
|
|
||||||
private async Task<Result> CreateFolderCoreAsync(
|
private async Task<Result> CreateFolderCoreAsync(
|
||||||
string name,
|
string name,
|
||||||
@ -586,7 +586,7 @@ public sealed class ImapMailSession : IMailSession {
|
|||||||
string folder,
|
string folder,
|
||||||
string? trashFolder,
|
string? trashFolder,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken);
|
_io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken, interactive: true);
|
||||||
|
|
||||||
private async Task<Result> EmptyFolderCoreAsync(
|
private async Task<Result> EmptyFolderCoreAsync(
|
||||||
string folder,
|
string folder,
|
||||||
|
|||||||
@ -144,7 +144,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
|
|||||||
if (body is null)
|
if (body is null)
|
||||||
return Result<MailMessageBody>.NotFound(null, "Message file is missing.");
|
return Result<MailMessageBody>.NotFound(null, "Message file is missing.");
|
||||||
return Result<MailMessageBody>.Ok(body);
|
return Result<MailMessageBody>.Ok(body);
|
||||||
}, cancellationToken);
|
}, cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default) {
|
public Task<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default) {
|
||||||
_ = cancellationToken;
|
_ = cancellationToken;
|
||||||
@ -193,7 +193,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(Result.Ok());
|
return Task.FromResult(Result.Ok());
|
||||||
}, cancellationToken);
|
}, cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> SetMessageFlagsAsync(
|
public Task<Result> SetMessageFlagsAsync(
|
||||||
string folder,
|
string folder,
|
||||||
@ -211,7 +211,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(Result.Ok());
|
return Task.FromResult(Result.Ok());
|
||||||
}, cancellationToken);
|
}, cancellationToken, interactive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Result> CreateFolderAsync(
|
public Task<Result> CreateFolderAsync(
|
||||||
@ -223,7 +223,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
|
|||||||
var full = string.IsNullOrWhiteSpace(parentFolder) ? name : parentFolder + "/" + name;
|
var full = string.IsNullOrWhiteSpace(parentFolder) ? name : parentFolder + "/" + name;
|
||||||
Directory.CreateDirectory(FolderDir(full));
|
Directory.CreateDirectory(FolderDir(full));
|
||||||
return Task.FromResult(Result.Ok());
|
return Task.FromResult(Result.Ok());
|
||||||
}, cancellationToken);
|
}, cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> RenameFolderAsync(
|
public Task<Result> RenameFolderAsync(
|
||||||
string folder,
|
string folder,
|
||||||
@ -273,7 +273,7 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(Result.Ok());
|
return Task.FromResult(Result.Ok());
|
||||||
}, cancellationToken);
|
}, cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> SetFolderSeenAsync(
|
public Task<Result> SetFolderSeenAsync(
|
||||||
string folder,
|
string folder,
|
||||||
|
|||||||
@ -3,10 +3,42 @@ namespace MaksIT.PostClient.Client;
|
|||||||
|
|
||||||
internal sealed class MailSessionGate : IDisposable {
|
internal sealed class MailSessionGate : IDisposable {
|
||||||
private readonly SemaphoreSlim _io = new(1, 1);
|
private readonly SemaphoreSlim _io = new(1, 1);
|
||||||
|
private int _interactiveWaiting;
|
||||||
|
|
||||||
public async Task<T> RunAsync<T>(Func<Task<T>> work, CancellationToken cancellationToken) {
|
public Task<T> RunAsync<T>(Func<Task<T>> work, CancellationToken cancellationToken) =>
|
||||||
|
RunCoreAsync(work, cancellationToken, interactive: false);
|
||||||
|
|
||||||
|
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);
|
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||||
|
if (interactive)
|
||||||
|
Interlocked.Increment(ref _interactiveWaiting);
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
await _io.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _io.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!interactive && Volatile.Read(ref _interactiveWaiting) > 0) {
|
||||||
|
_io.Release();
|
||||||
|
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await work().ConfigureAwait(false);
|
return await work().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@ -14,15 +46,10 @@ internal sealed class MailSessionGate : IDisposable {
|
|||||||
_io.Release();
|
_io.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(Func<Task> work, CancellationToken cancellationToken) {
|
|
||||||
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
|
||||||
await _io.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
try {
|
|
||||||
await work().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
_io.Release();
|
if (interactive)
|
||||||
|
Interlocked.Decrement(ref _interactiveWaiting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -168,7 +168,7 @@ public sealed class Pop3MailSession : IMailSession {
|
|||||||
string folder,
|
string folder,
|
||||||
uint id,
|
uint id,
|
||||||
CancellationToken cancellationToken = default) =>
|
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(
|
private async Task<Result<MailMessageBody>> GetMessageCoreAsync(
|
||||||
string folder,
|
string folder,
|
||||||
|
|||||||
@ -123,7 +123,7 @@ public sealed class PstMailSession : IMailSession {
|
|||||||
string folder,
|
string folder,
|
||||||
uint id,
|
uint id,
|
||||||
CancellationToken cancellationToken = default) =>
|
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) {
|
public Task<Result<MailboxQuota?>> GetQuotaAsync(CancellationToken cancellationToken = default) {
|
||||||
_ = cancellationToken;
|
_ = cancellationToken;
|
||||||
@ -153,20 +153,20 @@ public sealed class PstMailSession : IMailSession {
|
|||||||
foreach (var itemId in itemIds)
|
foreach (var itemId in itemIds)
|
||||||
tx.MoveItem(itemId, dest, cancellationToken);
|
tx.MoveItem(itemId, dest, cancellationToken);
|
||||||
return Task.FromResult(Result.Ok());
|
return Task.FromResult(Result.Ok());
|
||||||
}), cancellationToken);
|
}), cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> SetMessageFlagsAsync(
|
public Task<Result> SetMessageFlagsAsync(
|
||||||
string folder,
|
string folder,
|
||||||
IReadOnlyList<uint> ids,
|
IReadOnlyList<uint> ids,
|
||||||
MailFlagUpdate update,
|
MailFlagUpdate update,
|
||||||
CancellationToken cancellationToken = default) =>
|
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(
|
public Task<Result> CreateFolderAsync(
|
||||||
string name,
|
string name,
|
||||||
string? parentFolder,
|
string? parentFolder,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken);
|
_io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> RenameFolderAsync(
|
public Task<Result> RenameFolderAsync(
|
||||||
string folder,
|
string folder,
|
||||||
@ -182,7 +182,7 @@ public sealed class PstMailSession : IMailSession {
|
|||||||
string folder,
|
string folder,
|
||||||
string? trashFolder,
|
string? trashFolder,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken);
|
_io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken, interactive: true);
|
||||||
|
|
||||||
public Task<Result> SetFolderSeenAsync(
|
public Task<Result> SetFolderSeenAsync(
|
||||||
string folder,
|
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);
|
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 {
|
public class MailChainTests {
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Order_IndentsRepliesAndSortsByLatest() {
|
public void Order_IndentsRepliesAndSortsByLatest() {
|
||||||
|
|||||||
@ -265,7 +265,6 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<DataGrid x:Name="MessagesGrid"
|
<DataGrid x:Name="MessagesGrid"
|
||||||
ItemsSource="{Binding VisibleMessages}"
|
ItemsSource="{Binding VisibleMessages}"
|
||||||
SelectedItem="{Binding SelectedMessage, Mode=OneWayToSource}"
|
|
||||||
SelectionMode="Extended"
|
SelectionMode="Extended"
|
||||||
AutoGenerateColumns="False"
|
AutoGenerateColumns="False"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
|
|||||||
@ -369,7 +369,7 @@ public partial class MainWindow : Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnMessagesSelectionChanged(object? sender, SelectionChangedEventArgs e) {
|
private void OnMessagesSelectionChanged(object? sender, SelectionChangedEventArgs e) {
|
||||||
if (_syncingGridSelection)
|
if (_syncingGridSelection || DataContext is MainViewModel { IsSyncingMessageList: true })
|
||||||
return;
|
return;
|
||||||
SyncSelectionFromGrid();
|
SyncSelectionFromGrid();
|
||||||
}
|
}
|
||||||
@ -399,7 +399,7 @@ public partial class MainWindow : Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void OnMessagesPointerReleased(object? sender, PointerReleasedEventArgs e) {
|
private void OnMessagesPointerReleased(object? sender, PointerReleasedEventArgs e) {
|
||||||
if (_dragging || _syncingGridSelection)
|
if (_dragging || _syncingGridSelection || DataContext is MainViewModel { IsSyncingMessageList: true })
|
||||||
return;
|
return;
|
||||||
SyncSelectionFromGrid();
|
SyncSelectionFromGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,10 +33,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
private int _searchGen;
|
private int _searchGen;
|
||||||
private int _indexPause;
|
private int _indexPause;
|
||||||
private bool _syncingList;
|
private bool _syncingList;
|
||||||
|
private readonly HashSet<MailMessageKey> _selectedKeys = [];
|
||||||
private readonly HashSet<string> _indexMailboxes = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _indexMailboxes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _catalogDone = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly HashSet<string> _catalogFolders = 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 const int IndexUiChunk = 250;
|
||||||
private readonly StatusLineHold _indexHold;
|
private readonly StatusLineHold _indexHold;
|
||||||
private readonly StatusLineHold _semanticHold;
|
private readonly StatusLineHold _semanticHold;
|
||||||
@ -241,6 +242,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
|
|
||||||
public List<MessageRowViewModel> SelectedMessages { get; } = [];
|
public List<MessageRowViewModel> SelectedMessages { get; } = [];
|
||||||
|
|
||||||
|
public bool IsSyncingMessageList =>
|
||||||
|
_syncingList;
|
||||||
|
|
||||||
public List<FolderNodeViewModel> SelectedFolderNodes { get; } = [];
|
public List<FolderNodeViewModel> SelectedFolderNodes { get; } = [];
|
||||||
|
|
||||||
public IReadOnlyList<ChoiceRow> IncomingProtocols =>
|
public IReadOnlyList<ChoiceRow> IncomingProtocols =>
|
||||||
@ -1405,8 +1409,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
|
|
||||||
public void SetSelectedMessages(IEnumerable<MessageRowViewModel> rows) {
|
public void SetSelectedMessages(IEnumerable<MessageRowViewModel> rows) {
|
||||||
SelectedMessages.Clear();
|
SelectedMessages.Clear();
|
||||||
foreach (var row in rows)
|
_selectedKeys.Clear();
|
||||||
|
foreach (var row in rows) {
|
||||||
SelectedMessages.Add(row);
|
SelectedMessages.Add(row);
|
||||||
|
_selectedKeys.Add(row.Key);
|
||||||
|
}
|
||||||
|
|
||||||
if (SelectedMessages.Count > 0 && (SelectedMessage is null || !SelectedMessages.Contains(SelectedMessage)))
|
if (SelectedMessages.Count > 0 && (SelectedMessage is null || !SelectedMessages.Contains(SelectedMessage)))
|
||||||
SelectedMessage = SelectedMessages[0];
|
SelectedMessage = SelectedMessages[0];
|
||||||
NotifyMessageCommands();
|
NotifyMessageCommands();
|
||||||
@ -1663,32 +1671,39 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
row => row.Header.Priority = priority);
|
row => row.Header.Priority = priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOrganize))]
|
[RelayCommand(CanExecute = nameof(CanOrganize), AllowConcurrentExecutions = true)]
|
||||||
private async Task DeleteMessagesAsync() {
|
private async Task DeleteMessagesAsync() {
|
||||||
var rows = TargetRows();
|
var rows = TargetRows();
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
return;
|
return;
|
||||||
if (VisibleMessages.Count > 0
|
if (MailMessageList.CoversAll(rows, VisibleMessages, row => row.Key))
|
||||||
&& rows.Count >= VisibleMessages.Count
|
|
||||||
&& rows.Count >= Messages.Count)
|
|
||||||
rows = VisibleMessages.ToList();
|
rows = VisibleMessages.ToList();
|
||||||
var box = SelectedMailbox;
|
foreach (var group in rows.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) {
|
||||||
var folder = SelectedFolder;
|
var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox;
|
||||||
if (box is null || folder is null)
|
var folderName = group.Key.Folder;
|
||||||
return;
|
var groupRows = group.ToList();
|
||||||
if (!MailRetention.IsTrash(folder.Name, folder.FullName)) {
|
if (box is null || string.IsNullOrWhiteSpace(folderName))
|
||||||
|
continue;
|
||||||
|
if (!MailRetention.IsTrash(null, folderName)) {
|
||||||
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
|
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
|
||||||
if (!string.IsNullOrWhiteSpace(trash)
|
if (!string.IsNullOrWhiteSpace(trash)
|
||||||
&& !trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase)) {
|
&& !trash.Equals(folderName, StringComparison.OrdinalIgnoreCase)) {
|
||||||
await MoveRowsAsync(rows, trash);
|
await MoveIdsAsync(
|
||||||
return;
|
TargetIds(groupRows),
|
||||||
|
trash,
|
||||||
|
fromFolder: folderName,
|
||||||
|
rows: groupRows,
|
||||||
|
sourceMailboxId: box.Id);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, rows))
|
if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, groupRows))
|
||||||
return;
|
continue;
|
||||||
DropArchived(box.Id, folder.FullName, TargetIds(rows));
|
DropArchived(box.Id, folderName, TargetIds(groupRows));
|
||||||
RemoveRows(rows);
|
RemoveRows(groupRows);
|
||||||
|
}
|
||||||
|
|
||||||
Status = "Deleted.";
|
Status = "Deleted.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1750,6 +1765,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
&& SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) {
|
&& SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) {
|
||||||
Messages.Clear();
|
Messages.Clear();
|
||||||
VisibleMessages.Clear();
|
VisibleMessages.Clear();
|
||||||
|
SelectedMessages.Clear();
|
||||||
|
_selectedKeys.Clear();
|
||||||
SelectedMessage = null;
|
SelectedMessage = null;
|
||||||
ClearReading();
|
ClearReading();
|
||||||
RefreshFolderStats();
|
RefreshFolderStats();
|
||||||
@ -2247,19 +2264,40 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
return;
|
return;
|
||||||
if (MailFolderRole.IsNamespace(value.Name, value.FullName)) {
|
if (MailFolderRole.IsNamespace(value.Name, value.FullName)) {
|
||||||
_folderLoad?.Cancel();
|
_folderLoad?.Cancel();
|
||||||
|
_work?.Cancel();
|
||||||
Messages.Clear();
|
Messages.Clear();
|
||||||
VisibleMessages.Clear();
|
VisibleMessages.Clear();
|
||||||
SelectedMessages.Clear();
|
SelectedMessages.Clear();
|
||||||
|
_selectedKeys.Clear();
|
||||||
|
SelectedMessage = null;
|
||||||
|
ClearReading();
|
||||||
Status = "";
|
Status = "";
|
||||||
return;
|
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);
|
KickFolderLoad(value.FullName);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedMessageChanged(MessageRowViewModel? value) {
|
partial void OnSelectedMessageChanged(MessageRowViewModel? value) {
|
||||||
NotifyMessageCommands();
|
NotifyMessageCommands();
|
||||||
if (_syncingList || value is null)
|
if (value is null) {
|
||||||
|
if (!_syncingList)
|
||||||
|
_selectedKeys.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedKeys.Add(value.Key);
|
||||||
|
if (IsLoadedBody(value.Header))
|
||||||
return;
|
return;
|
||||||
_ = OpenMessageAsync(value);
|
_ = OpenMessageAsync(value);
|
||||||
}
|
}
|
||||||
@ -3314,6 +3352,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
if (SelectedMailbox is null || SelectedFolder is null) {
|
if (SelectedMailbox is null || SelectedFolder is null) {
|
||||||
VisibleMessages.Clear();
|
VisibleMessages.Clear();
|
||||||
SelectedMessages.Clear();
|
SelectedMessages.Clear();
|
||||||
|
_selectedKeys.Clear();
|
||||||
SelectedMessage = null;
|
SelectedMessage = null;
|
||||||
NotifyMessageCommands();
|
NotifyMessageCommands();
|
||||||
RestoreMessageGridSelection();
|
RestoreMessageGridSelection();
|
||||||
@ -3401,12 +3440,10 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyVisibleRows(List<MessageRowViewModel> rows) {
|
private void ApplyVisibleRows(List<MessageRowViewModel> rows) {
|
||||||
var keepKeys = SelectedMessages
|
var keepKeys = _selectedKeys.Count > 0
|
||||||
.Select(r => (r.MailboxId, r.Header.Folder, r.Header.Id))
|
? _selectedKeys.ToHashSet()
|
||||||
.ToHashSet();
|
: SelectedMessages.Select(row => row.Key).ToHashSet();
|
||||||
var keepOneKey = SelectedMessage is not null
|
var keepOneKey = SelectedMessage?.Key;
|
||||||
? (SelectedMessage.MailboxId, SelectedMessage.Header.Folder, SelectedMessage.Header.Id)
|
|
||||||
: ((string MailboxId, string Folder, uint Id)?)null;
|
|
||||||
_syncingList = true;
|
_syncingList = true;
|
||||||
VisibleMessages.Clear();
|
VisibleMessages.Clear();
|
||||||
try {
|
try {
|
||||||
@ -3441,21 +3478,24 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var keep = VisibleMessages
|
var keep = MailMessageList.Resolve(keepKeys, VisibleMessages, row => row.Key);
|
||||||
.Where(r => keepKeys.Contains((r.MailboxId, r.Header.Folder, r.Header.Id)))
|
|
||||||
.ToList();
|
|
||||||
SetSelectedMessages(keep);
|
SetSelectedMessages(keep);
|
||||||
SelectedMessage = keepOneKey is { } key
|
SelectedMessage = keepOneKey is { } key
|
||||||
? VisibleMessages.FirstOrDefault(r =>
|
? VisibleMessages.FirstOrDefault(r => r.Key == key)
|
||||||
r.MailboxId == key.MailboxId && r.Header.Folder == key.Folder && r.Header.Id == key.Id)
|
|
||||||
: keep.FirstOrDefault();
|
: keep.FirstOrDefault();
|
||||||
|
if (SelectedMessage is { } selected && !IsLoadedBody(selected.Header))
|
||||||
|
_ = OpenMessageAsync(selected);
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
_syncingList = false;
|
try {
|
||||||
RefreshFolderStats();
|
RefreshFolderStats();
|
||||||
NotifyMessageCommands();
|
NotifyMessageCommands();
|
||||||
RestoreMessageGridSelection();
|
RestoreMessageGridSelection();
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
_syncingList = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MailChainItem ChainItem(MessageRowViewModel row) =>
|
private static MailChainItem ChainItem(MessageRowViewModel row) =>
|
||||||
@ -3731,14 +3771,34 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
BindArchive(mailboxId, folder, _archive.ListFolder(mailboxId, folder));
|
BindArchive(mailboxId, folder, _archive.ListFolder(mailboxId, folder));
|
||||||
|
|
||||||
private void BindArchive(string mailboxId, string folder, IReadOnlyList<MailArchiveHeader> rows) {
|
private void BindArchive(string mailboxId, string folder, IReadOnlyList<MailArchiveHeader> rows) {
|
||||||
|
if (Messages.Count > 0
|
||||||
|
&& (Messages[0].MailboxId != mailboxId
|
||||||
|
|| !Messages[0].Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) {
|
||||||
Messages.Clear();
|
Messages.Clear();
|
||||||
SelectedMessages.Clear();
|
if (SelectedMessage is not null
|
||||||
|
&& (!SelectedMessage.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !SelectedMessage.Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) {
|
||||||
SelectedMessage = null;
|
SelectedMessage = null;
|
||||||
ClearReading();
|
ClearReading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var incoming = new List<MessageRowViewModel>(rows.Count);
|
||||||
foreach (var row in rows) {
|
foreach (var row in rows) {
|
||||||
var header = MailArchiveMap.ToHeader(row);
|
var header = MailArchiveMap.ToHeader(row);
|
||||||
ReconcileHeader(header, folder);
|
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")
|
if (TracksCertifiedReceipts(mailboxId) && MailFolderRole.Kind(null, folder) == "sent")
|
||||||
@ -4060,10 +4120,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
partial void OnHasReadingFilesChanged(bool value) =>
|
partial void OnHasReadingFilesChanged(bool value) =>
|
||||||
SaveAttachmentsZipCommand.NotifyCanExecuteChanged();
|
SaveAttachmentsZipCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
private IReadOnlyList<MessageRowViewModel> TargetRows() =>
|
private IReadOnlyList<MessageRowViewModel> TargetRows() {
|
||||||
SelectedMessages.Count > 0
|
var keys = _selectedKeys.Count > 0
|
||||||
? SelectedMessages.ToList()
|
? _selectedKeys
|
||||||
: SelectedMessage is { } one ? [one] : [];
|
: SelectedMessage is { } one ? [one.Key] : [];
|
||||||
|
return MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key);
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<FolderNodeViewModel> TargetFolderNodes() {
|
private IReadOnlyList<FolderNodeViewModel> TargetFolderNodes() {
|
||||||
if (SelectedFolderNodes.Count > 0)
|
if (SelectedFolderNodes.Count > 0)
|
||||||
@ -4095,7 +4157,15 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
private async Task MoveRowsAsync(IReadOnlyList<MessageRowViewModel> rows, string destFolder) {
|
private async Task MoveRowsAsync(IReadOnlyList<MessageRowViewModel> rows, string destFolder) {
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
return;
|
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(
|
private async Task MoveIdsAsync(
|
||||||
@ -4296,19 +4366,24 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
MailFlagUpdate update,
|
MailFlagUpdate update,
|
||||||
Action<MessageRowViewModel>? apply,
|
Action<MessageRowViewModel>? apply,
|
||||||
IReadOnlyList<MessageRowViewModel>? rows = null) {
|
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();
|
var targets = rows ?? TargetRows();
|
||||||
if (targets.Count == 0)
|
if (targets.Count == 0)
|
||||||
return false;
|
return false;
|
||||||
var ok = false;
|
var ok = false;
|
||||||
await RunAsync(Copy.UpdatingFlags, async token => {
|
await RunAsync(Copy.UpdatingFlags, async token => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folder = group.Key.Folder;
|
||||||
|
var groupRows = group.ToList();
|
||||||
var result = await session.SetMessageFlagsAsync(
|
var result = await session.SetMessageFlagsAsync(
|
||||||
SelectedFolder.FullName,
|
folder,
|
||||||
TargetIds(targets),
|
TargetIds(groupRows),
|
||||||
update,
|
update,
|
||||||
token);
|
token);
|
||||||
if (!result.IsSuccess) {
|
if (!result.IsSuccess) {
|
||||||
@ -4316,19 +4391,21 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.Seen is { } seen)
|
if (update.Seen is { } seen && SelectedFolder is { } current
|
||||||
ApplyUnreadDelta(SelectedFolder, targets, seen);
|
&& current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
ApplyUnreadDelta(current, groupRows, seen);
|
||||||
if (apply is not null) {
|
if (apply is not null) {
|
||||||
foreach (var row in targets) {
|
foreach (var row in groupRows) {
|
||||||
apply(row);
|
apply(row);
|
||||||
row.RefreshMarks();
|
row.RefreshMarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SelectedMailbox is not null)
|
if (box is not null)
|
||||||
_archive.UpdateFlags(
|
_archive.UpdateFlags(
|
||||||
SelectedMailbox.Id,
|
box.Id,
|
||||||
SelectedFolder.FullName,
|
folder,
|
||||||
targets.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)));
|
groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NotifyFolderCounts();
|
NotifyFolderCounts();
|
||||||
@ -4344,6 +4421,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
foreach (var row in rows) {
|
foreach (var row in rows) {
|
||||||
Messages.Remove(row);
|
Messages.Remove(row);
|
||||||
VisibleMessages.Remove(row);
|
VisibleMessages.Remove(row);
|
||||||
|
_selectedKeys.Remove(row.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SelectedMessage is not null && rows.Contains(SelectedMessage))
|
if (SelectedMessage is not null && rows.Contains(SelectedMessage))
|
||||||
@ -4455,6 +4533,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_work?.Cancel();
|
||||||
_folderLoad?.Cancel();
|
_folderLoad?.Cancel();
|
||||||
_folderLoad?.Dispose();
|
_folderLoad?.Dispose();
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
|
|||||||
@ -314,9 +314,27 @@ public sealed class MessageRowViewModel : ObservableObject {
|
|||||||
public FontWeight Weight =>
|
public FontWeight Weight =>
|
||||||
Header.IsSeen ? FontWeight.Normal : FontWeight.SemiBold;
|
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() {
|
public void RefreshMarks() {
|
||||||
OnPropertyChanged(nameof(Subject));
|
OnPropertyChanged(nameof(Subject));
|
||||||
OnPropertyChanged(nameof(ChainSubject));
|
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(UnreadMark));
|
||||||
OnPropertyChanged(nameof(UnreadTip));
|
OnPropertyChanged(nameof(UnreadTip));
|
||||||
OnPropertyChanged(nameof(FlagMark));
|
OnPropertyChanged(nameof(FlagMark));
|
||||||
@ -328,6 +346,7 @@ public sealed class MessageRowViewModel : ObservableObject {
|
|||||||
OnPropertyChanged(nameof(DeliveryMark));
|
OnPropertyChanged(nameof(DeliveryMark));
|
||||||
OnPropertyChanged(nameof(DeliveryLabel));
|
OnPropertyChanged(nameof(DeliveryLabel));
|
||||||
OnPropertyChanged(nameof(DeliveryTip));
|
OnPropertyChanged(nameof(DeliveryTip));
|
||||||
|
OnPropertyChanged(nameof(BadgeTip));
|
||||||
OnPropertyChanged(nameof(Labels));
|
OnPropertyChanged(nameof(Labels));
|
||||||
OnPropertyChanged(nameof(Weight));
|
OnPropertyChanged(nameof(Weight));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user