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
6 changes: 5 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ jobs:
run: dotnet publish src/BetterMail.App --configuration Release --no-restore --output artifacts/smoke -p:UseAppHost=false
- name: Install Linux desktop libraries
if: runner.os == 'Linux'
run: sudo apt-get update -qq && sudo apt-get install -y xvfb libice6 libsm6 libfontconfig1 libsecret-1-0 libwebkit2gtk-4.1-0
run: sudo apt-get update -qq && sudo apt-get install -y xvfb libxtst6 libice6 libsm6 libfontconfig1 libsecret-1-0 libwebkit2gtk-4.1-0
- name: Check Linux compose keyboard input
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: Smoke test published Linux app
if: runner.os == 'Linux'
run: xvfb-run -a python3 scripts/smoke-startup.py artifacts/smoke/BetterMail.dll
Expand Down
60 changes: 60 additions & 0 deletions src/BetterMail.App/LinuxWebViewFocus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Runtime.InteropServices;
using Avalonia.Platform;

namespace BetterMail.App;

internal static class LinuxWebViewFocus
{
// GTK's grab_focus selects its widget, but doesn't move X11 keyboard focus into
// the reparented WebView window. This also applies to GTK running under XWayland.
// Keep native key/IME handling rather than synthesizing text through JavaScript.
public static void Focus(IPlatformHandle? handle, IPlatformHandle? owner)
{
if (!OperatingSystem.IsLinux() || handle is not IGtkWebViewPlatformHandle ||
handle.HandleDescriptor != "XID" || owner?.HandleDescriptor != "XID" ||
handle.Handle == IntPtr.Zero || owner.Handle == IntPtr.Zero) return;
var display = XOpenDisplay(IntPtr.Zero);
if (display == IntPtr.Zero) return;
try
{
XGetInputFocus(display, out var focused, out _);
// Ignore delayed GTK notifications after the user has moved to another window.
if (focused == owner.Handle || focused == new IntPtr(1) /* PointerRoot */)
SetFocus(display, handle.Handle);
}
finally { XCloseDisplay(display); }
}

public static void RestoreOwner(IPlatformHandle? editor, IPlatformHandle? owner)
{
if (!OperatingSystem.IsLinux() || editor is not IGtkWebViewPlatformHandle ||
editor.HandleDescriptor != "XID" || owner?.HandleDescriptor != "XID" ||
editor.Handle == IntPtr.Zero || owner.Handle == IntPtr.Zero) return;
var display = XOpenDisplay(IntPtr.Zero);
if (display == IntPtr.Zero) return;
try
{
XGetInputFocus(display, out var focused, out _);
// Hand input back to To/Subject/the toolbar, but never reclaim focus from another app.
if (focused == editor.Handle) SetFocus(display, owner.Handle);
}
finally { XCloseDisplay(display); }
}

private static void SetFocus(IntPtr display, IntPtr window)
{
XSetInputFocus(display, window, 2 /* RevertToParent */, IntPtr.Zero /* CurrentTime */);
XFlush(display);
}

[DllImport("libX11.so.6")]
private static extern IntPtr XOpenDisplay(IntPtr name);
[DllImport("libX11.so.6")]
private static extern int XSetInputFocus(IntPtr display, IntPtr window, int revertTo, IntPtr time);
[DllImport("libX11.so.6")]
private static extern int XGetInputFocus(IntPtr display, out IntPtr window, out int revertTo);
[DllImport("libX11.so.6")]
private static extern int XFlush(IntPtr display);
[DllImport("libX11.so.6")]
private static extern int XCloseDisplay(IntPtr display);
}
8 changes: 8 additions & 0 deletions src/BetterMail.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,8 +1119,16 @@ private void OpenCompose(ComposeRequest request)
return;
}
_composeWindows[session] = window;
var recipientOwner = _viewModel;
void RefreshRecipients()
{
if (window.FocusManager?.GetFocusedElement() is TextBox { DataContext: ComposeRecipientField field } &&
!string.IsNullOrWhiteSpace(field.Query)) field.RefreshSearch();
}
recipientOwner.RecipientDirectoryChanged += RefreshRecipients;
window.Closed += (_, _) =>
{
recipientOwner.RecipientDirectoryChanged -= RefreshRecipients;
_composeWindows.Remove(session);
if (!_isClosing)
{
Expand Down
2 changes: 1 addition & 1 deletion src/BetterMail.App/MainWindowViewModel.Sync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ private async Task RunStorageMaintenanceAsync()
StorageSyncStep.Detail = $"{++batches} cleanup batches complete";
await Task.Delay(50);
}
_recipientDirectoryTask = null;
_ = RecipientDirectory.RefreshAsync();
if (WindowsSessionLock.IsLocked())
{
using var vacuumCancellation = new CancellationTokenSource();
Expand Down
74 changes: 42 additions & 32 deletions src/BetterMail.App/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,22 @@ public bool MailSenderImagesEnabled
private MailPageCursor? _messagePageCursor;
private IReadOnlyList<MailFolderKey> _messagePageFolders = [];
private bool _isLoadingMoreMessages;
private Task<IReadOnlyList<RecipientSuggestion>>? _recipientDirectoryTask;
private RecipientDirectoryCache? _recipientDirectory;
private string[] _recipientDirectoryScope = [];
internal event Action? RecipientDirectoryChanged;
private RecipientDirectoryCache RecipientDirectory
{
get
{
if (_recipientDirectory is not null) return _recipientDirectory;
var directory = new RecipientDirectoryCache(LoadRecipientContactsAsync, LoadRecipientHistoryAsync);
directory.Changed += () =>
{
if (ReferenceEquals(directory, _recipientDirectory)) RecipientDirectoryChanged?.Invoke();
};
return _recipientDirectory = directory;
}
}
private CalendarEventSource? _nextCalendarEvent;

internal string DataDirectory => _dataDirectory;
Expand Down Expand Up @@ -1521,7 +1536,7 @@ await RunBusyAsync("Loading mailbox…", async () =>
await RefreshNextCalendarEventAsync();
StartAutoSync();
_ = ReconcileAllDraftsInBackgroundAsync();
_recipientDirectoryTask = LoadRecipientDirectoryAsync();
_ = RecipientDirectory.RefreshAsync();
}

private async Task ConnectAsync()
Expand Down Expand Up @@ -3799,46 +3814,32 @@ await _store.UpsertWorkspaceItemsAsync(
.Select(result =>
$"{result.Owner.DisplayName}: {result.Error} Open Settings > Accounts and re-authenticate if access expired."));
RaisePropertyChanged(nameof(IsWorkspaceEmpty));
_recipientDirectoryTask = null;
_ = RecipientDirectory.RefreshAsync();
}

internal async Task<IReadOnlyList<RecipientSuggestion>> SearchRecipientSuggestionsAsync(
string query,
CancellationToken cancellationToken)
{
var directory = await (_recipientDirectoryTask ??= LoadRecipientDirectoryAsync())
.WaitAsync(cancellationToken);
return directory
.Where(suggestion => Contains(suggestion.DisplayName, query) || Contains(suggestion.Address, query))
.OrderByDescending(suggestion => suggestion.Address.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(suggestion => suggestion.DisplayName.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.ThenBy(static suggestion => suggestion.DisplayName, StringComparer.OrdinalIgnoreCase)
.Take(20)
.ToArray();
}
internal Task<IReadOnlyList<RecipientSuggestion>> SearchRecipientSuggestionsAsync(
string query, CancellationToken cancellationToken) =>
RecipientDirectory.SearchAsync(query.Trim(), cancellationToken);

private async Task<IReadOnlyList<RecipientSuggestion>> LoadRecipientDirectoryAsync()
private async Task<IReadOnlyList<RecipientSuggestion>> LoadRecipientContactsAsync()
{
if (_store is null)
{
return [];
}
if (_store is null) return [];
var suggestions = new List<RecipientSuggestion>();
foreach (var owner in ContactOwners)
foreach (var owner in ContactOwners.ToArray())
{
var contacts = await _store.GetWorkspaceItemsAsync<ContactInfo>(
"contact", owner.CacheId, "all");
suggestions.AddRange(contacts.SelectMany(contact => contact.EmailAddresses.Select(address =>
new RecipientSuggestion(contact.DisplayName, address, owner.DisplayName))));
}
if (_store.HasIndexedCorrespondents)
{
suggestions.AddRange((await _store.GetDiscoveredPeopleAsync("", 5000))
.Select(person => new RecipientSuggestion(person.DisplayName, person.EmailAddress, "Mail history")));
}
return suggestions
.Where(static suggestion => !string.IsNullOrWhiteSpace(suggestion.Address))
.DistinctBy(static suggestion => suggestion.Address, StringComparer.OrdinalIgnoreCase)
return suggestions;
}

private async Task<IReadOnlyList<RecipientSuggestion>> LoadRecipientHistoryAsync()
{
if (_store is null || !_store.HasIndexedCorrespondents) return [];
return (await _store.GetDiscoveredPeopleAsync("", 5000))
.Select(person => new RecipientSuggestion(person.DisplayName, person.EmailAddress, "Mail history"))
.ToArray();
}
private Task EditContactAsync(PersonEntry person)
Expand Down Expand Up @@ -4839,7 +4840,6 @@ join account in Accounts on mailbox.AccountId equals account.AccountId

private void RebuildContactOwners()
{
_recipientDirectoryTask = null;
var selectedMailboxId = SelectedContactOwner?.Mailbox.Id;
Replace(ContactOwners,
from mailbox in Mailboxes.Concat(Accounts
Expand All @@ -4853,6 +4853,16 @@ where account.Capabilities.HasFlag(ProviderCapabilities.Contacts) &&
SelectedContactOwner = ContactOwners.FirstOrDefault(owner => owner.Mailbox.Id == selectedMailboxId)
?? ContactOwners.FirstOrDefault();
RefreshContactCommands();
var scope = ContactOwners.Select(owner => $"contact:{owner.CacheId}:{owner.DisplayName}")
.Concat(Mailboxes.Select(mailbox => $"mail:{mailbox.Id}"))
.Order(StringComparer.Ordinal).ToArray();
if (!_recipientDirectoryScope.SequenceEqual(scope))
{
// Drop removed accounts immediately, but don't discard a warm cache on ordinary sync.
_recipientDirectoryScope = scope;
_recipientDirectory = null;
if (_initializationComplete) _ = RecipientDirectory.RefreshAsync();
}
}

private void OnSenderSignatureChanged(SenderSettingsItem item)
Expand Down
74 changes: 74 additions & 0 deletions src/BetterMail.App/RecipientDirectoryCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace BetterMail.App;

// Owned by the UI thread. Keep the last usable snapshot while local storage refreshes.
internal sealed class RecipientDirectoryCache(
Func<Task<IReadOnlyList<RecipientSuggestion>>> loadContacts,
Func<Task<IReadOnlyList<RecipientSuggestion>>> loadHistory)
{
private IReadOnlyList<RecipientSuggestion>? _snapshot;
private IReadOnlyList<RecipientSuggestion> _history = [];
private Task? _refresh;
private bool _refreshAgain;
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event Action? Changed;

public async Task<IReadOnlyList<RecipientSuggestion>> SearchAsync(string query, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (_snapshot is null)
{
if (_refresh is null) _ = RefreshAsync();
await _ready.Task.WaitAsync(token);
}
return (_snapshot ?? [])
.Where(item => item.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
item.Address.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.Address.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(item => item.DisplayName.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.ThenBy(static item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
.Take(20).ToArray();
}

public Task RefreshAsync()
{
if (_refresh is { IsCompleted: false })
{
_refreshAgain = true;
return _refresh;
}
return _refresh = RefreshCoreAsync();
}

private async Task RefreshCoreAsync()
{
do
{
_refreshAgain = false;
try
{
var contacts = await loadContacts();
Publish(contacts);
// Publish saved contacts first: autocomplete must not wait for a mail-history aggregate.
_history = await loadHistory();
Publish(contacts);
}
catch (Exception exception)
{
// A failed refresh must neither discard known recipients nor fault every keystroke.
System.Diagnostics.Trace.TraceWarning("Local recipient directory refresh failed: {0}", exception.GetType().Name);
_snapshot ??= [];
_ready.TrySetResult();
}
} while (_refreshAgain);
}

private void Publish(IReadOnlyList<RecipientSuggestion> contacts)
{
_snapshot = contacts.Concat(_history)
.Where(static item => !string.IsNullOrWhiteSpace(item.Address))
.DistinctBy(static item => item.Address, StringComparer.OrdinalIgnoreCase)
.ToArray();
_ready.TrySetResult();
Changed?.Invoke();
}
}
24 changes: 22 additions & 2 deletions src/BetterMail.App/RichHtmlEditor.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public sealed partial class RichHtmlEditor : UserControl
private bool _updatingFromEditor;
private bool _initialized;
private const string AttachmentMessagePrefix = "bettermail:attachment:";
private const string FocusMessage = "bettermail:editor-focus";
public event Action<DraftAttachment>? AttachmentDropped;

public static readonly StyledProperty<string> HtmlProperty = AvaloniaProperty.Register<RichHtmlEditor, string>(
Expand All @@ -29,6 +30,13 @@ public sealed partial class RichHtmlEditor : UserControl
public RichHtmlEditor()
{
InitializeComponent();
Editor.GotFocus += (_, _) =>
{
if (_ready && Editor.IsEffectivelyVisible)
LinuxWebViewFocus.Focus(Editor.TryGetPlatformHandle(), TopLevel.GetTopLevel(this)?.TryGetPlatformHandle());
};
Editor.LostFocus += (_, _) => LinuxWebViewFocus.RestoreOwner(
Editor.TryGetPlatformHandle(), TopLevel.GetTopLevel(this)?.TryGetPlatformHandle());
AttachedToVisualTree += InitializeEditor;
}

Expand Down Expand Up @@ -59,7 +67,9 @@ protected override async void OnPropertyChanged(AvaloniaPropertyChangedEventArgs
}
if (change.Property == HtmlProperty)
{
await Editor.InvokeScript($"editor.innerHTML={JsonSerializer.Serialize(Html)}");
// Two-way bindings can echo an input event back after its handler returns.
// Replacing identical HTML destroys the browser's selection and undo history.
await Editor.InvokeScript($"if(editor.innerHTML!=={JsonSerializer.Serialize(Html)}) editor.innerHTML={JsonSerializer.Serialize(Html)}");
}
else if (change.Property == IsReadOnlyProperty)
{
Expand Down Expand Up @@ -98,6 +108,7 @@ private void InitializeEditor(object? sender, VisualTreeAttachmentEventArgs e)
contenteditable="{{(!IsReadOnly).ToString().ToLowerInvariant()}}"></body>
<script>
editor.innerHTML={{JsonSerializer.Serialize(Html)}};
editor.addEventListener('pointerdown',()=>invokeCSharpAction('{{FocusMessage}}'));
editor.addEventListener('input',()=>invokeCSharpAction(editor.innerHTML));
document.addEventListener('dragover',e=>{
if(!Array.from(e.dataTransfer.types).includes('Files')) return;
Expand Down Expand Up @@ -132,6 +143,15 @@ private void InitializeEditor(object? sender, VisualTreeAttachmentEventArgs e)

private void EditorWebMessageReceived(object? sender, WebMessageReceivedEventArgs e)
{
if (e.Body == FocusMessage)
{
if (_ready && Editor.IsEffectivelyVisible)
{
Editor.Focus();
LinuxWebViewFocus.Focus(Editor.TryGetPlatformHandle(), TopLevel.GetTopLevel(this)?.TryGetPlatformHandle());
}
return;
}
if (e.Body?.StartsWith(AttachmentMessagePrefix, StringComparison.Ordinal) == true)
{
if (!IsReadOnly && AttachmentDropped is not null)
Expand All @@ -149,7 +169,7 @@ private void EditorWebMessageReceived(object? sender, WebMessageReceivedEventArg
return;
}
_updatingFromEditor = true;
Html = e.Body ?? "";
SetCurrentValue(HtmlProperty, e.Body ?? "");
_updatingFromEditor = false;
}

Expand Down
Loading
Loading