Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/BetterMail.App/ComposeWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<Style Selector="Border.recipientField:focus-within">
<Setter Property="BorderBrush" Value="{DynamicResource BetterMailAccentBrush}" />
</Style>
<Style Selector="Border.recipientField.recipientDropTarget">
<Setter Property="BorderBrush" Value="{DynamicResource BetterMailAccentBrush}" />
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto" Margin="18">
<Grid ColumnDefinitions="80,*" Margin="0,0,0,10" IsEnabled="{Binding CanChangeAttachments}">
Expand All @@ -41,14 +44,18 @@
<DataTemplate>
<Grid ColumnDefinitions="80,*" Margin="0,0,0,8">
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Opacity="0.7" />
<Border Grid.Column="1" Classes="recipientField" PointerPressed="RecipientFieldPressed">
<Border Grid.Column="1" Classes="recipientField" PointerPressed="RecipientFieldPressed"
DragDrop.AllowDrop="True" DragDrop.DragOver="RecipientDragOver"
DragDrop.DragLeave="RecipientDragLeave" DragDrop.Drop="RecipientDropped">
<Grid>
<DockPanel LastChildFill="True">
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding Tokens}" Margin="5,0,5,0">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><WrapPanel Orientation="Horizontal" /></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource BetterMailSelectionBrush}" CornerRadius="10" Padding="7,3" Margin="0,4,4,0"
<Border Classes="recipientToken" Background="{DynamicResource BetterMailSelectionBrush}" CornerRadius="10" Padding="7,3" Margin="0,4,4,0"
PointerPressed="RecipientTokenPressed" PointerMoved="RecipientTokenMoved"
PointerReleased="RecipientTokenReleased" PointerCaptureLost="RecipientTokenCaptureLost"
MaxWidth="{Binding $parent[ItemsControl].Bounds.Width}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="4">
<TextBlock Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center" />
Expand Down
85 changes: 85 additions & 0 deletions src/BetterMail.App/ComposeWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecipientDrag> RecipientDragFormat = DataFormat.CreateInProcessFormat<RecipientDrag>("BetterMail.Recipient");
private PointerPressedEventArgs? _recipientDragStart;
private Avalonia.Point _recipientDragOrigin;
private RecipientDrag? _recipientDrag;
private bool _closeAfterSave;
private IFilesProvider? _filesProvider;

Expand Down Expand Up @@ -173,6 +178,86 @@ private static void RecipientFieldPressed(object? sender, PointerPressedEventArg
border.GetVisualDescendants().OfType<TextBox>().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<Border>()
.FirstOrDefault(border => border.Classes.Contains("recipientField"));
if (sourceBorder?.DataContext is not ComposeRecipientField source) return;
sourceBorder.GetVisualDescendants().OfType<TextBox>().FirstOrDefault()?.Focus();
_recipientDragStart = e;
_recipientDragOrigin = e.GetPosition(this);
_recipientDrag = new(viewModel, source, token);
e.Pointer.Capture(chip);
e.Handled = true;
Comment thread
bcbetterninja marked this conversation as resolved.
}

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>())
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<TextBox>().FirstOrDefault()?.Focus();
}

private void FilesDragOver(object? sender, DragEventArgs e)
{
if (!e.DataTransfer.Formats.Contains(DataFormat.File))
Expand Down
24 changes: 24 additions & 0 deletions src/BetterMail.App/ComposeWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)))
Expand Down
4 changes: 3 additions & 1 deletion tests/BetterMail.Tests/DeletedMailRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ private static async Task WithPendingDelete(Func<EncryptedMailStore, string, Mai

private static async Task WriteLegacyRecoveryAsync(string path, string id, CancellationToken token)
{
await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
// This temporary legacy-payload writer must release its file handle on disposal.
// A pooled connection keeps mail.db open and prevents cleanup on Windows.
await using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
await connection.OpenAsync(token);
await using var key = connection.CreateCommand();
key.CommandText = $"PRAGMA key = '{new string('A', 64)}';";
Expand Down
4 changes: 3 additions & 1 deletion tests/BetterMail.Tests/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1527,7 +1527,9 @@ public async Task MailSearchShowsLocalResultsBeforeIncompleteCacheFallbackFinish
viewModel.Mailboxes.Add(mailbox);
viewModel.Folders.Add(new MailFolderItem(folder, mailbox.DisplayName));

viewModel.SearchText = "Needle";
// Isolate mail fallback: other workspace searches can reorder the result
// collection while this headless test inspects the blocked mail search.
viewModel.SearchText = "type:mail Needle";
viewModel.SearchCommand.Execute(null);
await WaitUntilAsync(
() => provider.SearchCalls == 1 &&
Expand Down
96 changes: 96 additions & 0 deletions tests/BetterMail.Tests/RecipientMoveTests.cs
Original file line number Diff line number Diff line change
@@ -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\" <jane@example.com>; 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 <JANE@example.com>", Cc: "Jane <jane@example.com>"),
(_, _, _) => 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 <jane@example.com>"), (_, _, _) => 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; }
}
}
Loading
Loading