diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d298efea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Wino Mail is a native Windows mail client (Windows 10 1809+ / Windows 11) replacing the deprecated Windows Mail & Calendar. It's **transitioning from UWP to WinUI 3** - always work with WinUI projects (Wino.Mail.WinUI), never edit the deprecated Wino.Mail UWP project. + +## Build and Development Commands + +```bash +# Open solution +# WinoMail.slnx is the main solution file (VS 2022+) + +# Build from command line +dotnet build WinoMail.slnx -c Debug + +# Run tests +dotnet test Wino.Core.Tests/Wino.Core.Tests.csproj + +# Build specific platform +dotnet build WinoMail.slnx -c Debug /p:Platform=x64 +``` + +**Prerequisites:** Visual Studio 2022+ with ".NET desktop development" workload, .NET SDK 10+ + +**Startup project:** Wino.Mail.WinUI + +**Platforms:** x86, x64, ARM64 + +## Architecture + +### Solution Structure +``` +Wino.Core.Domain → Entities, interfaces, translations, enums (shared contracts) +Wino.Core → Synchronization engine, authenticators, request processing +Wino.Services → Database, mail, folder, account services +Wino.Authentication → OAuth2 authenticators (Outlook, Gmail) +Wino.Mail.ViewModels → Mail-specific ViewModels +Wino.Core.ViewModels → Shared ViewModels (settings, personalization) +Wino.Messaging → Pub-sub message definitions +Wino.Mail.WinUI → **Active WinUI 3 UI project** (use this) +Wino.Mail → **Deprecated UWP project** (DO NOT EDIT) +``` + +### Mail Synchronization Flow +1. **WinoRequestDelegator** → Validates and delegates user actions (mark read, delete, move) +2. **WinoRequestProcessor** → Batches requests using RequestComparer, queues to synchronizers +3. **Synchronizers** (OutlookSynchronizer, GmailSynchronizer, ImapSynchronizer) → Execute batched operations +4. **ChangeProcessors** → Apply changes to local SQLite database +5. Database updates trigger **Messenger** events (MailAddedMessage, MailUpdatedMessage, etc.) + +### Synchronizer Types +- **OutlookSynchronizer** - Microsoft Graph SDK for Office 365 +- **GmailSynchronizer** - Gmail API +- **ImapSynchronizer** - MimeKit/MailKit for IMAP/SMTP + +### Queue-Based Sync Pattern +- Initial sync queues mail IDs first (MailItemQueue table), downloads metadata only +- MIME content downloaded on-demand when user opens mail +- Check `MailItemFolder.IsInitialSyncCompleted` for sync state +- See QUEUE_SYNC_IMPLEMENTATION.md for details + +### Dependency Injection +- `RegisterCoreServices()` in Wino.Core/CoreContainerSetup.cs +- `RegisterSharedServices()` in Wino.Services/ServicesContainerSetup.cs +- ViewModels registered in App.xaml.cs + +## Key Patterns + +### MVVM with Source Generators +**CORRECT - use public partial properties:** +```csharp +[ObservableProperty] +public partial string SearchQuery { get; set; } = string.Empty; +``` + +**WRONG - will not work:** +```csharp +[ObservableProperty] +private string searchQuery = string.Empty; +``` + +### Messenger Pattern +- ViewModels inherit from CoreBaseViewModel or MailBaseViewModel +- Register handlers in `RegisterRecipients()`, unregister in `UnregisterRecipients()` +- Send via `WeakReferenceMessenger.Default.Send(new MessageType(...))` + +### Data Binding - No Converters +- **NEVER** create IValueConverter classes +- WinUI 3 auto-converts bool to Visibility: `Visibility="{x:Bind IsVisible, Mode=OneWay}"` +- Use XamlHelpers for complex conversions: `{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(Prop)}` + +## Localization + +1. Add English strings ONLY to `Wino.Core.Domain/Translations/en_US/resources.json` +2. Build project - source generators create Translator properties +3. Use `Translator.{PropertyName}` in code/XAML +4. **NEVER** edit other language files - Crowdin manages translations + +## Storage + +- **SQLite database** in publisher cache folder (shared with future Wino Calendar) +- **EML files** in app local storage, referenced by `MailCopy.FileId` +- Paths resolved via `MimeFileService.GetMimeMessagePath()` + +## WebView2 Mail Rendering + +- `reader.html` for reading mails, `editor.html` for composing (Jodit editor) +- Virtual host mapping: `https://wino.mail/reader.html` +- JavaScript interop via `ExecuteScriptFunctionAsync()` +- MIME content downloaded on-demand, not during sync + +## Common Pitfalls + +- Forgetting to register ViewModels in App.xaml.cs `RegisterViewModels()` +- Not calling `RegisterRecipients()` for message handlers +- Using private fields with `[ObservableProperty]` instead of public partial +- Creating IValueConverter classes instead of using XamlHelpers +- Editing UWP project files instead of WinUI equivalents +- Hardcoding strings instead of using Translator +- Forgetting to unregister Messenger recipients (memory leaks) + +## Code Style + +- Avoid introducing new NuGet packages when possible +- Use existing libraries (MimeKit, MailKit, Microsoft Graph, Gmail API) +- Use `var` where type is obvious +- String interpolation over string.Format +- Wrap async operations in try-catch +- Log errors via IWinoLogger diff --git a/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md b/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md deleted file mode 100644 index 702848a0..00000000 --- a/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md +++ /dev/null @@ -1,252 +0,0 @@ -# Outlook Queue-Based Synchronization Implementation - -## Overview - -This document describes the implementation of the queue-based, metadata-only synchronization system for Outlook, mirroring the approach used in Gmail but adapted for Outlook's per-folder synchronization model. - -## Key Differences from Gmail - -1. **Per-Folder Queue**: Unlike Gmail which uses per-account `InitialSynchronizationStatus`, Outlook uses per-folder queue tracking via `RemoteFolderId` in `MailItemQueue`. -2. **Folder-Level Processing**: Each folder maintains its own delta token and processes its queue independently. -3. **No Account-Level Status**: Outlook doesn't use `Account.InitialSynchronizationStatus` since sync is per-folder, not per-account. - -## Architecture Changes - -### 1. MailItemQueue Entity Enhancement - -**File**: `Wino.Core.Domain\Entities\Mail\MailItemQueue.cs` - -Added `RemoteFolderId` property to support per-folder queue tracking: - -```csharp -public string RemoteFolderId { get; set; } // For Outlook per-folder sync -``` - -### 2. Service Layer Updates - -**Files**: -- `Wino.Core.Domain\Interfaces\IMailService.cs` -- `Wino.Services\MailService.cs` -- `Wino.Core\Integration\Processors\DefaultChangeProcessor.cs` - -Added new methods for folder-specific queue operations: - -```csharp -Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take); -Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId); -``` - -### 3. OutlookSynchronizer Redesign - -**File**: `Wino.Core\Synchronizers\OutlookSynchronizer.cs` - -#### New Methods - -1. **QueueMailIdsForFolderAsync** - - Queues all mail IDs for a specific folder using Delta API - - Only retrieves message IDs (minimal data transfer) - - Stores delta token for future incremental syncs - - Creates queue entries with `RemoteFolderId` for folder tracking - -2. **ProcessMailQueueForFolderAsync** - - Processes queued mail IDs in batches - - Downloads metadata only (no MIME content) - - Handles failures with retry logic - - Updates queue item status (IsProcessed, FailedCount) - -3. **DownloadMessageMetadataBatchAsync** - - Downloads metadata for a batch of messages concurrently - - Uses semaphore to limit concurrent downloads (10 max) - - Calls `CreateMailCopyFromMessage` for metadata extraction - - Creates `NewMailItemPackage` with null MimeMessage - -4. **CreateMailCopyFromMessage** _(Centralized)_ - - **REPLACES** scattered `AsMailCopy()` and `CreateMinimalMailCopyAsync()` calls - - Single source of truth for converting Graph Message to MailCopy - - Extracts all required fields from metadata - - Sets FolderId, UniqueId, and FileId - -#### Modified Methods - -1. **DownloadMailsForInitialSyncAsync** - - Now orchestrates queue-based sync - - Step 1: Queue all mail IDs via Delta API - - Step 2: Process queue in batches - -2. **ProcessDeltaChangesAndDownloadMailsAsync** - - Downloads delta changes with metadata only - - Uses `DownloadMessageMetadataBatchAsync` instead of full MIME download - -3. **CreateNewMailPackagesAsync** - - Still downloads MIME for specific scenarios (search results, drafts) - - Uses `CreateMailCopyFromMessage` for consistency - - Not called during normal sync operations - -#### Removed Methods - -- `DownloadMailsConcurrentlyAsync` - Replaced by queue system -- `DownloadSingleMailAsync` - Replaced by queue batch processing -- Scattered `CreateMinimalMailCopyAsync` implementations - -## Synchronization Flow - -### Initial Sync (Per Folder) - -``` -1. SynchronizeFolderAsync - ├─ Check: !folder.IsInitialSyncCompleted - └─ DownloadMailsForInitialSyncAsync - ├─ QueueMailIdsForFolderAsync - │ ├─ Use Delta API with Select=["Id"] - │ ├─ Iterate all pages - │ ├─ Create MailItemQueue entries with RemoteFolderId - │ └─ Store delta token - └─ ProcessMailQueueForFolderAsync - ├─ Get queue items by folder (100 at a time) - ├─ Process in chunks of 20 - ├─ DownloadMessageMetadataBatchAsync - │ ├─ Concurrent download (10 max) - │ ├─ GetMessageByIdAsync (metadata fields only) - │ ├─ CreateMailCopyFromMessage - │ └─ CreateMailAsync (package with null MIME) - └─ Update queue status -``` - -### Delta Sync (Per Folder) - -``` -1. SynchronizeFolderAsync - ├─ Check: folder.IsInitialSyncCompleted - └─ ProcessDeltaChangesAndDownloadMailsAsync - ├─ Use Delta API with existing token - ├─ Collect new mail IDs - ├─ DownloadMessageMetadataBatchAsync - │ ├─ Download metadata only - │ └─ Create MailCopy entries - └─ Update delta token -``` - -### On-Demand MIME Download - -``` -User Reads Mail -└─ DownloadMissingMimeMessageAsync - ├─ Download full MIME via /messages/{id}/$value - └─ SaveMimeFileAsync -``` - -## Benefits - -1. **Reduced Bandwidth**: Only metadata downloaded during sync (no 50+ MB MIME files) -2. **Faster Sync**: Parallel processing with controlled concurrency -3. **Resilient**: Queue system handles failures gracefully with retry logic -4. **Consistent**: Centralized `CreateMailCopyFromMessage` method -5. **Scalable**: Per-folder processing allows independent folder syncs - -## Code Consolidation - -### Before (Scattered Approach) - -```csharp -// Multiple places creating MailCopy -var mailCopy = message.AsMailCopy(); -mailCopy.FolderId = folder.Id; -// ... repeat in 3+ locations - -// Mixed MIME downloading -var package = await CreateNewMailPackagesAsync(...); // Downloads MIME -var minimal = await CreateMinimalMailCopyAsync(...); // No MIME -``` - -### After (Centralized Approach) - -```csharp -// Single method for all scenarios -var mailCopy = CreateMailCopyFromMessage(message, folder); - -// Clear separation -// Sync: metadata only -var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId); - -// On-demand: full MIME -await DownloadMissingMimeMessageAsync(mailCopy, ...); -``` - -## Graph API Fields Used - -Only essential fields are requested during sync: - -```csharp -private readonly string[] outlookMessageSelectParameters = -[ - "InferenceClassification", - "Flag", - "Importance", - "IsRead", - "IsDraft", - "ReceivedDateTime", - "HasAttachments", - "BodyPreview", - "Id", - "ConversationId", - "From", - "Subject", - "ParentFolderId", - "InternetMessageId", -]; -``` - -**NOT downloaded during sync**: -- Body content (HTML/Text) -- Raw MIME message -- Attachment content -- Extended properties - -## Migration Path for IMAP (Future) - -The base `WinoSynchronizer` class has been updated with: - -```csharp -protected virtual Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default); -protected virtual Task> DownloadMailsFromQueueAsync(MailItemFolder folder, int batchSize, CancellationToken cancellationToken = default); -protected virtual Task CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default); -``` - -IMAP can override these methods to implement similar queue-based sync: -- TODO: Add folder-based queue support to IMAP -- TODO: Implement metadata-only header parsing -- TODO: Centralize IMAP MailCopy creation - -## Testing Checklist - -- [x] Initial sync queues all mail IDs per folder -- [x] Queue processing downloads metadata only -- [x] Delta sync uses metadata-only approach -- [x] Failed queue items retry correctly -- [x] Concurrent download respects semaphore limits -- [x] Delta token stored and used correctly per folder -- [ ] Search results still download MIME when needed -- [ ] Draft handling works with MIME headers -- [ ] On-demand MIME download functions correctly -- [ ] Large folders (1000+ messages) sync efficiently -- [ ] Network interruption recovery - -## Performance Expectations - -### Before (With MIME): -- 100 messages ≈ 100-500 MB download -- Sync time: 5-10 minutes -- API calls: 100+ individual message downloads - -### After (Metadata Only): -- 100 messages ≈ 1-5 MB download (metadata) -- Sync time: 30-60 seconds -- API calls: Batched requests (10-20 concurrent) -- MIME downloaded only when user reads (lazy loading) - -## Notes - -- `CreateNewMailPackagesAsync` is now marked as **only for special cases** -- `DefaultChangeProcessor` no longer needed for basic operations -- All synchronizers can benefit from this pattern (Gmail, IMAP, Outlook) -- The `InitialSyncMimeDownloadCount` property is now obsolete diff --git a/QUEUE_SYNC_IMPLEMENTATION.md b/QUEUE_SYNC_IMPLEMENTATION.md deleted file mode 100644 index d7ab850b..00000000 --- a/QUEUE_SYNC_IMPLEMENTATION.md +++ /dev/null @@ -1,151 +0,0 @@ -# Mail Synchronization Queue-Based Implementation - -This document summarizes the changes made to implement the new queue-based mail synchronization system for Wino Mail. - -## Overview - -The new system changes the mail synchronization approach from downloading everything immediately during initial sync to queuing mail IDs first and then downloading mail content progressively. This makes initial synchronization much more efficient and responsive. - -## Changes Made - -### 1. New Database Entity: MailItemQueue - -**File:** `Wino.Core.Domain/Entities/Mail/MailItemQueue.cs` - -Created a new table to store mail IDs that need to be downloaded from the server: -- `Id`: Primary key (auto-increment) -- `AccountId`: Account that owns the mail -- `FolderId`: Local folder ID -- `RemoteFolderId`: Server-specific folder ID -- `MailCopyId`: Mail ID from the remote server -- `QueuedDate`: When the item was queued -- `Priority`: Priority for processing (lower number = higher priority) - -### 2. Enhanced MailItemFolder Entity - -**File:** `Wino.Core.Domain/Entities/Mail/MailItemFolder.cs` - -Added new property: -- `IsInitialSyncCompleted`: Boolean flag to track whether initial mail ID synchronization is complete for the folder - -### 3. New Queue Management Service - -**Files:** -- `Wino.Core.Domain/Interfaces/IMailItemQueueService.cs` -- `Wino.Services/MailItemQueueService.cs` - -Created a comprehensive service for managing the mail queue with methods to: -- Queue mail items for download -- Get next batch of items to process -- Remove processed items from queue -- Check queue counts and existence -- Clear queue for folders/accounts - -### 4. Updated Database Service - -**File:** `Wino.Services/DatabaseService.cs` - -Added `MailItemQueue` to the database table creation list. - -### 5. Enhanced Base Synchronizer - -**File:** `Wino.Core/Synchronizers/WinoSynchronizer.cs` - -Added new virtual methods that synchronizers can override to support queue-based sync: -- `QueueMailIdsForInitialSyncAsync()`: Queue all mail IDs for initial sync -- `DownloadMailsFromQueueAsync()`: Download mails from queue in batches -- `CreateMinimalMailCopyAsync()`: Create MailCopy with minimal properties (no MIME download) - -### 6. OutlookSynchronizer Implementation - -**File:** `Wino.Core/Synchronizers/OutlookSynchronizer.cs` - -Major changes to implement the new synchronization logic: - -#### Constructor Changes -- Added `IMailItemQueueService` dependency injection - -#### New Synchronization Algorithm -The `SynchronizeFolderAsync` method now implements the new algorithm: - -1. **Check Initial Sync Status**: If `IsInitialSyncCompleted` is false: - - Clear existing queue items for the folder - - Queue all mail IDs using `QueueMailIdsForInitialSyncAsync()` - - Mark initial sync as completed - -2. **Process Queue**: Download mails from queue in batches (50 at a time): - - Get queued items for the folder - - Download each mail with minimal properties (no MIME) - - Create MailCopy objects with essential fields only - - Remove processed items from queue - -3. **Process Delta Changes**: Handle incremental changes using existing delta sync logic - -#### New Methods Implemented -- `QueueMailIdsForInitialSyncAsync()`: Uses PageIterator to efficiently get all message IDs -- `CreateMinimalMailCopyAsync()`: Creates MailCopy without downloading MIME content -- `GetMessageByIdAsync()`: Downloads individual messages with selected properties only -- `ProcessDeltaChangesAsync()`: Handles incremental sync with delta tokens - -### 7. Enhanced Change Processor Interface - -**File:** `Wino.Core/Integration/Processors/DefaultChangeProcessor.cs` - -Added new method to `IOutlookChangeProcessor`: -- `UpdateFolderInitialSyncCompletedAsync()`: Updates the initial sync completion status - -**File:** `Wino.Core/Integration/Processors/OutlookChangeProcessor.cs` - -Implemented the new method to update the `IsInitialSyncCompleted` field in the database. - -### 8. Dependency Injection Updates - -**Files:** -- `Wino.Core/Services/SynchronizerFactory.cs`: Added `IMailItemQueueService` dependency and updated OutlookSynchronizer creation -- `Wino.Services/ServicesContainerSetup.cs`: Registered `IMailItemQueueService` as transient service - -## Key Benefits - -1. **Faster Initial Sync**: Only mail IDs are downloaded initially, making the sync much faster -2. **Progressive Loading**: Mail content is downloaded progressively based on queue -3. **Better User Experience**: Users see folder structure and mail list faster -4. **Efficient Resource Usage**: Avoids downloading full MIME messages during initial sync -5. **Prioritization Support**: Queue system supports priority-based processing -6. **Resilient**: Can handle sync interruptions and resume from where it left off - -## Implementation Details - -### Queue-Based Processing -- Initial sync: Download only message IDs and queue them -- Progressive download: Process queue items in batches -- Minimal properties: Download only essential mail properties (Subject, Preview, etc.) -- No MIME download: Full MIME messages are not downloaded during initial sync - -### Delta Sync Integration -- Existing delta sync logic is preserved for incremental changes -- Delta tokens are properly managed and updated -- Expired tokens trigger queue reset and re-sync - -### Error Handling -- Robust error handling for individual mail downloads -- Failed items don't block the entire batch -- Proper logging for debugging and monitoring - -## Future Enhancements - -The architecture is designed to be easily extended to other synchronizers: -- Gmail and IMAP synchronizers can adopt the same pattern -- Common functionality is in the base `WinoSynchronizer` class -- Queue service is provider-agnostic - -## Testing Recommendations - -1. Test initial synchronization with large mailboxes -2. Verify progressive loading of mail content -3. Test interruption and resume scenarios -4. Validate delta sync functionality -5. Test with multiple accounts and folders -6. Verify queue management operations -7. Test error scenarios and recovery - -This implementation significantly improves the initial synchronization experience while maintaining all existing functionality for incremental syncing. \ No newline at end of file diff --git a/Wino.Core.Domain/Enums/MailUpdateSource.cs b/Wino.Core.Domain/Enums/MailUpdateSource.cs index 4e5b86bf..e8a3b91f 100644 --- a/Wino.Core.Domain/Enums/MailUpdateSource.cs +++ b/Wino.Core.Domain/Enums/MailUpdateSource.cs @@ -6,9 +6,14 @@ namespace Wino.Core.Domain.Enums; public enum MailUpdateSource { /// - /// Update originated from client-side UI changes (ApplyUIChanges/RevertUIChanges). + /// Update originated from client-side UI changes (ApplyUIChanges). /// - Client, + ClientUpdated, + + /// + /// Update originated from client-side UI revert (RevertUIChanges). + /// + ClientReverted, /// /// Update originated from server synchronization or database operations. diff --git a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs index 1c0e4f4b..63d133b2 100644 --- a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs +++ b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs @@ -15,9 +15,12 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail { foreach (var item in MailsToMarkRead) { + // Skip if already read + if (item.IsRead) continue; + item.IsRead = true; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientUpdated)); } } @@ -25,9 +28,12 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail { foreach (var item in MailsToMarkRead) { + // Skip if already unread (wasn't changed by ApplyUIChanges) + if (!item.IsRead) continue; + item.IsRead = false; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs index 4ae4800c..50ab0809 100644 --- a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs +++ b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs @@ -18,18 +18,30 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeFlag; + /// + /// Gets whether this request represents an actual state change. + /// If the mail is already in the desired flagged state, no change is needed. + /// + public bool IsNoOp => Item.IsFlagged == IsFlagged; + public override void ApplyUIChanges() { + // Skip UI update if the mail is already in the desired state + if (IsNoOp) return; + Item.IsFlagged = IsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { + // Skip UI revert if this was a no-op request + if (IsNoOp) return; + Item.IsFlagged = !IsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/MarkReadRequest.cs b/Wino.Core/Requests/Mail/MarkReadRequest.cs index 046aee01..a7edc8c2 100644 --- a/Wino.Core/Requests/Mail/MarkReadRequest.cs +++ b/Wino.Core/Requests/Mail/MarkReadRequest.cs @@ -17,18 +17,30 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item public bool ExcludeMustHaveFolders => true; + /// + /// Gets whether this request represents an actual state change. + /// If the mail is already in the desired read state, no change is needed. + /// + public bool IsNoOp => Item.IsRead == IsRead; + public override void ApplyUIChanges() { + // Skip UI update if the mail is already in the desired state + if (IsNoOp) return; + Item.IsRead = IsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { + // Skip UI revert if this was a no-op request + if (IsNoOp) return; + Item.IsRead = !IsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.Client)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted)); } } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 55ca5fd6..900766c9 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -724,7 +724,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient /// Updated mail copy. /// - public Task UpdateMailCopy(MailCopy updatedMailCopy) + public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource) { // This item doesn't exist in the list. if (!MailCopyIdHashSet.ContainsKey(updatedMailCopy.UniqueId)) return Task.CompletedTask; @@ -743,14 +743,14 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + /// Indicates if this mail item is currently being processed by a network operation. + /// Used to show loading state in the UI. + /// + [ObservableProperty] + public partial bool IsBusy { get; set; } + public DateTime CreationDate { get => MailCopy.CreationDate; diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index 6ffe5790..5f360ee1 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -25,6 +25,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(IsSelectedOrExpanded))] public partial bool IsSelected { get; set; } + [ObservableProperty] + public partial bool IsBusy { get; set; } + public bool IsSelectedOrExpanded => IsSelected || IsThreadExpanded; /// diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 27139c2f..79399447 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -668,7 +668,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, { base.OnMailUpdated(updatedMail, source); - await MailCollection.UpdateMailCopy(updatedMail); + await MailCollection.UpdateMailCopy(updatedMail, source); await ExecuteUIThread(() => { SetupTopBarActions(); }); } diff --git a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml index 05c4b602..784b4cb6 100644 --- a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml +++ b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml @@ -244,6 +244,13 @@ x:Name="IsFlaggedContent" x:Load="{x:Bind IsFlagged, Mode=OneWay}" ContentTemplate="{StaticResource FlaggedSymbolControlTemplate}" /> + + diff --git a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs index ef5cda22..23a35dca 100644 --- a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs +++ b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml.cs @@ -1,11 +1,12 @@ using System; using System.Linq; using System.Numerics; -using System.Windows.Input; using CommunityToolkit.WinUI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Composition; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; @@ -21,6 +22,11 @@ public sealed partial class MailItemDisplayInformationControl : UserControl public bool IsRunningHoverAction { get; set; } + // Busy animation fields + private Compositor? _compositor; + private Visual? _contentVisual; + private ScalarKeyFrameAnimation? _opacityAnimation; + [GeneratedDependencyProperty(DefaultValue = MailListDisplayMode.Spacious)] public partial MailListDisplayMode DisplayMode { get; set; } @@ -45,6 +51,9 @@ public sealed partial class MailItemDisplayInformationControl : UserControl [GeneratedDependencyProperty(DefaultValue = true)] public partial bool IsHoverActionsEnabled { get; set; } + [GeneratedDependencyProperty(DefaultValue = false)] + public partial bool IsBusy { get; set; } + public event EventHandler? HoverActionExecuted; [GeneratedDependencyProperty(DefaultValue = false)] @@ -124,6 +133,55 @@ public sealed partial class MailItemDisplayInformationControl : UserControl IconsContainer.EnableImplicitAnimation(VisualPropertyType.Offset, 400); RootContainerVisualWrapper.SizeChanged += (s, e) => leftBackgroundVisual.Size = e.NewSize.ToVector2(); + + // Initialize shimmer effect compositor + _compositor = this.Visual().Compositor; + } + + partial void OnIsBusyChanged(bool newValue) + { + if (newValue) + { + StartBusyAnimation(); + } + else + { + StopBusyAnimation(); + } + } + + private void StartBusyAnimation() + { + if (_compositor == null) return; + + // Get the visual for the content area + _contentVisual = ElementCompositionPreview.GetElementVisual(MainContentContainer); + + // Create a subtle opacity pulse animation (1.0 -> 0.4 -> 1.0) + _opacityAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _opacityAnimation.InsertKeyFrame(0f, 1f); + _opacityAnimation.InsertKeyFrame(0.5f, 0.4f, _compositor.CreateCubicBezierEasingFunction(new Vector2(0.42f, 0f), new Vector2(0.58f, 1f))); + _opacityAnimation.InsertKeyFrame(1f, 1f, _compositor.CreateCubicBezierEasingFunction(new Vector2(0.42f, 0f), new Vector2(0.58f, 1f))); + _opacityAnimation.Duration = TimeSpan.FromSeconds(1.0); + _opacityAnimation.IterationBehavior = AnimationIterationBehavior.Forever; + + // Start animation + _contentVisual.StartAnimation("Opacity", _opacityAnimation); + } + + private void StopBusyAnimation() + { + if (_contentVisual != null) + { + _contentVisual.StopAnimation("Opacity"); + + // Reset to default value + _contentVisual.Opacity = 1f; + + _contentVisual = null; + } + + _opacityAnimation = null; } partial void OnIsFlaggedChanged(bool newValue) diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index d610e178..00593681 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -62,6 +62,7 @@ FromName="{x:Bind FromName}" HasAttachments="{x:Bind HasAttachments, Mode=OneWay}" HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted" + IsBusy="{x:Bind IsBusy, Mode=OneWay}" IsDraft="{x:Bind IsDraft, Mode=OneWay}" IsFlagged="{x:Bind IsFlagged, Mode=OneWay}" IsRead="{x:Bind IsRead, Mode=OneWay}" @@ -86,6 +87,7 @@ FromName="{x:Bind FromName, Mode=OneWay}" HasAttachments="{x:Bind HasAttachments, Mode=OneWay}" HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted" + IsBusy="{x:Bind IsBusy, Mode=OneWay}" IsDraft="{x:Bind IsDraft, Mode=OneWay}" IsFlagged="{x:Bind IsFlagged, Mode=OneWay}" IsRead="{x:Bind IsRead, Mode=OneWay}" @@ -208,13 +210,14 @@ + Translation="0,0,50"> diff --git a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml index 3784fd89..c6b9a0c5 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailRenderingPage.xaml @@ -169,7 +169,11 @@ Background="{ThemeResource WinoContentZoneBackgroud}" BorderBrush="{StaticResource CardStrokeColorDefaultBrush}" BorderThickness="1" - CornerRadius="{ThemeResource OverlayCornerRadius}"> + CornerRadius="{ThemeResource OverlayCornerRadius}" + Translation="0,0,20"> + + + @@ -473,6 +477,7 @@ BorderThickness="1" CornerRadius="{ThemeResource OverlayCornerRadius}"> +