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
+3
View File
@@ -146,6 +146,9 @@ public partial class App : WinoApplication,
// Note: Theme service is initialized separately after window creation.
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>();
_preferencesService = Services.GetRequiredService<IPreferencesService>();
_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>
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);
@@ -32,6 +32,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
private readonly IThumbnailService? _thumbnailService;
private readonly IPreferencesService? _preferencesService;
private readonly IContactPictureFileService? _contactPictureFileService;
private INotifyPropertyChanged? _mailItemInformationPropertySource;
private CancellationTokenSource? _refreshCancellationTokenSource;
private CancellationTokenSource? _scheduledRefreshCancellationTokenSource;
@@ -45,6 +46,7 @@ public sealed partial class ImagePreviewControl : PersonPicture
{
_thumbnailService = App.Current.Services.GetService<IThumbnailService>();
_preferencesService = App.Current.Services.GetService<IPreferencesService>();
_contactPictureFileService = App.Current.Services.GetService<IContactPictureFileService>();
}
catch
{
@@ -187,7 +189,26 @@ public sealed partial class ImagePreviewControl : PersonPicture
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))
{
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 &&
_thumbnailService != null &&
!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)
{
@@ -242,8 +263,9 @@ public sealed partial class ImagePreviewControl : PersonPicture
var address = ResolveAddress();
var displayName = ResolveDisplayName(address);
var base64Picture = ResolveBase64Picture();
var contactPictureFileId = MailItemInformation?.SenderContact?.ContactPictureFileId;
return new RefreshSnapshot(displayName, address, base64Picture);
return new RefreshSnapshot(displayName, address, contactPictureFileId, base64Picture);
}).ConfigureAwait(false);
}
@@ -373,6 +395,31 @@ public sealed partial class ImagePreviewControl : PersonPicture
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)
{
if (string.IsNullOrWhiteSpace(base64))
@@ -1,11 +1,13 @@
using System;
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI;
namespace Wino.Dialogs;
@@ -13,6 +15,7 @@ public sealed partial class ContactEditDialog : ContentDialog
{
private AccountContact _contact;
private IDialogServiceBase? _dialogService;
private IContactPictureFileService? _contactPictureFileService;
private bool _isEditMode;
public AccountContact Contact => _contact;
@@ -23,6 +26,7 @@ public sealed partial class ContactEditDialog : ContentDialog
_contact = contact ?? new AccountContact();
_dialogService = dialogService;
_contactPictureFileService = App.Current.Services.GetService<IContactPictureFileService>();
_isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address);
Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle;
@@ -47,10 +51,19 @@ public sealed partial class ContactEditDialog : ContentDialog
if (_contact.IsOverridden)
OverriddenContactInfoBorder.Visibility = Visibility.Visible;
// Load existing photo.
if (!string.IsNullOrEmpty(_contact.Base64ContactPicture))
// Load existing photo — prefer file-based picture, fall back to legacy base64.
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;
}
else
@@ -72,9 +85,27 @@ public sealed partial class ContactEditDialog : ContentDialog
if (files?.Count > 0)
{
var file = files[0];
var base64 = Convert.ToBase64String(file.Data);
_contact.Base64ContactPicture = base64;
LoadContactPhoto(base64);
if (_contactPictureFileService != null)
{
// 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;
}
}
@@ -86,13 +117,33 @@ public sealed partial class ContactEditDialog : ContentDialog
private void RemovePhotoClicked(object sender, RoutedEventArgs e)
{
if (_contact.ContactPictureFileId.HasValue && _contactPictureFileService != null)
_ = _contactPictureFileService.DeleteContactPictureAsync(_contact.ContactPictureFileId.Value);
_contact.ContactPictureFileId = null;
_contact.Base64ContactPicture = null;
ContactPhotoPersonPicture.ProfilePicture = null;
ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text;
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
{
+1
View File
@@ -141,6 +141,7 @@
<Content Include="AppThemes\Nighty.xaml" />
<Content Include="AppThemes\Snowflake.xaml" />
<Content Include="AppThemes\TestTheme.xaml" />
<Content Include="Assets\ReleaseNotes\vnext.md" />
<Content Include="Assets\Wino_Icon.ico" />
<Content Include="BackgroundImages\Acrylic.jpg" />
<Content Include="BackgroundImages\Clouds.jpg" />