Contacts management.

This commit is contained in:
Burak Kaan Köse
2026-03-01 21:07:10 +01:00
parent bdd32786d6
commit e816e87f61
19 changed files with 855 additions and 32 deletions
@@ -31,6 +31,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private readonly IUnderlyingThemeService _underlyingThemeService; private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly IContactService _contactService;
public CalendarSettings CurrentSettings { get; } public CalendarSettings CurrentSettings { get; }
public INativeAppService NativeAppService => _nativeAppService; public INativeAppService NativeAppService => _nativeAppService;
@@ -143,7 +144,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IMailDialogService dialogService, IMailDialogService dialogService,
IWinoRequestDelegator winoRequestDelegator, IWinoRequestDelegator winoRequestDelegator,
INavigationService navigationService, INavigationService navigationService,
IUnderlyingThemeService underlyingThemeService) IUnderlyingThemeService underlyingThemeService,
IContactService contactService)
{ {
_calendarService = calendarService; _calendarService = calendarService;
_nativeAppService = nativeAppService; _nativeAppService = nativeAppService;
@@ -152,6 +154,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
_winoRequestDelegator = winoRequestDelegator; _winoRequestDelegator = winoRequestDelegator;
_navigationService = navigationService; _navigationService = navigationService;
_underlyingThemeService = underlyingThemeService; _underlyingThemeService = underlyingThemeService;
_contactService = contactService;
CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark(); IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
@@ -260,6 +263,24 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
var attendees = await _calendarService.GetAttendeesAsync(calendarItemId); var attendees = await _calendarService.GetAttendeesAsync(calendarItemId);
// Resolve contacts for all attendees in a single batch DB query.
var emails = attendees
.Where(a => !string.IsNullOrEmpty(a.Email))
.Select(a => a.Email)
.ToList();
if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
emails.Add(calendarItem.OrganizerEmail);
var contacts = await _contactService.GetContactsByAddressesAsync(emails).ConfigureAwait(false);
var contactLookup = contacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
foreach (var attendee in attendees)
{
if (!string.IsNullOrEmpty(attendee.Email) && contactLookup.TryGetValue(attendee.Email, out var contact))
attendee.ResolvedContact = contact;
}
// Separate organizer from other attendees to ensure organizer is always first // Separate organizer from other attendees to ensure organizer is always first
var organizer = attendees.FirstOrDefault(a => a.IsOrganizer); var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList(); var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
@@ -281,6 +302,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
IsOrganizer = true, IsOrganizer = true,
AttendenceStatus = AttendeeStatus.Accepted AttendenceStatus = AttendeeStatus.Accepted
}; };
if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact))
organizerAttendee.ResolvedContact = organizerContact;
CurrentEvent.Attendees.Add(organizerAttendee); CurrentEvent.Attendees.Add(organizerAttendee);
} }
@@ -1,10 +1,10 @@
using System; using System;
using SQLite; using SQLite;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar; namespace Wino.Core.Domain.Entities.Calendar;
// TODO: Connect to Contact store with Wino People.
public class CalendarEventAttendee public class CalendarEventAttendee
{ {
[PrimaryKey] [PrimaryKey]
@@ -16,4 +16,11 @@ public class CalendarEventAttendee
public bool IsOrganizer { get; set; } public bool IsOrganizer { get; set; }
public bool IsOptionalAttendee { get; set; } public bool IsOptionalAttendee { get; set; }
public string Comment { get; set; } public string Comment { get; set; }
/// <summary>
/// Resolved contact from the contact store. Populated at runtime via IContactService;
/// not persisted to the database.
/// </summary>
[Ignore]
public AccountContact ResolvedContact { get; set; }
} }
@@ -25,7 +25,16 @@ public class AccountContact : IEquatable<AccountContact>
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// Base64 encoded profile image of the contact. /// File ID for the contact picture stored on disk.
/// The actual file lives at {ApplicationDataFolderPath}/contacts/{ContactPictureFileId}.jpg.
/// Preferred over Base64ContactPicture — allows native BitmapImage file loading and avoids SQLite bloat.
/// </summary>
public Guid? ContactPictureFileId { get; set; }
/// <summary>
/// Legacy base64 encoded profile image of the contact.
/// For user-set contact pictures: migrate to file storage via ContactPictureFileId instead.
/// Still used for OAuth account profile pictures (MailAccount.Base64ProfilePictureData).
/// </summary> /// </summary>
public string Base64ContactPicture { get; set; } public string Base64ContactPicture { get; set; }
@@ -0,0 +1,19 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// A named group of contacts that can be expanded to individual addresses during mail composition.
/// </summary>
public class ContactGroup
{
[PrimaryKey]
public Guid Id { get; set; }
/// <summary>Display name of the group (e.g., "Team Alpha", "Family").</summary>
public string Name { get; set; }
/// <summary>Optional description for the group.</summary>
public string Description { get; set; }
}
@@ -0,0 +1,21 @@
using System;
using SQLite;
namespace Wino.Core.Domain.Entities.Shared;
/// <summary>
/// Associates an e-mail address with a <see cref="ContactGroup"/>.
/// </summary>
public class ContactGroupMember
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
/// <summary>Group this member belongs to.</summary>
[Indexed]
public Guid GroupId { get; set; }
/// <summary>E-mail address of the member (FK to AccountContact.Address).</summary>
[Indexed]
public string MemberAddress { get; set; }
}
@@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
/// <summary>
/// Manages contact picture files stored on disk instead of as base64 in SQLite,
/// eliminating DB bloat and enabling native WIC hardware-accelerated image loading.
/// </summary>
public interface IContactPictureFileService
{
/// <summary>
/// Returns the full file path for the given file ID, or null if the file does not exist on disk.
/// </summary>
string GetContactPicturePath(Guid fileId);
/// <summary>
/// Saves raw image bytes to disk and returns the new file ID.
/// </summary>
Task<Guid> SaveContactPictureAsync(byte[] imageData);
/// <summary>
/// Deletes the picture file for the given file ID if it exists.
/// </summary>
Task DeleteContactPictureAsync(Guid fileId);
/// <summary>
/// One-time startup migration: reads AccountContact rows where Base64ContactPicture is set
/// but ContactPictureFileId is null, writes the picture bytes to disk, updates the DB row,
/// and clears the Base64ContactPicture column.
/// </summary>
Task MigrateBase64PicturesAsync();
}
+17 -2
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MimeKit; using MimeKit;
@@ -16,11 +17,25 @@ public interface IContactService
Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts); Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts);
Task<AccountContact> CreateNewContactAsync(string address, string displayName); Task<AccountContact> CreateNewContactAsync(string address, string displayName);
// New methods for ContactsPage // Paged contact queries for ContactsPage
Task<List<AccountContact>> GetAllContactsAsync(); Task<List<AccountContact>> GetAllContactsAsync();
Task<List<AccountContact>> SearchContactsAsync(string searchQuery); Task<List<AccountContact>> SearchContactsAsync(string searchQuery);
Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false); Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false);
Task<AccountContact> UpdateContactAsync(AccountContact contact); Task<AccountContact> UpdateContactAsync(AccountContact contact);
Task DeleteContactAsync(string address); Task DeleteContactAsync(string address);
Task DeleteContactsAsync(IEnumerable<string> addresses); Task DeleteContactsAsync(IEnumerable<string> addresses);
// Group / distribution list support
Task<List<ContactGroup>> GetGroupsAsync();
Task<ContactGroup> CreateGroupAsync(string name, string description = null);
Task DeleteGroupAsync(Guid groupId);
Task<List<AccountContact>> GetGroupMembersAsync(Guid groupId);
Task AddGroupMemberAsync(Guid groupId, string memberAddress);
Task RemoveGroupMemberAsync(Guid groupId, string memberAddress);
/// <summary>
/// Expands a contact group to the individual <see cref="AccountContact"/> entries of its members.
/// Returns an empty list if the group does not exist or has no members.
/// </summary>
Task<List<AccountContact>> ExpandGroupAsync(Guid groupId);
} }
@@ -41,6 +41,8 @@ public class InMemoryDatabaseService : IDatabaseService
await Connection.CreateTableAsync<MailAccountPreferences>(); await Connection.CreateTableAsync<MailAccountPreferences>();
await Connection.CreateTableAsync<MailAccountAlias>(); await Connection.CreateTableAsync<MailAccountAlias>();
await Connection.CreateTableAsync<Thumbnail>(); await Connection.CreateTableAsync<Thumbnail>();
await Connection.CreateTableAsync<ContactGroup>();
await Connection.CreateTableAsync<ContactGroupMember>();
await Connection.CreateTableAsync<KeyboardShortcut>(); await Connection.CreateTableAsync<KeyboardShortcut>();
await Connection.CreateTableAsync<AccountCalendar>(); await Connection.CreateTableAsync<AccountCalendar>();
await Connection.CreateTableAsync<CalendarEventAttendee>(); await Connection.CreateTableAsync<CalendarEventAttendee>();
@@ -0,0 +1,115 @@
using FluentAssertions;
using MimeKit;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Tests.Helpers;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Services;
public class ContactServiceTests : IAsyncLifetime
{
private InMemoryDatabaseService _databaseService = null!;
private ContactService _contactService = null!;
public async Task InitializeAsync()
{
_databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync();
_contactService = new ContactService(_databaseService);
}
public async Task DisposeAsync()
{
await _databaseService.DisposeAsync();
}
[Fact]
public async Task SaveAddressInformationAsync_WithNotificationReplyAddress_DoesNotPersistContact()
{
await _contactService.SaveAddressInformationAsync(
[
new AccountContact
{
Address = "reply+ABCD1234@reply.github.com",
Name = "[owner/repository] Issue #42"
}
]);
var contact = await _databaseService.Connection
.Table<AccountContact>()
.Where(c => c.Address == "reply+ABCD1234@reply.github.com")
.FirstOrDefaultAsync();
contact.Should().BeNull();
}
[Fact]
public async Task SaveAddressInformationAsync_WithHumanContact_PersistsContact()
{
await _contactService.SaveAddressInformationAsync(
[
new AccountContact
{
Address = "alice@example.com",
Name = "Alice Example"
}
]);
var contact = await _databaseService.Connection
.Table<AccountContact>()
.Where(c => c.Address == "alice@example.com")
.FirstOrDefaultAsync();
contact.Should().NotBeNull();
contact!.Name.Should().Be("Alice Example");
}
[Fact]
public async Task SaveAddressInformationAsync_WithExistingNoisyContact_RemovesAutoCapturedEntry()
{
await _databaseService.Connection.InsertAsync(
new AccountContact
{
Address = "notifications@github.com",
Name = "GitHub Notifications"
},
typeof(AccountContact));
await _contactService.SaveAddressInformationAsync(
[
new AccountContact
{
Address = "notifications@github.com",
Name = "[owner/repository] Issue #99"
}
]);
var contact = await _databaseService.Connection
.Table<AccountContact>()
.Where(c => c.Address == "notifications@github.com")
.FirstOrDefaultAsync();
contact.Should().BeNull();
}
[Fact]
public async Task SaveAddressInformationAsync_WithNoisyMimeGroup_SkipsGroupAndNoisyMembers()
{
var message = new MimeMessage();
message.To.Add(new GroupAddress("[owner/repository] Issue #123", new InternetAddressList
{
new MailboxAddress("Alice Example", "alice@example.com"),
new MailboxAddress("[owner/repository] Issue #123", "notifications@github.com")
}));
await _contactService.SaveAddressInformationAsync(message);
var contacts = await _databaseService.Connection.Table<AccountContact>().ToListAsync();
var groups = await _databaseService.Connection.Table<ContactGroup>().ToListAsync();
contacts.Select(c => c.Address).Should().Contain("alice@example.com");
contacts.Select(c => c.Address).Should().NotContain("notifications@github.com");
groups.Should().BeEmpty();
}
}
+2 -2
View File
@@ -8,12 +8,12 @@ namespace Wino.Mail.ViewModels;
public partial class WelcomePageViewModel : MailBaseViewModel public partial class WelcomePageViewModel : MailBaseViewModel
{ {
public const string VersionFile = "1102.md"; public const string VersionFile = "vnext.md";
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IFileService _fileService; private readonly IFileService _fileService;
[ObservableProperty] [ObservableProperty]
private string currentVersionNotes; public partial string CurrentVersionNotes { get; set; } = string.Empty;
public WelcomePageViewModel(IMailDialogService dialogService, IFileService fileService) public WelcomePageViewModel(IMailDialogService dialogService, IFileService fileService)
{ {
+3
View File
@@ -146,6 +146,9 @@ public partial class App : WinoApplication,
// Note: Theme service is initialized separately after window creation. // Note: Theme service is initialized separately after window creation.
await InitializeServicesAsync(); await InitializeServicesAsync();
// Migrate existing base64 contact pictures to file system (one-time, no-op on subsequent starts).
await Services.GetRequiredService<IContactPictureFileService>().MigrateBase64PicturesAsync();
_synchronizationManager = Services.GetRequiredService<ISynchronizationManager>(); _synchronizationManager = Services.GetRequiredService<ISynchronizationManager>();
_preferencesService = Services.GetRequiredService<IPreferencesService>(); _preferencesService = Services.GetRequiredService<IPreferencesService>();
_accountService = Services.GetRequiredService<IAccountService>(); _accountService = Services.GetRequiredService<IAccountService>();
@@ -0,0 +1,86 @@
# 🎉 Welcome to Wino Mail What's New?
Thank you for using Wino Mail! This update is one of the biggest yet, bringing a brand-new Wino Calendar, major security improvements, and a ton of quality-of-life upgrades. Here's a tour of everything new:
---
## 📅 Wino Calendar
Wino now ships with a fully integrated calendar alongside your mail. You can view, create, and manage your events without ever leaving the app. If you use any CalDAV-compatible service (like iCloud, Fastmail, or a self-hosted server), your events will sync automatically and stay up to date. Recurring events, reminders, RSVP responses, and online meeting links are all supported. When someone sends you a calendar invitation by email, Wino will recognize it and let you accept or decline right from the mail reading view.
- View, create, edit, and delete calendar events
- Sync with any CalDAV-compatible calendar service
- Full recurring event support
- RSVP directly from invitation emails
- Reminders and "Join Online" links for virtual meetings
- Calendar settings integrated into the main Settings page
---
## 🔒 Email Signing & Encryption (S/MIME)
You can now send digitally signed emails so recipients know a message genuinely came from you, and encrypt outgoing emails so only the intended recipient can read them. When you receive a signed or encrypted email, Wino will verify the signature and decrypt the content automatically. Import your personal certificate once from **Settings → Signature & Encryption**, and Wino takes care of the rest. Each email address (alias) can have its own certificate.
- Import your personal S/MIME certificate (PKCS#12 / .pfx)
- Sign and/or encrypt outgoing emails with toggle buttons in the compose toolbar
- Visual indicator on received emails that are signed or encrypted
- Automatic signature verification and decryption on incoming mail
---
## 💬 Threaded Mail View
Emails that belong to the same conversation are now grouped into threads, making it much easier to follow a back-and-forth discussion without scrolling through your entire inbox. Threads expand and collapse smoothly, and you can select or act on individual messages within a conversation.
---
## 📎 Large Attachments for Outlook
Sending large files via your Outlook or Microsoft 365 account no longer fails. Wino now uses Microsoft's upload session API behind the scenes, which handles big attachments reliably regardless of file size.
---
## 🔔 Smarter Notifications
Toast notifications now let you act on emails directly from the notification (mark as read, delete, etc.) even if the app is not open. Clicking a calendar reminder notification takes you straight to that event. Notifications for mail and calendar are now routed to the correct app entry automatically.
---
## 🗂️ Folder Management
You can now create new sub-folders and delete existing folders directly from the sidebar — no need to go to your webmail to organize your mailbox. A new Storage settings page also lets you see how much space Wino is using on your device.
---
## 💫 Swipe Actions
Swipe left or right on emails in the mail list to quickly archive, delete, or mark them — ideal for touch screen devices or when you want to process your inbox fast.
---
## ⌨️ Keyboard Shortcuts
A new keyboard shortcuts dialog is available so you can discover all the keyboard shortcuts Wino supports. Press the shortcut or find it in the app menu to open it.
---
## 🖨️ Custom Print Dialog
Printing an email now uses Wino's own print dialog, giving you a cleaner and more consistent experience.
---
## 🚀 Faster App Startup
Wino's internals have been modernized to take full advantage of the latest .NET runtime optimizations. While this is a behind-the-scenes change, it means the app starts quicker, uses less memory, and is set up for even better performance in future updates.
---
## 🐛 Bug Fixes & Stability
- Fixed several issues with Outlook sync reliability and speed
- Improved IMAP synchronization to be more stable and resource-efficient
- Fixed duplicate mail and calendar event issues
- Improved account sign-out and re-authentication handling
- Better error messages when something goes wrong during sync
- Dozens of smaller fixes throughout the app
@@ -23,7 +23,7 @@ namespace Wino.Controls;
/// </summary> /// </summary>
public sealed partial class ImagePreviewControl : PersonPicture public sealed partial class ImagePreviewControl : PersonPicture
{ {
private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture); private sealed record RefreshSnapshot(string DisplayName, string Address, Guid? ContactPictureFileId, string Base64Picture);
private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40); private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40);
@@ -32,6 +32,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
private readonly IThumbnailService? _thumbnailService; private readonly IThumbnailService? _thumbnailService;
private readonly IPreferencesService? _preferencesService; private readonly IPreferencesService? _preferencesService;
private readonly IContactPictureFileService? _contactPictureFileService;
private INotifyPropertyChanged? _mailItemInformationPropertySource; private INotifyPropertyChanged? _mailItemInformationPropertySource;
private CancellationTokenSource? _refreshCancellationTokenSource; private CancellationTokenSource? _refreshCancellationTokenSource;
private CancellationTokenSource? _scheduledRefreshCancellationTokenSource; private CancellationTokenSource? _scheduledRefreshCancellationTokenSource;
@@ -45,6 +46,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
{ {
_thumbnailService = App.Current.Services.GetService<IThumbnailService>(); _thumbnailService = App.Current.Services.GetService<IThumbnailService>();
_preferencesService = App.Current.Services.GetService<IPreferencesService>(); _preferencesService = App.Current.Services.GetService<IPreferencesService>();
_contactPictureFileService = App.Current.Services.GetService<IContactPictureFileService>();
} }
catch catch
{ {
@@ -187,7 +189,26 @@ public sealed partial class ImagePreviewControl : PersonPicture
await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false); await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false);
// 1) Explicit contact picture. // Skip all picture loading if the user has disabled sender pictures.
if (_preferencesService?.IsShowSenderPicturesEnabled == false)
return;
// 1) File-based contact picture (preferred — native WIC decode, no base64 overhead).
if (snapshot.ContactPictureFileId.HasValue && _contactPictureFileService != null)
{
var filePath = _contactPictureFileService.GetContactPicturePath(snapshot.ContactPictureFileId.Value);
if (!string.IsNullOrEmpty(filePath))
{
var fileBitmap = await CreateBitmapFromFileAsync(filePath, cancellationToken).ConfigureAwait(false);
if (fileBitmap != null)
{
await ApplyProfilePictureAsync(fileBitmap, refreshVersion, cancellationToken).ConfigureAwait(false);
return;
}
}
}
// 2) Legacy base64 contact picture (used until migration completes or for fallback).
if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture)) if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture))
{ {
var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false); var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false);
@@ -198,7 +219,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
} }
} }
// 2) Gravatar lookup through thumbnail service (if enabled). // 3) Gravatar lookup through thumbnail service (if enabled).
if (_preferencesService?.IsGravatarEnabled == true && if (_preferencesService?.IsGravatarEnabled == true &&
_thumbnailService != null && _thumbnailService != null &&
!string.IsNullOrWhiteSpace(snapshot.Address) && !string.IsNullOrWhiteSpace(snapshot.Address) &&
@@ -219,7 +240,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
} }
} }
// 3) Initials fallback is already in place via DisplayName + ProfilePicture = null. // 4) Initials fallback is already in place via DisplayName + ProfilePicture = null.
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -242,8 +263,9 @@ public sealed partial class ImagePreviewControl : PersonPicture
var address = ResolveAddress(); var address = ResolveAddress();
var displayName = ResolveDisplayName(address); var displayName = ResolveDisplayName(address);
var base64Picture = ResolveBase64Picture(); var base64Picture = ResolveBase64Picture();
var contactPictureFileId = MailItemInformation?.SenderContact?.ContactPictureFileId;
return new RefreshSnapshot(displayName, address, base64Picture); return new RefreshSnapshot(displayName, address, contactPictureFileId, base64Picture);
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
@@ -373,6 +395,31 @@ public sealed partial class ImagePreviewControl : PersonPicture
return await completion.Task.ConfigureAwait(false); return await completion.Task.ConfigureAwait(false);
} }
private async Task<BitmapImage?> CreateBitmapFromFileAsync(string filePath, CancellationToken cancellationToken)
{
byte[] bytes;
try
{
bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
return await ExecuteOnUiThreadAsync(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var memoryStream = new MemoryStream(bytes);
var bitmapImage = new BitmapImage();
bitmapImage.SetSource(memoryStream.AsRandomAccessStream());
return bitmapImage;
}).ConfigureAwait(false);
}
private async Task<BitmapImage?> CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken) private async Task<BitmapImage?> CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(base64)) if (string.IsNullOrWhiteSpace(base64))
@@ -1,11 +1,13 @@
using System; using System;
using System.IO; using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI;
namespace Wino.Dialogs; namespace Wino.Dialogs;
@@ -13,6 +15,7 @@ public sealed partial class ContactEditDialog : ContentDialog
{ {
private AccountContact _contact; private AccountContact _contact;
private IDialogServiceBase? _dialogService; private IDialogServiceBase? _dialogService;
private IContactPictureFileService? _contactPictureFileService;
private bool _isEditMode; private bool _isEditMode;
public AccountContact Contact => _contact; public AccountContact Contact => _contact;
@@ -23,6 +26,7 @@ public sealed partial class ContactEditDialog : ContentDialog
_contact = contact ?? new AccountContact(); _contact = contact ?? new AccountContact();
_dialogService = dialogService; _dialogService = dialogService;
_contactPictureFileService = App.Current.Services.GetService<IContactPictureFileService>();
_isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address); _isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address);
Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle; Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle;
@@ -47,10 +51,19 @@ public sealed partial class ContactEditDialog : ContentDialog
if (_contact.IsOverridden) if (_contact.IsOverridden)
OverriddenContactInfoBorder.Visibility = Visibility.Visible; OverriddenContactInfoBorder.Visibility = Visibility.Visible;
// Load existing photo. // Load existing photo — prefer file-based picture, fall back to legacy base64.
if (!string.IsNullOrEmpty(_contact.Base64ContactPicture)) if (_contact.ContactPictureFileId.HasValue && _contactPictureFileService != null)
{ {
LoadContactPhoto(_contact.Base64ContactPicture); var filePath = _contactPictureFileService.GetContactPicturePath(_contact.ContactPictureFileId.Value);
if (!string.IsNullOrEmpty(filePath))
{
LoadContactPhotoFromFile(filePath);
RemovePhotoButton.Visibility = Visibility.Visible;
}
}
else if (!string.IsNullOrEmpty(_contact.Base64ContactPicture))
{
LoadContactPhotoFromBase64(_contact.Base64ContactPicture);
RemovePhotoButton.Visibility = Visibility.Visible; RemovePhotoButton.Visibility = Visibility.Visible;
} }
else else
@@ -72,9 +85,27 @@ public sealed partial class ContactEditDialog : ContentDialog
if (files?.Count > 0) if (files?.Count > 0)
{ {
var file = files[0]; var file = files[0];
var base64 = Convert.ToBase64String(file.Data);
_contact.Base64ContactPicture = base64; if (_contactPictureFileService != null)
LoadContactPhoto(base64); {
// Delete existing file if replacing.
if (_contact.ContactPictureFileId.HasValue)
await _contactPictureFileService.DeleteContactPictureAsync(_contact.ContactPictureFileId.Value);
var fileId = await _contactPictureFileService.SaveContactPictureAsync(file.Data);
_contact.ContactPictureFileId = fileId;
var filePath = _contactPictureFileService.GetContactPicturePath(fileId);
if (!string.IsNullOrEmpty(filePath))
LoadContactPhotoFromFile(filePath);
}
else
{
// Fallback to legacy base64 when service is unavailable (e.g. design-time).
_contact.Base64ContactPicture = Convert.ToBase64String(file.Data);
LoadContactPhotoFromBase64(_contact.Base64ContactPicture);
}
RemovePhotoButton.Visibility = Visibility.Visible; RemovePhotoButton.Visibility = Visibility.Visible;
} }
} }
@@ -86,13 +117,33 @@ public sealed partial class ContactEditDialog : ContentDialog
private void RemovePhotoClicked(object sender, RoutedEventArgs e) private void RemovePhotoClicked(object sender, RoutedEventArgs e)
{ {
if (_contact.ContactPictureFileId.HasValue && _contactPictureFileService != null)
_ = _contactPictureFileService.DeleteContactPictureAsync(_contact.ContactPictureFileId.Value);
_contact.ContactPictureFileId = null;
_contact.Base64ContactPicture = null; _contact.Base64ContactPicture = null;
ContactPhotoPersonPicture.ProfilePicture = null; ContactPhotoPersonPicture.ProfilePicture = null;
ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text; ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text;
RemovePhotoButton.Visibility = Visibility.Collapsed; RemovePhotoButton.Visibility = Visibility.Collapsed;
} }
private void LoadContactPhoto(string base64String) private void LoadContactPhotoFromFile(string filePath)
{
try
{
var bytes = File.ReadAllBytes(filePath);
using var stream = new MemoryStream(bytes);
var bitmap = new BitmapImage();
bitmap.SetSource(stream.AsRandomAccessStream());
ContactPhotoPersonPicture.ProfilePicture = bitmap;
}
catch
{
// Failed to load image, ignore.
}
}
private void LoadContactPhotoFromBase64(string base64String)
{ {
try try
{ {
+1
View File
@@ -141,6 +141,7 @@
<Content Include="AppThemes\Nighty.xaml" /> <Content Include="AppThemes\Nighty.xaml" />
<Content Include="AppThemes\Snowflake.xaml" /> <Content Include="AppThemes\Snowflake.xaml" />
<Content Include="AppThemes\TestTheme.xaml" /> <Content Include="AppThemes\TestTheme.xaml" />
<Content Include="Assets\ReleaseNotes\vnext.md" />
<Content Include="Assets\Wino_Icon.ico" /> <Content Include="Assets\Wino_Icon.ico" />
<Content Include="BackgroundImages\Acrylic.jpg" /> <Content Include="BackgroundImages\Acrylic.jpg" />
<Content Include="BackgroundImages\Clouds.jpg" /> <Content Include="BackgroundImages\Clouds.jpg" />
@@ -0,0 +1,88 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
namespace Wino.Services;
/// <summary>
/// Stores contact pictures as JPEG files under {ApplicationDataFolderPath}/contacts/{fileId}.jpg.
/// This avoids base64 inline storage in SQLite that bloats all AccountContact queries.
/// </summary>
public class ContactPictureFileService : BaseDatabaseService, IContactPictureFileService
{
private const string ContactsSubFolder = "contacts";
private readonly string _contactPicturesFolder;
private readonly ILogger _logger = Log.ForContext<ContactPictureFileService>();
public ContactPictureFileService(IDatabaseService databaseService, IApplicationConfiguration applicationConfiguration)
: base(databaseService)
{
_contactPicturesFolder = Path.Combine(applicationConfiguration.ApplicationDataFolderPath, ContactsSubFolder);
Directory.CreateDirectory(_contactPicturesFolder);
}
public string GetContactPicturePath(Guid fileId)
{
var path = BuildFilePath(fileId);
return File.Exists(path) ? path : null;
}
public async Task<Guid> SaveContactPictureAsync(byte[] imageData)
{
var fileId = Guid.NewGuid();
var filePath = BuildFilePath(fileId);
await File.WriteAllBytesAsync(filePath, imageData).ConfigureAwait(false);
return fileId;
}
public Task DeleteContactPictureAsync(Guid fileId)
{
var filePath = BuildFilePath(fileId);
if (File.Exists(filePath))
File.Delete(filePath);
return Task.CompletedTask;
}
public async Task MigrateBase64PicturesAsync()
{
try
{
var contacts = await Connection
.QueryAsync<AccountContact>(
"SELECT * FROM AccountContact WHERE Base64ContactPicture IS NOT NULL AND ContactPictureFileId IS NULL")
.ConfigureAwait(false);
foreach (var contact in contacts)
{
try
{
var base64 = contact.Base64ContactPicture;
if (string.IsNullOrEmpty(base64))
continue;
var bytes = Convert.FromBase64String(base64);
var fileId = await SaveContactPictureAsync(bytes).ConfigureAwait(false);
contact.ContactPictureFileId = fileId;
contact.Base64ContactPicture = null;
await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to migrate Base64ContactPicture for contact {Address}.", contact.Address);
}
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to migrate contact pictures from base64 to file system.");
}
}
private string BuildFilePath(Guid fileId) => Path.Combine(_contactPicturesFolder, $"{fileId}.jpg");
}
+301 -13
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -13,6 +14,14 @@ namespace Wino.Services;
public class ContactService : BaseDatabaseService, IContactService public class ContactService : BaseDatabaseService, IContactService
{ {
/// <summary>
/// In-memory contact cache keyed by e-mail address (case-insensitive).
/// Eliminates per-mail DB round-trips during bulk mail list loads.
/// Entries are added on fetch and invalidated on update/delete.
/// </summary>
private readonly ConcurrentDictionary<string, AccountContact> _cache
= new(StringComparer.OrdinalIgnoreCase);
public ContactService(IDatabaseService databaseService) : base(databaseService) { } public ContactService(IDatabaseService databaseService) : base(databaseService) { }
public async Task<AccountContact> CreateNewContactAsync(string address, string displayName) public async Task<AccountContact> CreateNewContactAsync(string address, string displayName)
@@ -21,6 +30,7 @@ public class ContactService : BaseDatabaseService, IContactService
await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false); await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
_cache[address] = contact;
return contact; return contact;
} }
@@ -34,25 +44,61 @@ public class ContactService : BaseDatabaseService, IContactService
return Connection.QueryAsync<AccountContact>(query, pattern, pattern); return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
} }
public Task<AccountContact> GetAddressInformationByAddressAsync(string address) public async Task<AccountContact> GetAddressInformationByAddressAsync(string address)
=> Connection.Table<AccountContact>().FirstOrDefaultAsync(a => a.Address == address);
public Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses)
{ {
var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList(); if (string.IsNullOrEmpty(address))
if (addressList == null || addressList.Count == 0) return null;
return Task.FromResult(new List<AccountContact>());
var placeholders = string.Join(",", addressList.Select(_ => "?")); if (_cache.TryGetValue(address, out var cached))
return Connection.QueryAsync<AccountContact>( return cached;
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
addressList.Cast<object>().ToArray()); var contact = await Connection.Table<AccountContact>().FirstOrDefaultAsync(a => a.Address == address).ConfigureAwait(false);
if (contact != null)
_cache[contact.Address] = contact;
return contact;
}
public async Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses)
{
var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (addressList == null || addressList.Count == 0)
return new List<AccountContact>();
var result = new List<AccountContact>(addressList.Count);
var missing = new List<string>();
foreach (var addr in addressList)
{
if (_cache.TryGetValue(addr, out var cached))
result.Add(cached);
else
missing.Add(addr);
}
if (missing.Count > 0)
{
var placeholders = string.Join(",", missing.Select(_ => "?"));
var fromDb = await Connection.QueryAsync<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
missing.Cast<object>().ToArray()).ConfigureAwait(false);
foreach (var contact in fromDb)
{
_cache[contact.Address] = contact;
result.Add(contact);
}
}
return result;
} }
public async Task SaveAddressInformationAsync(MimeMessage message) public async Task SaveAddressInformationAsync(MimeMessage message)
{ {
if (message == null) return; if (message == null) return;
// Save all individual contacts (GetRecipients expands GroupAddress members automatically).
var contacts = message var contacts = message
.GetRecipients(true) .GetRecipients(true)
.Where(a => !string.IsNullOrWhiteSpace(a.Address)) .Where(a => !string.IsNullOrWhiteSpace(a.Address))
@@ -63,6 +109,72 @@ public class ContactService : BaseDatabaseService, IContactService
}); });
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
// Persist named RFC 2822 group structure (e.g. "Team Alpha: alice@x.com, bob@x.com;").
await SaveGroupsFromInternetAddressesAsync(message.To, message.Cc, message.Bcc).ConfigureAwait(false);
}
/// <summary>
/// Detects <see cref="GroupAddress"/> entries in the supplied address lists and upserts
/// corresponding <see cref="ContactGroup"/> and <see cref="ContactGroupMember"/> rows.
/// Individual member contacts are expected to already be saved by the caller.
/// </summary>
private async Task SaveGroupsFromInternetAddressesAsync(params InternetAddressList[] addressLists)
{
foreach (var list in addressLists)
{
if (list == null) continue;
foreach (var address in list)
{
if (address is not GroupAddress group) continue;
var groupName = group.Name?.Trim();
if (!ShouldPersistGroupName(groupName)) continue;
var memberAddresses = group.Members
.OfType<MailboxAddress>()
.Where(m => !string.IsNullOrWhiteSpace(m.Address))
.Select(m => new { Address = m.Address.Trim(), m.Name })
.Where(m => ShouldPersistAutoCollectedContact(m.Address, m.Name))
.Select(m => m.Address)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (memberAddresses.Count == 0) continue;
var contactGroup = await GetOrCreateGroupByNameAsync(groupName!).ConfigureAwait(false);
// Fetch current members once to avoid duplicate inserts.
var existingMembers = await Connection.QueryAsync<ContactGroupMember>(
"SELECT * FROM ContactGroupMember WHERE GroupId = ?", contactGroup.Id
).ConfigureAwait(false);
var existingAddresses = new HashSet<string>(
existingMembers.Select(m => m.MemberAddress),
StringComparer.OrdinalIgnoreCase
);
foreach (var memberAddress in memberAddresses)
{
if (!existingAddresses.Contains(memberAddress))
await AddGroupMemberAsync(contactGroup.Id, memberAddress).ConfigureAwait(false);
}
}
}
}
/// <summary>
/// Returns the <see cref="ContactGroup"/> with the given name, creating it if it does not exist.
/// </summary>
private async Task<ContactGroup> GetOrCreateGroupByNameAsync(string name)
{
var existing = await Connection.QueryAsync<ContactGroup>(
"SELECT * FROM ContactGroup WHERE Name = ? LIMIT 1", name
).ConfigureAwait(false);
return existing.Count > 0
? existing[0]
: await CreateGroupAsync(name).ConfigureAwait(false);
} }
public async Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts) public async Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts)
@@ -74,7 +186,7 @@ public class ContactService : BaseDatabaseService, IContactService
private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> contacts) private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> contacts)
{ {
var addressInformations = contacts var normalizedContacts = contacts
.Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address)) .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address))
.Select(a => new AccountContact .Select(a => new AccountContact
{ {
@@ -85,10 +197,25 @@ public class ContactService : BaseDatabaseService, IContactService
.Select(g => g.First()) .Select(g => g.First())
.ToList(); .ToList();
if (addressInformations.Count == 0) return; if (normalizedContacts.Count == 0) return;
try try
{ {
var noiseAddresses = normalizedContacts
.Where(a => !ShouldPersistAutoCollectedContact(a.Address, a.Name))
.Select(a => a.Address)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (noiseAddresses.Count > 0)
await DeleteAutoCapturedContactsAsync(noiseAddresses).ConfigureAwait(false);
var addressInformations = normalizedContacts
.Where(a => ShouldPersistAutoCollectedContact(a.Address, a.Name))
.ToList();
if (addressInformations.Count == 0) return;
// Batch-fetch all existing contacts in one query. // Batch-fetch all existing contacts in one query.
var addresses = addressInformations.Select(a => a.Address).ToList(); var addresses = addressInformations.Select(a => a.Address).ToList();
var placeholders = string.Join(",", addresses.Select((_, i) => "?")); var placeholders = string.Join(",", addresses.Select((_, i) => "?"));
@@ -130,6 +257,12 @@ public class ContactService : BaseDatabaseService, IContactService
foreach (var contact in toUpdate) foreach (var contact in toUpdate)
conn.Update(contact, typeof(AccountContact)); conn.Update(contact, typeof(AccountContact));
}).ConfigureAwait(false); }).ConfigureAwait(false);
// Update cache for inserted and updated contacts.
foreach (var c in toInsert)
_cache[c.Address] = c;
foreach (var c in toUpdate)
_cache[c.Address] = c;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -138,6 +271,108 @@ public class ContactService : BaseDatabaseService, IContactService
} }
} }
private async Task DeleteAutoCapturedContactsAsync(IReadOnlyList<string> addresses)
{
if (addresses == null || addresses.Count == 0) return;
var placeholders = string.Join(",", addresses.Select(_ => "?"));
await Connection.ExecuteAsync(
$"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0 AND IsOverridden = 0",
addresses.Cast<object>().ToArray()
).ConfigureAwait(false);
foreach (var address in addresses)
_cache.TryRemove(address, out _);
}
private static bool ShouldPersistAutoCollectedContact(string address, string displayName)
{
if (!TryGetLocalPart(address, out var localPart))
return false;
var localPartLower = localPart.ToLowerInvariant();
// High confidence machine-generated senders/recipients that should not pollute the contact list.
if (localPartLower.StartsWith("reply+", StringComparison.Ordinal) ||
localPartLower.Contains("noreply", StringComparison.Ordinal) ||
localPartLower.Contains("no-reply", StringComparison.Ordinal) ||
localPartLower.Contains("donotreply", StringComparison.Ordinal) ||
localPartLower.Contains("do-not-reply", StringComparison.Ordinal) ||
localPartLower == "mailer-daemon" ||
localPartLower == "postmaster")
{
return false;
}
// Generic notification mailboxes are only persisted when they look human-assigned.
if (localPartLower is "notification" or "notifications" or "updates" or "digest")
return !IsLikelyMachineGeneratedDisplayName(displayName);
return true;
}
private static bool ShouldPersistGroupName(string groupName)
{
if (string.IsNullOrWhiteSpace(groupName)) return false;
var trimmed = groupName.Trim();
var lower = trimmed.ToLowerInvariant();
if (lower.Contains("issue #", StringComparison.Ordinal) ||
lower.Contains("pull request #", StringComparison.Ordinal) ||
lower.Contains("discussion #", StringComparison.Ordinal) ||
lower.Contains("notification", StringComparison.Ordinal))
{
return false;
}
// GitHub-like dynamic repository labels: [owner/repository]
if (trimmed.StartsWith("[", StringComparison.Ordinal) &&
trimmed.Contains('/') &&
trimmed.Contains("]"))
{
return false;
}
return true;
}
private static bool IsLikelyMachineGeneratedDisplayName(string displayName)
{
if (string.IsNullOrWhiteSpace(displayName)) return false;
var trimmed = displayName.Trim();
var lower = trimmed.ToLowerInvariant();
if (lower.Contains("notification", StringComparison.Ordinal) ||
lower.Contains("issue #", StringComparison.Ordinal) ||
lower.Contains("pull request #", StringComparison.Ordinal) ||
lower.Contains("discussion #", StringComparison.Ordinal))
{
return true;
}
return trimmed.StartsWith("[", StringComparison.Ordinal) &&
trimmed.Contains('/') &&
trimmed.Contains("]");
}
private static bool TryGetLocalPart(string address, out string localPart)
{
localPart = string.Empty;
if (string.IsNullOrWhiteSpace(address))
return false;
var trimmed = address.Trim();
var atIndex = trimmed.LastIndexOf('@');
if (atIndex <= 0 || atIndex == trimmed.Length - 1)
return false;
localPart = trimmed[..atIndex];
return !string.IsNullOrWhiteSpace(localPart);
}
public Task<List<AccountContact>> GetAllContactsAsync() public Task<List<AccountContact>> GetAllContactsAsync()
{ {
return Connection.Table<AccountContact>().ToListAsync(); return Connection.Table<AccountContact>().ToListAsync();
@@ -201,6 +436,7 @@ public class ContactService : BaseDatabaseService, IContactService
await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
_cache[contact.Address] = contact;
return contact; return contact;
} }
@@ -211,6 +447,7 @@ public class ContactService : BaseDatabaseService, IContactService
if (contact != null && !contact.IsRootContact) if (contact != null && !contact.IsRootContact)
{ {
await Connection.DeleteAsync<AccountContact>(contact.Address).ConfigureAwait(false); await Connection.DeleteAsync<AccountContact>(contact.Address).ConfigureAwait(false);
_cache.TryRemove(address, out _);
} }
} }
@@ -224,5 +461,56 @@ public class ContactService : BaseDatabaseService, IContactService
$"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0", $"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0",
addressList.Cast<object>().ToArray() addressList.Cast<object>().ToArray()
).ConfigureAwait(false); ).ConfigureAwait(false);
foreach (var addr in addressList)
_cache.TryRemove(addr, out _);
} }
#region Group / Distribution List
public Task<List<ContactGroup>> GetGroupsAsync()
=> Connection.Table<ContactGroup>().OrderBy(g => g.Name).ToListAsync();
public async Task<ContactGroup> CreateGroupAsync(string name, string description = null)
{
var group = new ContactGroup { Id = Guid.NewGuid(), Name = name, Description = description };
await Connection.InsertAsync(group, typeof(ContactGroup)).ConfigureAwait(false);
return group;
}
public async Task DeleteGroupAsync(Guid groupId)
{
// Remove members first to avoid orphaned rows.
await Connection.ExecuteAsync(
"DELETE FROM ContactGroupMember WHERE GroupId = ?", groupId).ConfigureAwait(false);
await Connection.DeleteAsync<ContactGroup>(groupId).ConfigureAwait(false);
}
public async Task<List<AccountContact>> GetGroupMembersAsync(Guid groupId)
{
var members = await Connection.QueryAsync<ContactGroupMember>(
"SELECT * FROM ContactGroupMember WHERE GroupId = ?", groupId).ConfigureAwait(false);
var addresses = members.Select(m => m.MemberAddress).ToList();
return await GetContactsByAddressesAsync(addresses).ConfigureAwait(false);
}
public async Task AddGroupMemberAsync(Guid groupId, string memberAddress)
{
var member = new ContactGroupMember { GroupId = groupId, MemberAddress = memberAddress };
await Connection.InsertAsync(member, typeof(ContactGroupMember)).ConfigureAwait(false);
}
public async Task RemoveGroupMemberAsync(Guid groupId, string memberAddress)
{
await Connection.ExecuteAsync(
"DELETE FROM ContactGroupMember WHERE GroupId = ? AND MemberAddress = ?",
groupId, memberAddress).ConfigureAwait(false);
}
public Task<List<AccountContact>> ExpandGroupAsync(Guid groupId)
=> GetGroupMembersAsync(groupId);
#endregion
} }
+11
View File
@@ -51,6 +51,8 @@ public class DatabaseService : IDatabaseService
Connection.CreateTableAsync<MailItemFolder>(), Connection.CreateTableAsync<MailItemFolder>(),
Connection.CreateTableAsync<MailAccount>(), Connection.CreateTableAsync<MailAccount>(),
Connection.CreateTableAsync<AccountContact>(), Connection.CreateTableAsync<AccountContact>(),
Connection.CreateTableAsync<ContactGroup>(),
Connection.CreateTableAsync<ContactGroupMember>(),
Connection.CreateTableAsync<CustomServerInformation>(), Connection.CreateTableAsync<CustomServerInformation>(),
Connection.CreateTableAsync<AccountSignature>(), Connection.CreateTableAsync<AccountSignature>(),
Connection.CreateTableAsync<MergedInbox>(), Connection.CreateTableAsync<MergedInbox>(),
@@ -117,6 +119,15 @@ public class DatabaseService : IDatabaseService
.ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalendarSupportMode)} INTEGER NOT NULL DEFAULT 0") .ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalendarSupportMode)} INTEGER NOT NULL DEFAULT 0")
.ConfigureAwait(false); .ConfigureAwait(false);
} }
var contactColumns = await Connection.GetTableInfoAsync(nameof(AccountContact)).ConfigureAwait(false);
if (!contactColumns.Any(c => c.Name == nameof(AccountContact.ContactPictureFileId)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(AccountContact)} ADD COLUMN {nameof(AccountContact.ContactPictureFileId)} TEXT NULL")
.ConfigureAwait(false);
}
} }
private async Task EnsureIndexesAsync() private async Task EnsureIndexesAsync()
+2
View File
@@ -26,6 +26,8 @@ public static class ServicesContainerSetup
services.AddTransient<IContextMenuItemService, ContextMenuItemService>(); services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>(); services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>(); services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
services.AddTransient<ICalDavClient, CalDavClient>(); services.AddTransient<ICalDavClient, CalDavClient>();
} }
} }