diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index f143d6d..62078a7 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -54,6 +54,10 @@ jobs:
if: runner.os == 'Linux'
timeout-minutes: 2
run: xvfb-run -a dotnet run --project tools/BetterMail.UiPreview --configuration Release --no-build -- artifacts/compose-input --compose-input
+ - name: Check recipient drag and drop
+ if: runner.os == 'Linux'
+ timeout-minutes: 2
+ run: xvfb-run -a dotnet run --project tools/BetterMail.UiPreview --configuration Release --no-build -- artifacts/recipient-dragging --recipient-dragging
- name: Smoke test published Linux app
if: runner.os == 'Linux'
run: xvfb-run -a python3 scripts/smoke-startup.py artifacts/smoke/BetterMail.dll
diff --git a/src/BetterMail.App/ComposeWindow.axaml b/src/BetterMail.App/ComposeWindow.axaml
index 5960b73..ad646c6 100644
--- a/src/BetterMail.App/ComposeWindow.axaml
+++ b/src/BetterMail.App/ComposeWindow.axaml
@@ -27,6 +27,9 @@
+
@@ -41,14 +44,18 @@
-
+
-
diff --git a/src/BetterMail.App/ComposeWindow.axaml.cs b/src/BetterMail.App/ComposeWindow.axaml.cs
index 6e708b3..229603a 100644
--- a/src/BetterMail.App/ComposeWindow.axaml.cs
+++ b/src/BetterMail.App/ComposeWindow.axaml.cs
@@ -9,6 +9,11 @@ namespace BetterMail.App;
public sealed partial class ComposeWindow : Window
{
+ private sealed record RecipientDrag(ComposeWindowViewModel Owner, ComposeRecipientField Source, ComposeRecipientToken Token);
+ private static readonly DataFormat RecipientDragFormat = DataFormat.CreateInProcessFormat("BetterMail.Recipient");
+ private PointerPressedEventArgs? _recipientDragStart;
+ private Avalonia.Point _recipientDragOrigin;
+ private RecipientDrag? _recipientDrag;
private bool _closeAfterSave;
private IFilesProvider? _filesProvider;
@@ -173,6 +178,86 @@ private static void RecipientFieldPressed(object? sender, PointerPressedEventArg
border.GetVisualDescendants().OfType().FirstOrDefault()?.Focus();
}
+ private void RecipientTokenPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Border { DataContext: ComposeRecipientToken token } chip ||
+ DataContext is not ComposeWindowViewModel { CanChangeAttachments: true } viewModel ||
+ !e.GetCurrentPoint(chip).Properties.IsLeftButtonPressed ||
+ e.Source is Avalonia.Visual visual && visual.GetSelfAndVisualAncestors().Any(ancestor => ancestor is Button)) return;
+ var sourceBorder = chip.GetVisualAncestors().OfType()
+ .FirstOrDefault(border => border.Classes.Contains("recipientField"));
+ if (sourceBorder?.DataContext is not ComposeRecipientField source) return;
+ sourceBorder.GetVisualDescendants().OfType().FirstOrDefault()?.Focus();
+ _recipientDragStart = e;
+ _recipientDragOrigin = e.GetPosition(this);
+ _recipientDrag = new(viewModel, source, token);
+ e.Pointer.Capture(chip);
+ e.Handled = true;
+ }
+
+ private async void RecipientTokenMoved(object? sender, PointerEventArgs e)
+ {
+ if (_recipientDragStart is not { } pressed || _recipientDrag is not { } drag ||
+ !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
+ var position = e.GetPosition(this);
+ if (Math.Abs(position.X - _recipientDragOrigin.X) < 6 && Math.Abs(position.Y - _recipientDragOrigin.Y) < 6) return;
+ _recipientDragStart = null;
+ _recipientDrag = null;
+ e.Pointer.Capture(null);
+ var data = new DataTransfer();
+ data.Add(DataTransferItem.Create(RecipientDragFormat, drag));
+ e.Handled = true;
+ try { await DragDrop.DoDragDropAsync(pressed, data, DragDropEffects.Move); }
+ finally
+ {
+ foreach (var border in this.GetVisualDescendants().OfType())
+ border.Classes.Remove("recipientDropTarget");
+ }
+ }
+
+ private void RecipientTokenReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ _recipientDragStart = null;
+ _recipientDrag = null;
+ e.Pointer.Capture(null);
+ }
+
+ private void RecipientTokenCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ _recipientDragStart = null;
+ _recipientDrag = null;
+ }
+
+ private bool CanDropRecipient(Border border, RecipientDrag drag) =>
+ ReferenceEquals(DataContext, drag.Owner) && border.DataContext is ComposeRecipientField target &&
+ drag.Owner.CanMoveRecipient(drag.Source, target, drag.Token);
+
+ private void RecipientDragOver(object? sender, DragEventArgs e)
+ {
+ if (sender is not Border border || e.DataTransfer.TryGetValue(RecipientDragFormat) is not { } drag) return;
+ var allowed = CanDropRecipient(border, drag);
+ border.Classes.Set("recipientDropTarget", allowed);
+ e.DragEffects = allowed ? DragDropEffects.Move : DragDropEffects.None;
+ e.Handled = true;
+ }
+
+ private static void RecipientDragLeave(object? sender, DragEventArgs e)
+ {
+ if (sender is Border border) border.Classes.Remove("recipientDropTarget");
+ }
+
+ private void RecipientDropped(object? sender, DragEventArgs e)
+ {
+ if (sender is not Border border || e.DataTransfer.TryGetValue(RecipientDragFormat) is not { } drag) return;
+ border.Classes.Remove("recipientDropTarget");
+ e.DragEffects = CanDropRecipient(border, drag) &&
+ drag.Owner.MoveRecipient(drag.Source, (ComposeRecipientField)border.DataContext!, drag.Token)
+ ? DragDropEffects.Move : DragDropEffects.None;
+ e.Handled = true;
+ if (e.DragEffects == DragDropEffects.Move)
+ border.GetVisualDescendants().OfType().FirstOrDefault()?.Focus();
+ }
+
private void FilesDragOver(object? sender, DragEventArgs e)
{
if (!e.DataTransfer.Formats.Contains(DataFormat.File))
diff --git a/src/BetterMail.App/ComposeWindowViewModel.cs b/src/BetterMail.App/ComposeWindowViewModel.cs
index 8c07e8f..a39766c 100644
--- a/src/BetterMail.App/ComposeWindowViewModel.cs
+++ b/src/BetterMail.App/ComposeWindowViewModel.cs
@@ -372,6 +372,18 @@ private void RecipientsChanged()
ScheduleAutosave();
}
+ internal bool CanMoveRecipient(ComposeRecipientField source, ComposeRecipientField target, ComposeRecipientToken token) =>
+ CanChangeAttachments && !ReferenceEquals(source, target) &&
+ RecipientFields.Contains(source) && RecipientFields.Contains(target) &&
+ source.Tokens.Any(existing => ReferenceEquals(existing, token));
+
+ internal bool MoveRecipient(ComposeRecipientField source, ComposeRecipientField target, ComposeRecipientToken token)
+ {
+ if (!CanMoveRecipient(source, target, token)) return false;
+ source.MoveTokenTo(target, token);
+ return true;
+ }
+
private void ApplySignatureForSender(ComposeSender? sender)
{
if (!_manageSignature || sender is null)
@@ -688,6 +700,18 @@ private Task RemoveTokenAsync(ComposeRecipientToken token)
return Task.CompletedTask;
}
+ internal void MoveTokenTo(ComposeRecipientField target, ComposeRecipientToken token)
+ {
+ Tokens.Remove(token);
+ if (!target.Tokens.Any(existing => existing.Address.Equals(token.Address, StringComparison.OrdinalIgnoreCase)))
+ target.Tokens.Add(token);
+ RaisePropertyChanged(nameof(Serialized));
+ target.RaisePropertyChanged(nameof(Serialized));
+ // Both fields belong to the same compose model. Notify after the whole move so an
+ // immediate autosave cannot persist the recipient in both fields or in neither.
+ _changed();
+ }
+
private void Add(string name, string address, bool notify = true)
{
if (Tokens.Any(token => string.Equals(token.Address, address, StringComparison.OrdinalIgnoreCase)))
diff --git a/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs b/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs
index 7848baa..d180b44 100644
--- a/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs
+++ b/tests/BetterMail.Tests/DeletedMailRecoveryTests.cs
@@ -145,7 +145,9 @@ private static async Task WithPendingDelete(Func provider.SearchCalls == 1 &&
diff --git a/tests/BetterMail.Tests/RecipientMoveTests.cs b/tests/BetterMail.Tests/RecipientMoveTests.cs
new file mode 100644
index 0000000..5ee28ed
--- /dev/null
+++ b/tests/BetterMail.Tests/RecipientMoveTests.cs
@@ -0,0 +1,96 @@
+using BetterMail.App;
+using BetterMail.Core;
+
+namespace BetterMail.Tests;
+
+public sealed class RecipientMoveTests
+{
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(0, 2)]
+ [InlineData(1, 0)]
+ [InlineData(1, 2)]
+ [InlineData(2, 0)]
+ [InlineData(2, 1)]
+ public async Task MovingBetweenEveryRecipientFieldPreservesNamesPendingTextAndSavedAndSentRecipients(int sourceIndex, int targetIndex)
+ {
+ LocalDraft? saved = null;
+ DraftMessage? sent = null;
+ var account = new MailAccount("microsoft365", "account", "tenant", "author@example.com", "Author", ProviderCapabilities.Mail);
+ var vm = new ComposeWindowViewModel([account], [new(account.AccountId, account.EmailAddress, "Author")],
+ new ComposeRequest(), (_, _, draft) => { sent = draft; return Task.CompletedTask; },
+ draft => { saved = draft; return Task.CompletedTask; }, autosaveDelay: TimeSpan.Zero);
+ var source = vm.RecipientFields[sourceIndex];
+ var target = vm.RecipientFields[targetIndex];
+ source.SetSerialized("\"Doe, Jane\" ; stays@example.com");
+ target.SetSerialized("already@example.com");
+ source.Query = "source-query@example.com";
+ target.Query = "target-query@example.com";
+ var token = source.Tokens[0];
+
+ Assert.True(vm.MoveRecipient(source, target, token));
+ Assert.DoesNotContain(token, source.Tokens);
+ Assert.Same(token, target.Tokens[1]);
+ Assert.Equal("source-query@example.com", source.Query);
+ Assert.Equal("target-query@example.com", target.Query);
+ Assert.NotNull(saved);
+ var savedFields = new[] { saved.To, saved.Cc, saved.Bcc };
+ Assert.DoesNotContain("jane@example.com", savedFields[sourceIndex]);
+ var moved = Assert.Single(ComposeWindowViewModel.ParseRecipients(savedFields[targetIndex]), recipient => recipient.Address == "jane@example.com");
+ Assert.Equal("Doe, Jane", moved.Name);
+ if (vm.ToField.Tokens.Count == 0 && vm.ToField.Query.Length == 0) vm.To = "required@example.com";
+ await ((AsyncCommand)vm.SendCommand).ExecuteAsync();
+ Assert.NotNull(sent);
+ var sentFields = new[] { sent.To, sent.Cc!, sent.Bcc! };
+ Assert.DoesNotContain(sentFields[sourceIndex], recipient => recipient.Address == "jane@example.com");
+ Assert.Contains(sentFields[targetIndex], recipient => recipient.Address == "jane@example.com" && recipient.Name == "Doe, Jane");
+ }
+
+ [Fact]
+ public void MovingToAnExistingAddressMergesTheDuplicateIgnoringCase()
+ {
+ var vm = new ComposeWindowViewModel([], [], new ComposeRequest(To: "Existing ", Cc: "Jane "),
+ (_, _, _) => Task.CompletedTask);
+ var existing = Assert.Single(vm.ToField.Tokens);
+ Assert.True(vm.MoveRecipient(vm.CcField, vm.ToField, Assert.Single(vm.CcField.Tokens)));
+ Assert.Empty(vm.CcField.Tokens);
+ Assert.Same(existing, Assert.Single(vm.ToField.Tokens));
+ }
+
+ [Fact]
+ public void RejectsSameFieldStaleTokensForeignFieldsAndBusyCompose()
+ {
+ var vm = new ComposeWindowViewModel([], [], new ComposeRequest(Cc: "Jane "), (_, _, _) => Task.CompletedTask);
+ var token = Assert.Single(vm.CcField.Tokens);
+ var other = new ComposeWindowViewModel([], [], new ComposeRequest(), (_, _, _) => Task.CompletedTask);
+ Assert.False(vm.MoveRecipient(vm.CcField, vm.CcField, token));
+ Assert.False(vm.MoveRecipient(vm.CcField, other.ToField, token));
+ Assert.False(vm.MoveRecipient(other.CcField, vm.ToField, token));
+ Assert.False(vm.MoveRecipient(vm.CcField, vm.ToField, token with { }));
+ vm.IsUploadingAttachment = true;
+ Assert.False(vm.MoveRecipient(vm.CcField, vm.ToField, token));
+ vm.IsUploadingAttachment = false;
+ Assert.True(vm.MoveRecipient(vm.CcField, vm.ToField, token));
+ Assert.False(vm.MoveRecipient(vm.CcField, vm.BccField, token));
+ Assert.Empty(vm.CcField.Tokens);
+ Assert.Empty(vm.BccField.Tokens);
+ Assert.Same(token, Assert.Single(vm.ToField.Tokens));
+ }
+
+ [Fact]
+ public async Task RejectsRecipientMovesWhileSending()
+ {
+ var account = new MailAccount("microsoft365", "account", "tenant", "author@example.com", "Author", ProviderCapabilities.Mail);
+ var release = new TaskCompletionSource();
+ var vm = new ComposeWindowViewModel([account], [new(account.AccountId, account.EmailAddress, "Author")],
+ new ComposeRequest(To: "required@example.com", Cc: "jane@example.com"), (_, _, _) => release.Task);
+ var sending = ((AsyncCommand)vm.SendCommand).ExecuteAsync();
+ try
+ {
+ Assert.True(vm.IsSending);
+ Assert.False(vm.MoveRecipient(vm.CcField, vm.ToField, Assert.Single(vm.CcField.Tokens)));
+ Assert.Equal("jane@example.com", vm.Cc);
+ }
+ finally { release.SetResult(); await sending; }
+ }
+}
diff --git a/tools/BetterMail.UiPreview/Program.RecipientDragging.cs b/tools/BetterMail.UiPreview/Program.RecipientDragging.cs
new file mode 100644
index 0000000..e615d04
--- /dev/null
+++ b/tools/BetterMail.UiPreview/Program.RecipientDragging.cs
@@ -0,0 +1,103 @@
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+using BetterMail.App;
+using BetterMail.Core;
+
+internal static partial class Program
+{
+ // Exercise native drag/drop through the real recipient templates with fictional offline data.
+ private static async Task CheckRecipientDraggingAsync()
+ {
+ LocalDraft? saved = null;
+ var account = new MailAccount("microsoft365", "fictional", "tenant", "author@example.test", "Author", ProviderCapabilities.Mail);
+ var window = new ComposeWindow([account], [new(account.AccountId, account.EmailAddress, "Author")],
+ new ComposeRequest(To: "already@example.test", Cc: "\"Doe, Jane\" ", Bcc: "hidden@example.test"),
+ (_, _, _) => throw new InvalidOperationException("This test must never send mail."),
+ draft => { saved = draft; return Task.CompletedTask; }, _ => Task.CompletedTask, (_, _) => null)
+ { Position = new(0, 0), WindowStartupLocation = WindowStartupLocation.Manual, WindowDecorations = WindowDecorations.None };
+ var vm = (ComposeWindowViewModel)window.DataContext!;
+ var token = vm.CcField.Tokens.Single();
+ try
+ {
+ window.Show();
+ window.Activate();
+ await Task.Delay(600);
+ var subject = window.GetVisualDescendants().OfType()
+ .Single(box => Avalonia.Automation.AutomationProperties.GetName(box) == "Subject");
+ foreach (var field in vm.RecipientFields)
+ {
+ // A chip click must still focus its field when no drag follows.
+ await Click(subject);
+ await Click(Chip(field.Tokens[0]));
+ await Input("r");
+ if (field.Query != "r" || subject.Text?.Length > 0)
+ throw new InvalidOperationException($"Clicking a {field.Label} chip did not direct typing to its recipient input.");
+ field.Query = "";
+ }
+ foreach (var field in vm.RecipientFields) field.Query = $"pending-{field.Label.ToLowerInvariant()}@example.test";
+ foreach (var (source, target) in new[]
+ {
+ (vm.CcField, vm.ToField), (vm.ToField, vm.BccField), (vm.BccField, vm.CcField),
+ (vm.CcField, vm.BccField), (vm.BccField, vm.ToField), (vm.ToField, vm.CcField)
+ })
+ {
+ await Drag(Chip(token), Field(target));
+ if (source.Tokens.Contains(token) || !target.Tokens.Any(existing => ReferenceEquals(existing, token)))
+ throw new InvalidOperationException($"Recipient drag from {source.Label} to {target.Label} failed.");
+ foreach (var field in vm.RecipientFields)
+ if (field.Query != $"pending-{field.Label.ToLowerInvariant()}@example.test")
+ throw new InvalidOperationException($"Dragging replaced pending {field.Label} text.");
+ await vm.FlushDraftAsync();
+ var fields = new[] { saved!.To, saved.Cc, saved.Bcc };
+ if (fields.Count(value => value.Contains("jane@example.test")) != 1 ||
+ !fields[vm.RecipientFields.ToList().IndexOf(target)].Contains("\"Doe, Jane\""))
+ throw new InvalidOperationException("Saved draft did not preserve the moved recipient and display name.");
+ }
+ // Same-field and unrelated drops must leave the recipient where it was.
+ await Drag(Chip(token), Field(vm.CcField));
+ await Drag(Chip(token), subject);
+ if (!vm.CcField.Tokens.Contains(token) || subject.Text?.Length > 0)
+ throw new InvalidOperationException("Rejected drop moved a contact or pasted it into Subject.");
+ vm.To = "Existing ";
+ var existing = vm.ToField.Tokens.Single();
+ await Drag(Chip(token), Field(vm.ToField));
+ if (vm.CcField.Tokens.Count != 0 || vm.ToField.Tokens.Count != 1 || !ReferenceEquals(existing, vm.ToField.Tokens[0]))
+ throw new InvalidOperationException("Recipient drop did not merge the duplicate destination address.");
+ var remove = Chip(existing).GetVisualDescendants().OfType