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