Bunch of improvements i dunno.
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
|
||||||
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
||||||
<PackageVersion Include="sqlite-net-pcl" Version="1.9.172" />
|
<PackageVersion Include="sqlite-net-pcl" Version="1.10.196-beta" />
|
||||||
<PackageVersion Include="SqlKata" Version="4.0.1" />
|
<PackageVersion Include="SqlKata" Version="4.0.1" />
|
||||||
<PackageVersion Include="System.Drawing.Common" Version="9.0.10" />
|
<PackageVersion Include="System.Drawing.Common" Version="9.0.10" />
|
||||||
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# 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<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
|
||||||
|
Task<int> 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<List<string>> DownloadMailsFromQueueAsync(MailItemFolder folder, int batchSize, CancellationToken cancellationToken = default);
|
||||||
|
protected virtual Task<MailCopy> 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
|
||||||
@@ -4,6 +4,7 @@ using Wino.Core.Domain.Interfaces;
|
|||||||
|
|
||||||
namespace Wino.Core.Domain.Entities.Calendar;
|
namespace Wino.Core.Domain.Entities.Calendar;
|
||||||
|
|
||||||
|
[Preserve]
|
||||||
public class AccountCalendar : IAccountCalendar
|
public class AccountCalendar : IAccountCalendar
|
||||||
{
|
{
|
||||||
[PrimaryKey]
|
[PrimaryKey]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class MailItemFolder : IMailItemFolder
|
|||||||
/// Whether initial synchronization of mail ids is completed for this folder.
|
/// Whether initial synchronization of mail ids is completed for this folder.
|
||||||
/// Used to determine if we should queue all mail ids first or start downloading from queue.
|
/// Used to determine if we should queue all mail ids first or start downloading from queue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsInitialSyncCompleted { get; set; }
|
public InitialSynchronizationStatus FolderStatus { get; set; }
|
||||||
|
|
||||||
// For GMail Labels
|
// For GMail Labels
|
||||||
public string TextColorHex { get; set; }
|
public string TextColorHex { get; set; }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public class MailItemQueue
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
public string RemoteServerId { get; set; }
|
public string RemoteServerId { get; set; }
|
||||||
|
public string RemoteFolderId { get; set; } // For Outlook per-folder sync
|
||||||
public bool IsProcessed { get; set; }
|
public bool IsProcessed { get; set; }
|
||||||
public int FailedCount { get; set; }
|
public int FailedCount { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|||||||
@@ -7,7 +7,27 @@ namespace Wino.Core.Domain.Interfaces;
|
|||||||
public interface IAccountMenuItem : IMenuItem
|
public interface IAccountMenuItem : IMenuItem
|
||||||
{
|
{
|
||||||
bool IsEnabled { get; set; }
|
bool IsEnabled { get; set; }
|
||||||
double SynchronizationProgress { get; set; }
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculated synchronization progress percentage (0-100). -1 for indeterminate.
|
||||||
|
/// </summary>
|
||||||
|
double SynchronizationProgress { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total items to sync. 0 for indeterminate progress.
|
||||||
|
/// </summary>
|
||||||
|
int TotalItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaining items to sync.
|
||||||
|
/// </summary>
|
||||||
|
int RemainingItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current synchronization status message.
|
||||||
|
/// </summary>
|
||||||
|
string SynchronizationStatus { get; set; }
|
||||||
|
|
||||||
int UnreadItemCount { get; set; }
|
int UnreadItemCount { get; set; }
|
||||||
IEnumerable<MailAccount> HoldingAccounts { get; }
|
IEnumerable<MailAccount> HoldingAccounts { get; }
|
||||||
void UpdateAccount(MailAccount account);
|
void UpdateAccount(MailAccount account);
|
||||||
|
|||||||
@@ -166,5 +166,7 @@ public interface IMailService
|
|||||||
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
|
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
|
||||||
Task<int> GetMailItemQueueCountAsync(Guid accountId);
|
Task<int> GetMailItemQueueCountAsync(Guid accountId);
|
||||||
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
|
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
|
||||||
|
Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
|
||||||
|
Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
|
||||||
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
|
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,18 +14,57 @@ public partial class AccountMenuItem : MenuItemBase<MailAccount, MenuItemBase<IM
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int unreadItemCount;
|
private int unreadItemCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total items to sync. 0 means indeterminate progress.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))]
|
[NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible), nameof(SynchronizationProgress), nameof(IsProgressIndeterminate))]
|
||||||
private double synchronizationProgress;
|
public partial int TotalItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaining items to sync.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
|
||||||
|
public partial int RemainingItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current synchronization status message.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isEnabled = true;
|
private bool _isEnabled = true;
|
||||||
|
|
||||||
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
|
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
|
||||||
public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100;
|
|
||||||
|
|
||||||
// We can't determine the progress for gmail synchronization since it is based on history changes.
|
/// <summary>
|
||||||
public bool IsProgressIndeterminate => Parameter?.ProviderType == MailProviderType.Gmail;
|
/// Calculates synchronization progress percentage (0-100).
|
||||||
|
/// Returns -1 for indeterminate progress when TotalItemsToSync is 0.
|
||||||
|
/// </summary>
|
||||||
|
public double SynchronizationProgress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
|
||||||
|
return -1; // Indeterminate
|
||||||
|
|
||||||
|
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether synchronization progress should be visible.
|
||||||
|
/// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether progress should be indeterminate (when total is 0 but there's still synchronization happening).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsProgressIndeterminate => TotalItemsToSync == 0 && RemainingItemsToSync == 0 && IsSynchronizationProgressVisible;
|
||||||
|
|
||||||
public Guid AccountId => Parameter.Id;
|
public Guid AccountId => Parameter.Id;
|
||||||
|
|
||||||
private AccountAttentionReason attentionReason;
|
private AccountAttentionReason attentionReason;
|
||||||
|
|||||||
@@ -16,8 +16,39 @@ public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int unreadItemCount;
|
private int unreadItemCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total items to sync across all merged accounts.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double synchronizationProgress;
|
[NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
|
||||||
|
public partial int TotalItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaining items to sync across all merged accounts.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(SynchronizationProgress))]
|
||||||
|
public partial int RemainingItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current synchronization status message.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculated synchronization progress for merged accounts.
|
||||||
|
/// </summary>
|
||||||
|
public double SynchronizationProgress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
|
||||||
|
return -1; // Indeterminate
|
||||||
|
|
||||||
|
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string mergedAccountName;
|
private string mergedAccountName;
|
||||||
@@ -35,6 +66,20 @@ public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem
|
|||||||
{
|
{
|
||||||
UnreadItemCount = SubMenuItems.OfType<IAccountMenuItem>().Sum(a => a.UnreadItemCount);
|
UnreadItemCount = SubMenuItems.OfType<IAccountMenuItem>().Sum(a => a.UnreadItemCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates synchronization progress from all child account menu items.
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshSynchronizationProgress()
|
||||||
|
{
|
||||||
|
var accountMenuItems = SubMenuItems.OfType<IAccountMenuItem>().ToList();
|
||||||
|
|
||||||
|
TotalItemsToSync = accountMenuItems.Sum(a => a.TotalItemsToSync);
|
||||||
|
RemainingItemsToSync = accountMenuItems.Sum(a => a.RemainingItemsToSync);
|
||||||
|
|
||||||
|
// Use first non-empty status message
|
||||||
|
SynchronizationStatus = accountMenuItems.FirstOrDefault(a => !string.IsNullOrEmpty(a.SynchronizationStatus))?.SynchronizationStatus ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateAccount(MailAccount account)
|
public void UpdateAccount(MailAccount account)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using System.Net.Mail;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Gravatar;
|
using Gravatar;
|
||||||
using Windows.Networking.Connectivity;
|
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
@@ -92,10 +91,10 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS
|
|||||||
|
|
||||||
// No network available, skip fetching Gravatar
|
// No network available, skip fetching Gravatar
|
||||||
// Do not cache it, since network can be available later
|
// Do not cache it, since network can be available later
|
||||||
bool isInternetAvailable = GetIsInternetAvailable();
|
//bool isInternetAvailable = GetIsInternetAvailable();
|
||||||
|
|
||||||
if (!isInternetAvailable)
|
//if (!isInternetAvailable)
|
||||||
return default;
|
// return default;
|
||||||
|
|
||||||
if (!_requests.TryGetValue(email, out var request))
|
if (!_requests.TryGetValue(email, out var request))
|
||||||
{
|
{
|
||||||
@@ -112,11 +111,11 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS
|
|||||||
|
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
static bool GetIsInternetAvailable()
|
//static bool GetIsInternetAvailable()
|
||||||
{
|
//{
|
||||||
var connection = NetworkInformation.GetInternetConnectionProfile();
|
// var connection = NetworkInformation.GetInternetConnectionProfile();
|
||||||
return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess;
|
// return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess;
|
||||||
}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RequestNewThumbnail(string email)
|
private async Task RequestNewThumbnail(string email)
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public interface IDefaultChangeProcessor
|
|||||||
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
|
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
|
||||||
Task<int> GetMailItemQueueCountAsync(Guid accountId);
|
Task<int> GetMailItemQueueCountAsync(Guid accountId);
|
||||||
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
|
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
|
||||||
|
Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
|
||||||
|
Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
|
||||||
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
|
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,12 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
|||||||
public Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take)
|
public Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take)
|
||||||
=> MailService.GetMailItemQueueAsync(accountId, take);
|
=> MailService.GetMailItemQueueAsync(accountId, take);
|
||||||
|
|
||||||
|
public Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
|
||||||
|
=> MailService.GetMailItemQueueByFolderAsync(accountId, remoteFolderId, take);
|
||||||
|
|
||||||
|
public Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
|
||||||
|
=> MailService.GetMailItemQueueCountByFolderAsync(accountId, remoteFolderId);
|
||||||
|
|
||||||
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
|
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
|
||||||
=> MailService.UpdateMailItemQueueAsync(queueItems);
|
=> MailService.UpdateMailItemQueueAsync(queueItems);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
|||||||
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
|
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
|
||||||
|
|
||||||
public Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted)
|
public Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted)
|
||||||
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET IsInitialSyncCompleted = ? WHERE Id = ?", isCompleted, folderId);
|
{
|
||||||
|
var status = isCompleted ? InitialSynchronizationStatus.Completed : InitialSynchronizationStatus.None;
|
||||||
|
return Connection.ExecuteAsync("UPDATE MailItemFolder SET FolderStatus = ? WHERE Id = ?", status, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
@@ -13,12 +14,14 @@ using Wino.Messaging.UI;
|
|||||||
|
|
||||||
namespace Wino.Core.Synchronizers;
|
namespace Wino.Core.Synchronizers;
|
||||||
|
|
||||||
public abstract class BaseSynchronizer<TBaseRequest> : IBaseSynchronizer
|
public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject, IBaseSynchronizer
|
||||||
{
|
{
|
||||||
protected SemaphoreSlim synchronizationSemaphore = new(1);
|
protected SemaphoreSlim synchronizationSemaphore = new(1);
|
||||||
protected CancellationToken activeSynchronizationCancellationToken;
|
protected CancellationToken activeSynchronizationCancellationToken;
|
||||||
|
|
||||||
protected List<IRequestBase> changeRequestQueue = [];
|
protected List<IRequestBase> changeRequestQueue = [];
|
||||||
|
protected readonly IMessenger Messenger;
|
||||||
|
|
||||||
public MailAccount Account { get; }
|
public MailAccount Account { get; }
|
||||||
|
|
||||||
private AccountSynchronizerState state;
|
private AccountSynchronizerState state;
|
||||||
@@ -29,13 +32,87 @@ public abstract class BaseSynchronizer<TBaseRequest> : IBaseSynchronizer
|
|||||||
{
|
{
|
||||||
state = value;
|
state = value;
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Send(new AccountSynchronizerStateChanged(Account.Id, value));
|
// Send state changed message with current progress information
|
||||||
|
Messenger.Send(new AccountSynchronizerStateChanged(
|
||||||
|
Account.Id,
|
||||||
|
value,
|
||||||
|
TotalItemsToSync,
|
||||||
|
RemainingItemsToSync,
|
||||||
|
SynchronizationStatus));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected BaseSynchronizer(MailAccount account)
|
/// <summary>
|
||||||
|
/// Current synchronization status message.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total items to download/sync in current operation.
|
||||||
|
/// 0 means no active download or indeterminate progress.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial int TotalItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remaining items to download/sync in current operation.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial int RemainingItemsToSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculated progress percentage (0-100) based on TotalItemsToSync and RemainingItemsToSync.
|
||||||
|
/// Returns -1 for indeterminate progress (when both are 0).
|
||||||
|
/// </summary>
|
||||||
|
public double SynchronizationProgress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
|
||||||
|
return -1; // Indeterminate
|
||||||
|
|
||||||
|
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BaseSynchronizer(MailAccount account, IMessenger messenger)
|
||||||
{
|
{
|
||||||
Account = account;
|
Account = account;
|
||||||
|
Messenger = messenger ?? WeakReferenceMessenger.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets synchronization progress to default state.
|
||||||
|
/// </summary>
|
||||||
|
protected void ResetSyncProgress()
|
||||||
|
{
|
||||||
|
TotalItemsToSync = 0;
|
||||||
|
RemainingItemsToSync = 0;
|
||||||
|
SynchronizationStatus = string.Empty;
|
||||||
|
OnPropertyChanged(nameof(SynchronizationProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates synchronization progress with current item counts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="total">Total items to sync</param>
|
||||||
|
/// <param name="remaining">Remaining items to sync</param>
|
||||||
|
/// <param name="status">Optional status message</param>
|
||||||
|
protected void UpdateSyncProgress(int total, int remaining, string status = "")
|
||||||
|
{
|
||||||
|
TotalItemsToSync = total;
|
||||||
|
RemainingItemsToSync = remaining;
|
||||||
|
SynchronizationStatus = status;
|
||||||
|
OnPropertyChanged(nameof(SynchronizationProgress));
|
||||||
|
|
||||||
|
// Send progress update message
|
||||||
|
Messenger.Send(new AccountSynchronizerStateChanged(
|
||||||
|
Account.Id,
|
||||||
|
State,
|
||||||
|
TotalItemsToSync,
|
||||||
|
RemainingItemsToSync,
|
||||||
|
SynchronizationStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
public GmailSynchronizer(MailAccount account,
|
public GmailSynchronizer(MailAccount account,
|
||||||
IGmailAuthenticator authenticator,
|
IGmailAuthenticator authenticator,
|
||||||
IGmailChangeProcessor gmailChangeProcessor,
|
IGmailChangeProcessor gmailChangeProcessor,
|
||||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory) : base(account)
|
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
|
||||||
{
|
{
|
||||||
var messageHandler = new GmailClientMessageHandler(authenticator, account);
|
var messageHandler = new GmailClientMessageHandler(authenticator, account);
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
public ImapSynchronizer(MailAccount account,
|
public ImapSynchronizer(MailAccount account,
|
||||||
IImapChangeProcessor imapChangeProcessor,
|
IImapChangeProcessor imapChangeProcessor,
|
||||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||||
IApplicationConfiguration applicationConfiguration) : base(account)
|
IApplicationConfiguration applicationConfiguration) : base(account, WeakReferenceMessenger.Default)
|
||||||
{
|
{
|
||||||
// Create client pool with account protocol log.
|
// Create client pool with account protocol log.
|
||||||
_imapChangeProcessor = imapChangeProcessor;
|
_imapChangeProcessor = imapChangeProcessor;
|
||||||
@@ -294,7 +294,8 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
||||||
_logger.Information("Options: {Options}", options);
|
_logger.Information("Options: {Options}", options);
|
||||||
|
|
||||||
PublishSynchronizationProgress(1);
|
// Set indeterminate progress initially
|
||||||
|
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||||
|
|
||||||
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
||||||
|
|
||||||
@@ -307,12 +308,14 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
{
|
{
|
||||||
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||||
|
|
||||||
for (int i = 0; i < synchronizationFolders.Count; i++)
|
var totalFolders = synchronizationFolders.Count;
|
||||||
|
|
||||||
|
for (int i = 0; i < totalFolders; i++)
|
||||||
{
|
{
|
||||||
var folder = synchronizationFolders[i];
|
var folder = synchronizationFolders[i];
|
||||||
var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100);
|
|
||||||
|
// Update progress based on folder completion
|
||||||
PublishSynchronizationProgress(progress);
|
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||||
|
|
||||||
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
|
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -325,7 +328,8 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PublishSynchronizationProgress(100);
|
// Reset progress
|
||||||
|
ResetSyncProgress();
|
||||||
|
|
||||||
// Get all unread new downloaded items and return in the result.
|
// Get all unread new downloaded items and return in the result.
|
||||||
// This is primarily used in notifications.
|
// This is primarily used in notifications.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using System.Text.Json.Serialization;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Microsoft.Graph;
|
using Microsoft.Graph;
|
||||||
using Microsoft.Graph.Me.MailFolders.Item.Messages.Delta;
|
using Microsoft.Graph.Me.MailFolders.Item.Messages.Delta;
|
||||||
using Microsoft.Graph.Models;
|
using Microsoft.Graph.Models;
|
||||||
@@ -50,6 +51,25 @@ namespace Wino.Core.Synchronizers.Mail;
|
|||||||
[JsonSerializable(typeof(OutlookFileAttachment))]
|
[JsonSerializable(typeof(OutlookFileAttachment))]
|
||||||
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
|
||||||
|
///
|
||||||
|
/// SYNCHRONIZATION STRATEGY:
|
||||||
|
/// - Uses per-folder queue system (unlike Gmail's per-account queue)
|
||||||
|
/// - During sync (initial/delta), only message metadata is downloaded (no MIME content)
|
||||||
|
/// - Messages are queued by folder using MailItemQueue with RemoteFolderId
|
||||||
|
/// - MailCopy objects are created from Graph API metadata fields only
|
||||||
|
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
||||||
|
/// - This dramatically reduces bandwidth usage and sync time
|
||||||
|
///
|
||||||
|
/// Key implementation details:
|
||||||
|
/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API
|
||||||
|
/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue
|
||||||
|
/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches
|
||||||
|
/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only)
|
||||||
|
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
||||||
|
/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME)
|
||||||
|
/// </summary>
|
||||||
public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message, Event>
|
public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message, Event>
|
||||||
{
|
{
|
||||||
public override uint BatchModificationSize => 20;
|
public override uint BatchModificationSize => 20;
|
||||||
@@ -93,7 +113,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
public OutlookSynchronizer(MailAccount account,
|
public OutlookSynchronizer(MailAccount account,
|
||||||
IAuthenticator authenticator,
|
IAuthenticator authenticator,
|
||||||
IOutlookChangeProcessor outlookChangeProcessor,
|
IOutlookChangeProcessor outlookChangeProcessor,
|
||||||
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account)
|
IOutlookSynchronizerErrorHandlerFactory errorHandlingFactory) : base(account, WeakReferenceMessenger.Default)
|
||||||
{
|
{
|
||||||
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
|
var tokenProvider = new MicrosoftTokenProvider(Account, authenticator);
|
||||||
|
|
||||||
@@ -157,7 +177,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
PublishSynchronizationProgress(1);
|
// Set indeterminate progress initially
|
||||||
|
UpdateSyncProgress(0, 0, "Synchronizing folders...");
|
||||||
|
|
||||||
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -167,12 +188,14 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
_logger.Information(string.Format("{1} Folders: {0}", string.Join(",", synchronizationFolders.Select(a => a.FolderName)), synchronizationFolders.Count));
|
_logger.Information(string.Format("{1} Folders: {0}", string.Join(",", synchronizationFolders.Select(a => a.FolderName)), synchronizationFolders.Count));
|
||||||
|
|
||||||
for (int i = 0; i < synchronizationFolders.Count; i++)
|
var totalFolders = synchronizationFolders.Count;
|
||||||
|
|
||||||
|
for (int i = 0; i < totalFolders; i++)
|
||||||
{
|
{
|
||||||
var folder = synchronizationFolders[i];
|
var folder = synchronizationFolders[i];
|
||||||
var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100);
|
|
||||||
|
// Update progress based on folder completion
|
||||||
PublishSynchronizationProgress(progress);
|
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||||
|
|
||||||
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
||||||
@@ -188,7 +211,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
PublishSynchronizationProgress(100);
|
// Reset progress at the end
|
||||||
|
ResetSyncProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unred new downloaded items and return in the result.
|
// Get all unred new downloaded items and return in the result.
|
||||||
@@ -235,7 +259,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
_logger.Debug("Synchronizing {FolderName} with direct download approach", folder.FolderName);
|
_logger.Debug("Synchronizing {FolderName} with direct download approach", folder.FolderName);
|
||||||
|
|
||||||
// Check if initial sync is completed for this folder
|
// Check if initial sync is completed for this folder
|
||||||
if (!folder.IsInitialSyncCompleted)
|
if (folder.FolderStatus != InitialSynchronizationStatus.Completed)
|
||||||
{
|
{
|
||||||
_logger.Debug("Initial sync not completed for folder {FolderName}. Starting mail synchronization.", folder.FolderName);
|
_logger.Debug("Initial sync not completed for folder {FolderName}. Starting mail synchronization.", folder.FolderName);
|
||||||
|
|
||||||
@@ -244,7 +268,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
// Mark initial sync as completed
|
// Mark initial sync as completed
|
||||||
await _outlookChangeProcessor.UpdateFolderInitialSyncCompletedAsync(folder.Id, true).ConfigureAwait(false);
|
await _outlookChangeProcessor.UpdateFolderInitialSyncCompletedAsync(folder.Id, true).ConfigureAwait(false);
|
||||||
folder.IsInitialSyncCompleted = true;
|
folder.FolderStatus = InitialSynchronizationStatus.Completed;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -265,88 +289,20 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads mails for initial synchronization using Delta API and direct download with concurrency control.
|
/// Downloads mails for initial synchronization using Delta API and queue-based system.
|
||||||
|
/// First, queues all mail IDs, then downloads metadata in batches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName);
|
_logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName);
|
||||||
|
|
||||||
var mailIds = new List<string>();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
|
// Step 1: Queue all mail IDs using Delta API
|
||||||
DeltaGetResponse messageCollectionPage = null;
|
await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(folder.DeltaToken))
|
// Step 2: Process queued mail IDs in batches
|
||||||
{
|
await ProcessMailQueueForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
||||||
messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
|
|
||||||
{
|
|
||||||
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
|
||||||
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
|
||||||
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
|
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
|
|
||||||
{
|
|
||||||
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
|
||||||
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
|
||||||
});
|
|
||||||
|
|
||||||
requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
|
|
||||||
requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken);
|
|
||||||
|
|
||||||
messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use PageIterator<DeltaGetResponse> for iterating through the messages
|
|
||||||
var messageIterator = PageIterator<Message, DeltaGetResponse>.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
|
|
||||||
{
|
|
||||||
if (!IsResourceDeleted(message.AdditionalData))
|
|
||||||
{
|
|
||||||
mailIds.Add(message.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterator must continue all the time to recieve delta token at the end.
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Extract delta token from the iterator's delta link
|
|
||||||
string deltaToken = null;
|
|
||||||
if (!string.IsNullOrEmpty(messageIterator.Deltalink))
|
|
||||||
{
|
|
||||||
deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download mails concurrently with semaphore control
|
|
||||||
if (mailIds.Any())
|
|
||||||
{
|
|
||||||
var mimeDownloadCount = Math.Min(mailIds.Count, InitialSyncMimeDownloadCount);
|
|
||||||
_logger.Information("Starting concurrent download of {Count} mails for folder {FolderName} (first {MimeCount} with MIME messages)",
|
|
||||||
mailIds.Count, folder.FolderName, mimeDownloadCount);
|
|
||||||
await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Information("No mail ids found to download for folder {FolderName}", folder.FolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the delta token for future incremental syncs - always store when available
|
|
||||||
if (!string.IsNullOrEmpty(deltaToken))
|
|
||||||
{
|
|
||||||
await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
|
|
||||||
await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
|
|
||||||
folder.DeltaToken = deltaToken;
|
|
||||||
_logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (ApiException apiException)
|
catch (ApiException apiException)
|
||||||
{
|
{
|
||||||
@@ -368,7 +324,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
if (apiException.ResponseStatusCode == 410)
|
if (apiException.ResponseStatusCode == 410)
|
||||||
{
|
{
|
||||||
folder.DeltaToken = string.Empty;
|
folder.DeltaToken = string.Empty;
|
||||||
folder.IsInitialSyncCompleted = false;
|
folder.FolderStatus = InitialSynchronizationStatus.None;
|
||||||
_logger.Information("API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
|
_logger.Information("API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,162 +345,345 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10.
|
/// Queues all mail IDs for a folder using Delta API.
|
||||||
/// This overload is used for initial sync where MIME messages are downloaded for the first 50 messages.
|
/// Only retrieves message IDs to minimize data transfer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task DownloadMailsConcurrentlyAsync(List<string> mailIds, MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
private async Task QueueMailIdsForFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, true, cancellationToken).ConfigureAwait(false);
|
_logger.Debug("Queuing mail IDs for folder {FolderName}", folder.FolderName);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
var mailIds = new List<string>();
|
||||||
/// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10.
|
|
||||||
/// </summary>
|
// Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
|
||||||
private async Task DownloadMailsConcurrentlyAsync(List<string> mailIds, MailItemFolder folder, List<string> downloadedMessageIds, bool isInitialSync, CancellationToken cancellationToken)
|
DeltaGetResponse messageCollectionPage = null;
|
||||||
{
|
|
||||||
var downloadTasks = mailIds.Select(async (mailId, index) =>
|
if (string.IsNullOrEmpty(folder.DeltaToken))
|
||||||
{
|
{
|
||||||
await _concurrentDownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
|
||||||
try
|
|
||||||
{
|
{
|
||||||
// Download MIME for the first 50 messages during initial sync only
|
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
||||||
bool shouldDownloadMime = isInitialSync && index < InitialSyncMimeDownloadCount;
|
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
||||||
var downloaded = await DownloadSingleMailAsync(mailId, folder, shouldDownloadMime, cancellationToken).ConfigureAwait(false);
|
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
|
||||||
if (downloaded != null)
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
}
|
||||||
lock (downloadedMessageIds)
|
else
|
||||||
{
|
{
|
||||||
downloadedMessageIds.Add(downloaded);
|
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
{
|
||||||
_concurrentDownloadSemaphore.Release();
|
config.QueryParameters.Select = ["Id"]; // Only get the message Ids
|
||||||
|
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
||||||
|
});
|
||||||
|
|
||||||
|
requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
|
||||||
|
requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken);
|
||||||
|
|
||||||
|
messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use PageIterator to iterate through all messages and collect IDs
|
||||||
|
var messageIterator = PageIterator<Message, DeltaGetResponse>.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
|
||||||
|
{
|
||||||
|
if (!IsResourceDeleted(message.AdditionalData))
|
||||||
|
{
|
||||||
|
mailIds.Add(message.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iterator must continue all the time to receive delta token at the end.
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await Task.WhenAll(downloadTasks).ConfigureAwait(false);
|
await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Extract delta token from the iterator's delta link
|
||||||
|
string deltaToken = null;
|
||||||
|
if (!string.IsNullOrEmpty(messageIterator.Deltalink))
|
||||||
|
{
|
||||||
|
deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue all mail IDs for processing
|
||||||
|
if (mailIds.Any())
|
||||||
|
{
|
||||||
|
var queueEntries = mailIds.Select(id => new MailItemQueue
|
||||||
|
{
|
||||||
|
Id = Guid.CreateVersion7(),
|
||||||
|
AccountId = Account.Id,
|
||||||
|
RemoteServerId = id,
|
||||||
|
RemoteFolderId = folder.RemoteFolderId,
|
||||||
|
IsProcessed = false,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await _outlookChangeProcessor.AddMailItemQueueItemsAsync(queueEntries).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.Information("Queued {Count} mail IDs for folder {FolderName}", mailIds.Count, folder.FolderName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Information("No mail ids found to queue for folder {FolderName}", folder.FolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the delta token for future incremental syncs - always store when available
|
||||||
|
if (!string.IsNullOrEmpty(deltaToken))
|
||||||
|
{
|
||||||
|
await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
|
||||||
|
await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
|
||||||
|
folder.DeltaToken = deltaToken;
|
||||||
|
_logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads a single mail by ID and creates it in the database.
|
/// Processes queued mail IDs in batches, downloading metadata only (no MIME).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> DownloadSingleMailAsync(string mailId, MailItemFolder folder, bool downloadMime, CancellationToken cancellationToken)
|
private async Task ProcessMailQueueForFolderAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
var totalInQueue = await _outlookChangeProcessor.GetMailItemQueueCountByFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (totalInQueue == 0)
|
||||||
{
|
{
|
||||||
// Check if mail already exists in database before downloading
|
_logger.Information("No mails in queue for folder {FolderName}", folder.FolderName);
|
||||||
// to avoid unnecessary API calls and reprocessing existing mails
|
return;
|
||||||
bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(mailId, folder.Id).ConfigureAwait(false);
|
}
|
||||||
if (mailExists)
|
|
||||||
|
_logger.Information("Processing {Count} queued mails for folder {FolderName}", totalInQueue, folder.FolderName);
|
||||||
|
|
||||||
|
var totalFailed = 0;
|
||||||
|
var totalProcessed = 0;
|
||||||
|
|
||||||
|
// Set initial progress for queue processing
|
||||||
|
UpdateSyncProgress(totalInQueue, totalInQueue, $"Downloading {folder.FolderName}...");
|
||||||
|
|
||||||
|
// Continue until all emails in queue are processed
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Get next batch of unprocessed emails from queue
|
||||||
|
var mailItemQueue = await _outlookChangeProcessor.GetMailItemQueueByFolderAsync(Account.Id, folder.RemoteFolderId, 100).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (mailItemQueue.Count == 0)
|
||||||
|
break; // No more emails to process
|
||||||
|
|
||||||
|
// Remove the items that should be deleted from queue first
|
||||||
|
mailItemQueue.RemoveAll(a => a.ShouldDelete());
|
||||||
|
|
||||||
|
var mailChunks = mailItemQueue.Chunk(20); // Process 20 at a time
|
||||||
|
|
||||||
|
foreach (var chunk in mailChunks)
|
||||||
{
|
{
|
||||||
_logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", mailId, folder.FolderName);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return null; // Not a new download
|
|
||||||
|
// Collect message IDs from the chunk
|
||||||
|
var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Download all messages in this chunk concurrently
|
||||||
|
var chunkDownloadedIds = await DownloadMessageMetadataBatchAsync(messageIdsToDownload, folder, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
downloadedMessageIds.AddRange(chunkDownloadedIds);
|
||||||
|
|
||||||
|
// Mark all items in chunk as processed
|
||||||
|
foreach (var queueItem in chunk)
|
||||||
|
{
|
||||||
|
queueItem.IsProcessed = true;
|
||||||
|
queueItem.ProcessedAt = DateTime.UtcNow;
|
||||||
|
totalProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress with remaining items
|
||||||
|
var remainingItems = totalInQueue - totalProcessed;
|
||||||
|
UpdateSyncProgress(totalInQueue, remainingItems, $"Downloading {folder.FolderName} ({totalProcessed}/{totalInQueue})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "Failed to download chunk of messages for folder {FolderName}", folder.FolderName);
|
||||||
|
|
||||||
|
// Mark all items in chunk as failed
|
||||||
|
foreach (var queueItem in chunk)
|
||||||
|
{
|
||||||
|
queueItem.IsProcessed = false;
|
||||||
|
queueItem.ProcessedAt = null;
|
||||||
|
queueItem.FailedCount++;
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _outlookChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// If too many failures, pause to avoid hitting rate limits
|
||||||
|
if (totalFailed > 50)
|
||||||
|
{
|
||||||
|
_logger.Warning("Too many failures ({Count}), pausing for 10 seconds", totalFailed);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||||
|
totalFailed = 0; // Reset counter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the message with minimal properties
|
_logger.Debug("Processed batch: {Processed}/{Total} for folder {FolderName}", totalProcessed, totalInQueue, folder.FolderName);
|
||||||
var message = await GetMessageByIdAsync(mailId, cancellationToken).ConfigureAwait(false);
|
}
|
||||||
|
|
||||||
if (message != null)
|
_logger.Information("Completed processing queue for folder {FolderName}. Processed: {Count}", folder.FolderName, totalProcessed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content).
|
||||||
|
/// Processes up to 20 messages per batch request as per MaximumAllowedBatchRequestSize.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<string>> DownloadMessageMetadataBatchAsync(List<string> messageIds, MailItemFolder folder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (messageIds == null || messageIds.Count == 0)
|
||||||
|
return new List<string>();
|
||||||
|
|
||||||
|
var downloadedIds = new List<string>();
|
||||||
|
|
||||||
|
// Filter out messages that already exist in the database
|
||||||
|
var messagesToDownload = new List<string>();
|
||||||
|
foreach (var messageId in messageIds)
|
||||||
|
{
|
||||||
|
bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(messageId, folder.Id).ConfigureAwait(false);
|
||||||
|
if (!mailExists)
|
||||||
{
|
{
|
||||||
if (downloadMime)
|
messagesToDownload.Add(messageId);
|
||||||
{
|
}
|
||||||
// Download the full message packages with MIME for the first 50 messages
|
else
|
||||||
var mailPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
_logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", messageId, folder.FolderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mailPackages != null)
|
if (messagesToDownload.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.Debug("All messages already exist in folder {FolderName}", folder.FolderName);
|
||||||
|
return downloadedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in batches of MaximumAllowedBatchRequestSize (20)
|
||||||
|
var batches = messagesToDownload.Batch((int)MaximumAllowedBatchRequestSize);
|
||||||
|
|
||||||
|
foreach (var batch in batches)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var batchContent = new BatchRequestContentCollection(_graphClient);
|
||||||
|
var requestIdToMessageIdMap = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
// Add all message requests to the batch
|
||||||
|
foreach (var messageId in batch)
|
||||||
|
{
|
||||||
|
var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) =>
|
||||||
{
|
{
|
||||||
foreach (var package in mailPackages)
|
config.QueryParameters.Select = outlookMessageSelectParameters;
|
||||||
|
});
|
||||||
|
|
||||||
|
var batchRequestId = await batchContent.AddBatchRequestStepAsync(requestInfo).ConfigureAwait(false);
|
||||||
|
requestIdToMessageIdMap[batchRequestId] = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the batch request
|
||||||
|
var batchResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Process all responses
|
||||||
|
foreach (var batchRequestId in requestIdToMessageIdMap.Keys)
|
||||||
|
{
|
||||||
|
var messageId = requestIdToMessageIdMap[batchRequestId];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Deserialize the Message directly from batch response
|
||||||
|
var message = await batchResponse.GetResponseByIdAsync<Message>(batchRequestId).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (message != null)
|
||||||
{
|
{
|
||||||
if (package?.Copy != null)
|
// Create MailCopy from metadata only
|
||||||
|
var mailCopy = CreateMailCopyFromMessage(message, folder);
|
||||||
|
|
||||||
|
if (mailCopy != null)
|
||||||
{
|
{
|
||||||
|
// Create package without MIME
|
||||||
|
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
|
||||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
|
||||||
if (isInserted)
|
if (isInserted)
|
||||||
{
|
{
|
||||||
_logger.Debug("Downloaded MIME message {MailId} for folder {FolderName}", mailId, folder.FolderName);
|
downloadedIds.Add(mailCopy.Id);
|
||||||
return package.Copy.Id; // Successfully created with MIME
|
_logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.Warning("Failed to insert mail with MIME {MailId} for folder {FolderName}", mailId, folder.FolderName);
|
_logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", messageId, folder.FolderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Debug("Could not create MIME mail packages for {MailId} in folder {FolderName}", mailId, folder.FolderName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Create minimal MailCopy without downloading MIME
|
|
||||||
var mailCopy = await CreateMinimalMailCopyAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (mailCopy != null)
|
|
||||||
{
|
|
||||||
// Create a minimal package without MIME for direct sync
|
|
||||||
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
|
|
||||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (isInserted)
|
|
||||||
{
|
{
|
||||||
return mailCopy.Id; // Successfully created
|
_logger.Warning("Failed to deserialize message {MailId} for folder {FolderName}", messageId, folder.FolderName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ODataError odataError)
|
||||||
|
{
|
||||||
|
// Handle OData errors from the batch response
|
||||||
|
if (odataError.ResponseStatusCode == 404)
|
||||||
|
{
|
||||||
|
_logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", messageId, folder.FolderName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", mailId, folder.FolderName);
|
_logger.Error("OData error while downloading mail {MailId} for folder {FolderName}. Error: {Error}", messageId, folder.FolderName, odataError.Error?.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
catch (ServiceException serviceException)
|
||||||
{
|
{
|
||||||
_logger.Debug("Could not create MailCopy for {MailId} in folder {FolderName} (might be unsupported message type)", mailId, folder.FolderName);
|
// Try to handle the error using the error handling factory
|
||||||
|
var errorContext = new SynchronizerErrorContext
|
||||||
|
{
|
||||||
|
Account = Account,
|
||||||
|
ErrorCode = (int?)serviceException.ResponseStatusCode,
|
||||||
|
ErrorMessage = $"Service error during batch mail download: {serviceException.Message}",
|
||||||
|
Exception = serviceException
|
||||||
|
};
|
||||||
|
|
||||||
|
var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!handled)
|
||||||
|
{
|
||||||
|
_logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", messageId, folder.FolderName, serviceException.ResponseStatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "Error occurred while processing message {MailId} for folder {FolderName}", messageId, folder.FolderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Debug("Message {MailId} is null for folder {FolderName} (filtered out)", mailId, folder.FolderName);
|
_logger.Error(ex, "Error occurred during batch download for folder {FolderName}", folder.FolderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (ServiceException serviceException)
|
|
||||||
{
|
|
||||||
// Try to handle the error using the error handling factory first
|
|
||||||
var errorContext = new SynchronizerErrorContext
|
|
||||||
{
|
|
||||||
Account = Account,
|
|
||||||
ErrorCode = (int?)serviceException.ResponseStatusCode,
|
|
||||||
ErrorMessage = $"Service error during mail download: {serviceException.Message}",
|
|
||||||
Exception = serviceException
|
|
||||||
};
|
|
||||||
|
|
||||||
var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!handled)
|
|
||||||
{
|
|
||||||
// No handler could process this error, log appropriately
|
|
||||||
if (serviceException.ResponseStatusCode == 404)
|
|
||||||
{
|
|
||||||
_logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", mailId, folder.FolderName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Information("Service error handled successfully during mail download. Mail: {MailId}, Folder: {FolderName}, Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Error(ex, "Error occurred while downloading mail {MailId} for folder {FolderName}", mailId, folder.FolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return downloadedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a MailCopy from an Outlook Message with metadata only (centralized method).
|
||||||
|
/// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls.
|
||||||
|
/// </summary>
|
||||||
|
private MailCopy CreateMailCopyFromMessage(Message message, MailItemFolder assignedFolder)
|
||||||
|
{
|
||||||
|
if (message == null) return null;
|
||||||
|
|
||||||
|
var mailCopy = message.AsMailCopy();
|
||||||
|
mailCopy.FolderId = assignedFolder.Id;
|
||||||
|
mailCopy.UniqueId = Guid.NewGuid();
|
||||||
|
mailCopy.FileId = Guid.NewGuid();
|
||||||
|
|
||||||
|
return mailCopy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
||||||
@@ -552,23 +691,14 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
protected override async Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
|
protected override async Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// This method is now replaced by direct downloading logic
|
// Queue all mail IDs for the folder
|
||||||
// Instead of queuing mail IDs, we now directly download them with concurrency control
|
await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||||
var downloadedMessageIds = new List<string>();
|
|
||||||
await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
protected override Task<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (message == null) return Task.FromResult<MailCopy>(null);
|
// Use centralized method
|
||||||
|
return Task.FromResult(CreateMailCopyFromMessage(message, assignedFolder));
|
||||||
// Create MailCopy with minimal properties - no MIME download
|
|
||||||
var mailCopy = message.AsMailCopy();
|
|
||||||
mailCopy.FolderId = assignedFolder.Id;
|
|
||||||
mailCopy.UniqueId = Guid.NewGuid();
|
|
||||||
mailCopy.FileId = Guid.NewGuid();
|
|
||||||
|
|
||||||
return Task.FromResult(mailCopy);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Message> GetMessageByIdAsync(string messageId, CancellationToken cancellationToken = default)
|
private async Task<Message> GetMessageByIdAsync(string messageId, CancellationToken cancellationToken = default)
|
||||||
@@ -622,7 +752,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
private async Task ProcessDeltaChangesAndDownloadMailsAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken = default)
|
private async Task ProcessDeltaChangesAndDownloadMailsAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Process delta changes and directly download new mails
|
// Process delta changes and download new mails with metadata only (no MIME)
|
||||||
if (string.IsNullOrEmpty(folder.DeltaToken))
|
if (string.IsNullOrEmpty(folder.DeltaToken))
|
||||||
{
|
{
|
||||||
_logger.Debug("No delta token available for folder {FolderName}. Skipping delta sync.", folder.FolderName);
|
_logger.Debug("No delta token available for folder {FolderName}. Skipping delta sync.", folder.FolderName);
|
||||||
@@ -638,7 +768,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
// Always use Delta endpoint with proper configuration
|
// Always use Delta endpoint with proper configuration
|
||||||
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
|
var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
|
||||||
{
|
{
|
||||||
config.QueryParameters.Select = ["Id"]; // Only get IDs for direct download
|
config.QueryParameters.Select = ["Id"]; // Only get IDs
|
||||||
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -665,11 +795,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
|
await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Download new mails directly with concurrency control
|
// Download new mails with metadata only (no MIME)
|
||||||
if (newMailIds.Any())
|
if (newMailIds.Any())
|
||||||
{
|
{
|
||||||
_logger.Information("Starting direct download of {Count} new mails from delta sync for folder {FolderName}", newMailIds.Count, folder.FolderName);
|
_logger.Information("Downloading {Count} new mails from delta sync for folder {FolderName} (metadata only)", newMailIds.Count, folder.FolderName);
|
||||||
await DownloadMailsConcurrentlyAsync(newMailIds, folder, downloadedMessageIds, false, cancellationToken).ConfigureAwait(false);
|
var deltaDownloadedIds = await DownloadMessageMetadataBatchAsync(newMailIds, folder, cancellationToken).ConfigureAwait(false);
|
||||||
|
downloadedMessageIds.AddRange(deltaDownloadedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update delta token for next sync - always store when there are no nextPageToken remaining
|
// Update delta token for next sync - always store when there are no nextPageToken remaining
|
||||||
@@ -701,7 +832,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
if (apiException.ResponseStatusCode == 410)
|
if (apiException.ResponseStatusCode == 410)
|
||||||
{
|
{
|
||||||
folder.DeltaToken = string.Empty;
|
folder.DeltaToken = string.Empty;
|
||||||
folder.IsInitialSyncCompleted = false;
|
folder.FolderStatus = InitialSynchronizationStatus.None;
|
||||||
_logger.Information("API error handled successfully for folder {FolderName} during delta sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
|
_logger.Information("API error handled successfully for folder {FolderName} during delta sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1638,8 +1769,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// Download MIME message for specific scenarios (e.g., search results, draft handling)
|
||||||
|
// During normal sync, this method should not be called - use CreateMailCopyFromMessage instead
|
||||||
var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
|
var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
|
||||||
var mailCopy = message.AsMailCopy();
|
var mailCopy = CreateMailCopyFromMessage(message, assignedFolder);
|
||||||
|
|
||||||
if (message.IsDraft.GetValueOrDefault()
|
if (message.IsDraft.GetValueOrDefault()
|
||||||
&& mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader)
|
&& mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
|
|
||||||
protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>();
|
protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>();
|
||||||
|
|
||||||
protected WinoSynchronizer(MailAccount account) : base(account) { }
|
protected WinoSynchronizer(MailAccount account, IMessenger messenger) : base(account, messenger) { }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many items per single HTTP call can be modified.
|
/// How many items per single HTTP call can be modified.
|
||||||
@@ -249,7 +249,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
|
|
||||||
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
|
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
|
||||||
|
|
||||||
PublishSynchronizationProgress(1);
|
// Set indeterminate progress for initial state
|
||||||
|
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||||
|
|
||||||
State = AccountSynchronizerState.Synchronizing;
|
State = AccountSynchronizerState.Synchronizing;
|
||||||
|
|
||||||
@@ -330,8 +331,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
PendingSynchronizationRequest.Remove(pendingRequest.Key);
|
PendingSynchronizationRequest.Remove(pendingRequest.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset account progress to hide the progress.
|
// Reset synchronization progress
|
||||||
PublishSynchronizationProgress(0);
|
ResetSyncProgress();
|
||||||
|
|
||||||
State = AccountSynchronizerState.Idle;
|
State = AccountSynchronizerState.Idle;
|
||||||
synchronizationSemaphore.Release();
|
synchronizationSemaphore.Release();
|
||||||
@@ -357,13 +358,6 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
|||||||
private void PublishUnreadItemChanges()
|
private void PublishUnreadItemChanges()
|
||||||
=> WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
|
=> WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends a message to the shell to update the synchronization progress.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="progress">Percentage of the progress.</param>
|
|
||||||
public void PublishSynchronizationProgress(double progress)
|
|
||||||
=> WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to find out the best possible synchronization options after the batch request execution.
|
/// Attempts to find out the best possible synchronization options after the batch request execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
|
|||||||
IRecipient<MergedInboxRenamed>,
|
IRecipient<MergedInboxRenamed>,
|
||||||
IRecipient<LanguageChanged>,
|
IRecipient<LanguageChanged>,
|
||||||
IRecipient<AccountMenuItemsReordered>,
|
IRecipient<AccountMenuItemsReordered>,
|
||||||
IRecipient<AccountSynchronizationProgressUpdatedMessage>,
|
IRecipient<AccountSynchronizerStateChanged>,
|
||||||
IRecipient<NavigateAppPreferencesRequested>,
|
IRecipient<NavigateAppPreferencesRequested>,
|
||||||
IRecipient<AccountFolderConfigurationUpdated>,
|
IRecipient<AccountFolderConfigurationUpdated>,
|
||||||
IRecipient<AccountRemovedMessage>,
|
IRecipient<AccountRemovedMessage>,
|
||||||
@@ -992,13 +992,24 @@ public partial class AppShellViewModel : MailBaseViewModel,
|
|||||||
UpdateFolderCollection(mailItemFolder);
|
UpdateFolderCollection(mailItemFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void Receive(AccountSynchronizationProgressUpdatedMessage message)
|
public async void Receive(AccountSynchronizerStateChanged message)
|
||||||
{
|
{
|
||||||
var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId);
|
var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId);
|
||||||
|
|
||||||
if (accountMenuItem == null) return;
|
if (accountMenuItem == null) return;
|
||||||
|
|
||||||
await ExecuteUIThread(() => { accountMenuItem.SynchronizationProgress = message.Progress; });
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
accountMenuItem.TotalItemsToSync = message.TotalItemsToSync;
|
||||||
|
accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync;
|
||||||
|
accountMenuItem.SynchronizationStatus = message.SynchronizationStatus;
|
||||||
|
|
||||||
|
// If this account is part of a merged inbox, update the merged inbox progress as well
|
||||||
|
if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem)
|
||||||
|
{
|
||||||
|
mergedAccountMenuItem.RefreshSynchronizationProgress();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void Receive(NavigateAppPreferencesRequested message)
|
public async void Receive(NavigateAppPreferencesRequested message)
|
||||||
@@ -1020,7 +1031,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
|
|||||||
Messenger.Register<MergedInboxRenamed>(this);
|
Messenger.Register<MergedInboxRenamed>(this);
|
||||||
Messenger.Register<LanguageChanged>(this);
|
Messenger.Register<LanguageChanged>(this);
|
||||||
Messenger.Register<AccountMenuItemsReordered>(this);
|
Messenger.Register<AccountMenuItemsReordered>(this);
|
||||||
Messenger.Register<AccountSynchronizationProgressUpdatedMessage>(this);
|
Messenger.Register<AccountSynchronizerStateChanged>(this);
|
||||||
Messenger.Register<NavigateAppPreferencesRequested>(this);
|
Messenger.Register<NavigateAppPreferencesRequested>(this);
|
||||||
Messenger.Register<AccountFolderConfigurationUpdated>(this);
|
Messenger.Register<AccountFolderConfigurationUpdated>(this);
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1047,7 @@ public partial class AppShellViewModel : MailBaseViewModel,
|
|||||||
Messenger.Unregister<MergedInboxRenamed>(this);
|
Messenger.Unregister<MergedInboxRenamed>(this);
|
||||||
Messenger.Unregister<LanguageChanged>(this);
|
Messenger.Unregister<LanguageChanged>(this);
|
||||||
Messenger.Unregister<AccountMenuItemsReordered>(this);
|
Messenger.Unregister<AccountMenuItemsReordered>(this);
|
||||||
Messenger.Unregister<AccountSynchronizationProgressUpdatedMessage>(this);
|
Messenger.Unregister<AccountSynchronizerStateChanged>(this);
|
||||||
Messenger.Unregister<NavigateAppPreferencesRequested>(this);
|
Messenger.Unregister<NavigateAppPreferencesRequested>(this);
|
||||||
Messenger.Unregister<AccountFolderConfigurationUpdated>(this);
|
Messenger.Unregister<AccountFolderConfigurationUpdated>(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
// Cache ThreadIds to quickly find items that should be threaded together
|
// Cache ThreadIds to quickly find items that should be threaded together
|
||||||
private readonly Dictionary<string, List<IMailListItem>> _threadIdToItemsMap = new();
|
private readonly Dictionary<string, List<IMailListItem>> _threadIdToItemsMap = new();
|
||||||
|
|
||||||
|
// Cache item to group mapping for faster lookups
|
||||||
|
private readonly Dictionary<IMailListItem, ObservableGroup<object, IMailListItem>> _itemToGroupMap = new();
|
||||||
|
|
||||||
|
// Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups
|
||||||
|
private readonly Dictionary<Guid, MailItemViewModel> _uniqueIdToMailItemMap = new();
|
||||||
|
|
||||||
public event EventHandler<MailItemViewModel> MailItemRemoved;
|
public event EventHandler<MailItemViewModel> MailItemRemoved;
|
||||||
public event EventHandler ItemSelectionChanged;
|
public event EventHandler ItemSelectionChanged;
|
||||||
|
|
||||||
@@ -87,6 +93,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
_mailItemSource.Clear();
|
_mailItemSource.Clear();
|
||||||
MailCopyIdHashSet.Clear();
|
MailCopyIdHashSet.Clear();
|
||||||
_threadIdToItemsMap.Clear();
|
_threadIdToItemsMap.Clear();
|
||||||
|
_itemToGroupMap.Clear();
|
||||||
|
_uniqueIdToMailItemMap.Clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +113,17 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
if (isAdd)
|
if (isAdd)
|
||||||
{
|
{
|
||||||
MailCopyIdHashSet.Add(item);
|
MailCopyIdHashSet.Add(item);
|
||||||
|
|
||||||
|
// Update the uniqueId to MailItemViewModel cache
|
||||||
|
if (itemContainer is MailItemViewModel mailItemVM)
|
||||||
|
{
|
||||||
|
_uniqueIdToMailItemMap[item] = mailItemVM;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
MailCopyIdHashSet.Remove(item);
|
MailCopyIdHashSet.Remove(item);
|
||||||
|
_uniqueIdToMailItemMap.Remove(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +195,13 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
_mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer);
|
_mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer);
|
||||||
|
|
||||||
|
// Update item-to-group cache
|
||||||
|
var group = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
||||||
|
if (group != null)
|
||||||
|
{
|
||||||
|
_itemToGroupMap[mailItem] = group;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +225,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
group.Remove(mailItem);
|
group.Remove(mailItem);
|
||||||
|
|
||||||
|
// Remove from item-to-group cache
|
||||||
|
_itemToGroupMap.Remove(mailItem);
|
||||||
|
|
||||||
if (group.Count == 0)
|
if (group.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -323,10 +348,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
|
|
||||||
private ObservableGroup<object, IMailListItem> FindGroupContainingItem(IMailListItem item)
|
private ObservableGroup<object, IMailListItem> FindGroupContainingItem(IMailListItem item)
|
||||||
{
|
{
|
||||||
|
// Try cache first
|
||||||
|
if (_itemToGroupMap.TryGetValue(item, out var cachedGroup))
|
||||||
|
{
|
||||||
|
return cachedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to search if not in cache
|
||||||
foreach (var group in _mailItemSource)
|
foreach (var group in _mailItemSource)
|
||||||
{
|
{
|
||||||
if (group.Contains(item))
|
if (group.Contains(item))
|
||||||
{
|
{
|
||||||
|
_itemToGroupMap[item] = group;
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,9 +392,38 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
_threadIdToItemsMap.Clear();
|
_threadIdToItemsMap.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemsList = items.ToList();
|
var itemsList = items as List<MailItemViewModel> ?? items.ToList();
|
||||||
var itemsToAdd = new List<IMailListItem>();
|
if (itemsList.Count == 0) return;
|
||||||
var processedItems = new HashSet<MailItemViewModel>();
|
|
||||||
|
var itemsToAdd = new List<IMailListItem>(itemsList.Count);
|
||||||
|
var processedItems = new HashSet<MailItemViewModel>(itemsList.Count);
|
||||||
|
var itemsToUpdate = new List<(MailItemViewModel existing, MailCopy updated)>();
|
||||||
|
var threadingOperations = new List<(ObservableGroup<object, IMailListItem> group, IMailListItem item, MailCopy addedItem)>();
|
||||||
|
|
||||||
|
// Build a lookup for existing groups to avoid repeated searches
|
||||||
|
var groupLookup = new Dictionary<IMailListItem, ObservableGroup<object, IMailListItem>>(_mailItemSource.Count * 10);
|
||||||
|
foreach (var group in _mailItemSource)
|
||||||
|
{
|
||||||
|
foreach (var item in group)
|
||||||
|
{
|
||||||
|
groupLookup[item] = group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build thread lookup from the batch items
|
||||||
|
var batchThreadLookup = new Dictionary<string, List<MailItemViewModel>>();
|
||||||
|
foreach (var item in itemsList)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(item.MailCopy.ThreadId))
|
||||||
|
{
|
||||||
|
if (!batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var list))
|
||||||
|
{
|
||||||
|
list = new List<MailItemViewModel>();
|
||||||
|
batchThreadLookup[item.MailCopy.ThreadId] = list;
|
||||||
|
}
|
||||||
|
list.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process items and handle threading
|
// Process items and handle threading
|
||||||
foreach (var item in itemsList)
|
foreach (var item in itemsList)
|
||||||
@@ -375,7 +437,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
var existingItemContainer = GetMailItemContainer(item.MailCopy.UniqueId);
|
var existingItemContainer = GetMailItemContainer(item.MailCopy.UniqueId);
|
||||||
if (existingItemContainer?.ItemViewModel != null)
|
if (existingItemContainer?.ItemViewModel != null)
|
||||||
{
|
{
|
||||||
await UpdateExistingItemAsync(existingItemContainer.ItemViewModel, item.MailCopy);
|
itemsToUpdate.Add((existingItemContainer.ItemViewModel, item.MailCopy));
|
||||||
processedItems.Add(item);
|
processedItems.Add(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -390,34 +452,25 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
if (existingThreadableItem != null)
|
if (existingThreadableItem != null)
|
||||||
{
|
{
|
||||||
// Thread with existing item
|
// Thread with existing item
|
||||||
var targetGroup = FindGroupContainingItem(existingThreadableItem);
|
if (groupLookup.TryGetValue(existingThreadableItem, out var targetGroup))
|
||||||
if (targetGroup != null)
|
|
||||||
{
|
{
|
||||||
await HandleThreadingAsync(targetGroup, existingThreadableItem, item.MailCopy);
|
threadingOperations.Add((targetGroup, existingThreadableItem, item.MailCopy));
|
||||||
processedItems.Add(item);
|
processedItems.Add(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for other items in the current batch with same ThreadId
|
// Look for other items in the current batch with same ThreadId
|
||||||
var threadableItems = itemsList
|
if (batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var threadableItems) && threadableItems.Count > 1)
|
||||||
.Where(i => !processedItems.Contains(i) &&
|
|
||||||
!string.IsNullOrEmpty(i.MailCopy.ThreadId) &&
|
|
||||||
i.MailCopy.ThreadId == item.MailCopy.ThreadId)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (threadableItems.Count > 1)
|
|
||||||
{
|
{
|
||||||
// Create a new thread with all matching items
|
// Create a new thread with all matching items - defer UI operations
|
||||||
var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId);
|
var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId);
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
// Add emails without UI thread for now
|
||||||
|
foreach (var threadItem in threadableItems)
|
||||||
{
|
{
|
||||||
foreach (var threadItem in threadableItems)
|
threadViewModel.AddEmail(threadItem);
|
||||||
{
|
}
|
||||||
threadViewModel.AddEmail(threadItem);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
itemsToAdd.Add(threadViewModel);
|
itemsToAdd.Add(threadViewModel);
|
||||||
|
|
||||||
@@ -435,40 +488,103 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
processedItems.Add(item);
|
processedItems.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group items by their grouping key and add them
|
// Execute all threading operations in a single UI thread call
|
||||||
var groupedItems = itemsToAdd
|
if (threadingOperations.Count > 0)
|
||||||
.GroupBy(GetGroupingKey)
|
|
||||||
.Select(g => new ObservableGroup<object, IMailListItem>(g.Key, g));
|
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
|
||||||
{
|
{
|
||||||
foreach (var group in groupedItems)
|
foreach (var (group, existingItem, addedItem) in threadingOperations)
|
||||||
{
|
{
|
||||||
foreach (var item in group)
|
await HandleThreadingAsync(group, existingItem, addedItem);
|
||||||
{
|
}
|
||||||
UpdateUniqueIdHashes(item, true);
|
}
|
||||||
UpdateThreadIdCache(item, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key);
|
// Execute all updates in a single UI thread call
|
||||||
|
if (itemsToUpdate.Count > 0)
|
||||||
if (existingGroup == null)
|
{
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
foreach (var (existing, updated) in itemsToUpdate)
|
||||||
{
|
{
|
||||||
_mailItemSource.AddGroup(group.Key, group);
|
UpdateUniqueIdHashes(existing, false);
|
||||||
|
UpdateUniqueIdHashes(new MailItemViewModel(updated), true);
|
||||||
|
existing.NotifyPropertyChanges();
|
||||||
}
|
}
|
||||||
else
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by their grouping key and add them in a single UI thread call
|
||||||
|
if (itemsToAdd.Count > 0)
|
||||||
|
{
|
||||||
|
var groupedItems = itemsToAdd
|
||||||
|
.GroupBy(GetGroupingKey)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
foreach (var kvp in groupedItems)
|
||||||
{
|
{
|
||||||
foreach (var item in group)
|
var groupKey = kvp.Key;
|
||||||
|
var groupItems = kvp.Value;
|
||||||
|
|
||||||
|
// Update caches first
|
||||||
|
foreach (var item in groupItems)
|
||||||
{
|
{
|
||||||
existingGroup.Add(item);
|
UpdateUniqueIdHashes(item, true);
|
||||||
|
UpdateThreadIdCache(item, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
||||||
|
|
||||||
|
if (existingGroup == null)
|
||||||
|
{
|
||||||
|
var newGroup = new ObservableGroup<object, IMailListItem>(groupKey, groupItems);
|
||||||
|
_mailItemSource.AddGroup(groupKey, newGroup);
|
||||||
|
|
||||||
|
// Update item-to-group cache
|
||||||
|
foreach (var item in groupItems)
|
||||||
|
{
|
||||||
|
_itemToGroupMap[item] = newGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var item in groupItems)
|
||||||
|
{
|
||||||
|
existingGroup.Add(item);
|
||||||
|
_itemToGroupMap[item] = existingGroup;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
|
public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
|
||||||
{
|
{
|
||||||
|
// Try cache first for fast lookup
|
||||||
|
if (_uniqueIdToMailItemMap.TryGetValue(uniqueMailId, out var cachedMailItem))
|
||||||
|
{
|
||||||
|
// Check if it's in a thread
|
||||||
|
if (_itemToGroupMap.TryGetValue(cachedMailItem, out var cachedGroup))
|
||||||
|
{
|
||||||
|
return new MailItemContainer(cachedMailItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all threads for this mail item
|
||||||
|
foreach (var group in _mailItemSource)
|
||||||
|
{
|
||||||
|
foreach (var item in group)
|
||||||
|
{
|
||||||
|
if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
|
||||||
|
{
|
||||||
|
return new MailItemContainer(cachedMailItem, threadMailItemViewModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MailItemContainer(cachedMailItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to full search if not in cache
|
||||||
var groupCount = _mailItemSource.Count;
|
var groupCount = _mailItemSource.Count;
|
||||||
|
|
||||||
for (int i = 0; i < groupCount; i++)
|
for (int i = 0; i < groupCount; i++)
|
||||||
@@ -480,10 +596,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
var item = group[k];
|
var item = group[k];
|
||||||
|
|
||||||
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.MailCopy.UniqueId == uniqueMailId)
|
if (item is MailItemViewModel singleMailItemViewModel && singleMailItemViewModel.MailCopy.UniqueId == uniqueMailId)
|
||||||
|
{
|
||||||
|
_uniqueIdToMailItemMap[uniqueMailId] = singleMailItemViewModel;
|
||||||
return new MailItemContainer(singleMailItemViewModel);
|
return new MailItemContainer(singleMailItemViewModel);
|
||||||
|
}
|
||||||
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId))
|
||||||
{
|
{
|
||||||
var singleItemViewModel = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueMailId);
|
var singleItemViewModel = threadMailItemViewModel.ThreadEmails.FirstOrDefault(e => e.MailCopy.UniqueId == uniqueMailId);
|
||||||
|
|
||||||
|
if (singleItemViewModel != null)
|
||||||
|
{
|
||||||
|
_uniqueIdToMailItemMap[uniqueMailId] = singleItemViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
return new MailItemContainer(singleItemViewModel, threadMailItemViewModel);
|
return new MailItemContainer(singleItemViewModel, threadMailItemViewModel);
|
||||||
}
|
}
|
||||||
@@ -851,7 +975,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
public Task UnselectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false, true);
|
public Task UnselectAllAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false, true);
|
||||||
public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true);
|
public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true);
|
||||||
|
|
||||||
private async Task ExecuteUIThread(Action action) => await CoreDispatcher?.ExecuteOnUIThread(action);
|
private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action);
|
||||||
|
|
||||||
public void Receive(SelectedItemsChangedMessage message) => _ = NotifySelectionChangesAsync();
|
public void Receive(SelectedItemsChangedMessage message) => _ = NotifySelectionChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -110,25 +110,32 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
[NotifyPropertyChangedFor(nameof(IsEmpty))]
|
[NotifyPropertyChangedFor(nameof(IsEmpty))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFolderEmpty))]
|
[NotifyPropertyChangedFor(nameof(IsFolderEmpty))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressRing))]
|
[NotifyPropertyChangedFor(nameof(IsProgressRing))]
|
||||||
private bool isInitializingFolder;
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))]
|
||||||
|
public partial bool IsInitializingFolder { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private InfoBarMessageType barSeverity;
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))]
|
||||||
|
public partial bool FinishedLoading { get; set; } = false;
|
||||||
|
|
||||||
|
public bool CanLoadMoreItems => !IsInitializingFolder && !IsOnlineSearchEnabled && !FinishedLoading;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string barMessage;
|
public partial InfoBarMessageType BarSeverity { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string BarMessage { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial double MailListLength { get; set; } = 420;
|
public partial double MailListLength { get; set; } = 420;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double maxMailListLength = 1200;
|
public partial double MaxMailListLength { get; set; } = 1200;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string barTitle;
|
public partial string BarTitle { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isBarOpen;
|
public partial bool IsBarOpen { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current folder that is being represented from the menu.
|
/// Current folder that is being represented from the menu.
|
||||||
@@ -136,11 +143,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
|
[NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))]
|
||||||
private IBaseFolderMenuItem activeFolder;
|
public partial IBaseFolderMenuItem ActiveFolder { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
[NotifyPropertyChangedFor(nameof(CanSynchronize))]
|
||||||
private bool isAccountSynchronizerInSynchronization;
|
public partial bool IsAccountSynchronizerInSynchronization { get; set; }
|
||||||
|
|
||||||
public MailListPageViewModel(IMailDialogService dialogService,
|
public MailListPageViewModel(IMailDialogService dialogService,
|
||||||
INavigationService navigationService,
|
INavigationService navigationService,
|
||||||
@@ -303,6 +310,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
public partial bool IsOnlineSearchButtonVisible { get; set; }
|
public partial bool IsOnlineSearchButtonVisible { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))]
|
||||||
public partial bool IsOnlineSearchEnabled { get; set; }
|
public partial bool IsOnlineSearchEnabled { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -538,10 +546,10 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanLoadMoreItems))]
|
||||||
private async Task LoadMoreItemsAsync()
|
private async Task LoadMoreItemsAsync()
|
||||||
{
|
{
|
||||||
if (IsInitializingFolder || IsOnlineSearchEnabled) return;
|
if (IsInitializingFolder || IsOnlineSearchEnabled || FinishedLoading) return;
|
||||||
|
|
||||||
Debug.WriteLine("Loading more...");
|
Debug.WriteLine("Loading more...");
|
||||||
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
await ExecuteUIThread(() => { IsInitializingFolder = true; });
|
||||||
@@ -556,6 +564,13 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
|
var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
await ExecuteUIThread(() => { FinishedLoading = true; });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var viewModels = PrepareMailViewModels(items);
|
var viewModels = PrepareMailViewModels(items);
|
||||||
|
|
||||||
await MailCollection.AddRangeAsync(viewModels, false);
|
await MailCollection.AddRangeAsync(viewModels, false);
|
||||||
@@ -613,9 +628,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
await listManipulationSemepahore.WaitAsync();
|
await listManipulationSemepahore.WaitAsync();
|
||||||
|
|
||||||
await ExecuteUIThread(async () =>
|
// AddAsync already handles UI threading internally, no need to wrap it
|
||||||
|
await MailCollection.AddAsync(addedMail);
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
await MailCollection.AddAsync(addedMail);
|
|
||||||
NotifyItemFoundState();
|
NotifyItemFoundState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -671,11 +688,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the deleted item from the list.
|
// RemoveAsync already handles UI threading internally
|
||||||
await ExecuteUIThread(() =>
|
await MailCollection.RemoveAsync(removedMail);
|
||||||
{
|
|
||||||
_ = MailCollection.RemoveAsync(removedMail);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextItem != null)
|
if (nextItem != null)
|
||||||
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true));
|
WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true));
|
||||||
@@ -684,10 +698,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
// There are no next item to select, but we removed the last item which was selected.
|
// There are no next item to select, but we removed the last item which was selected.
|
||||||
// Clearing selected item will dispose rendering page.
|
// Clearing selected item will dispose rendering page.
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
// UnselectAllAsync already handles UI threading internally
|
||||||
{
|
await MailCollection.UnselectAllAsync();
|
||||||
_ = MailCollection.UnselectAllAsync();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ExecuteUIThread(() => { NotifyItemFoundState(); });
|
await ExecuteUIThread(() => { NotifyItemFoundState(); });
|
||||||
@@ -709,11 +721,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
// Otherwise the draft mail item will be duplicated on the next add execution.
|
// Otherwise the draft mail item will be duplicated on the next add execution.
|
||||||
await listManipulationSemepahore.WaitAsync();
|
await listManipulationSemepahore.WaitAsync();
|
||||||
|
|
||||||
await ExecuteUIThread(async () =>
|
// AddAsync already handles UI threading internally
|
||||||
{
|
await MailCollection.AddAsync(draftMail);
|
||||||
// Create the item. Draft folder navigation is already done at this point.
|
|
||||||
await MailCollection.AddAsync(draftMail);
|
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
// New draft is created by user. Select the item.
|
// New draft is created by user. Select the item.
|
||||||
Messenger.Send(new MailItemNavigationRequested(draftMail.UniqueId, ScrollToItem: true));
|
Messenger.Send(new MailItemNavigationRequested(draftMail.UniqueId, ScrollToItem: true));
|
||||||
|
|
||||||
@@ -726,9 +738,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<MailItemViewModel> PrepareMailViewModels(IEnumerable<MailCopy> mailItems)
|
private List<MailItemViewModel> PrepareMailViewModels(IEnumerable<MailCopy> mailItems)
|
||||||
{
|
{
|
||||||
return mailItems.Select(a => new MailItemViewModel(a));
|
return mailItems.Select(a => new MailItemViewModel(a)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1052,7 +1064,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
await ExecuteUIThread(() => { IsAccountSynchronizerInSynchronization = isAnyAccountSynchronizing; });
|
await ExecuteUIThread(() => { IsAccountSynchronizerInSynchronization = isAnyAccountSynchronizing; });
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(AccountCacheResetMessage message)
|
public async void Receive(AccountCacheResetMessage message)
|
||||||
{
|
{
|
||||||
if (message.Reason == AccountCacheResetReason.ExpiredCache &&
|
if (message.Reason == AccountCacheResetReason.ExpiredCache &&
|
||||||
ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == message.AccountId))
|
ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == message.AccountId))
|
||||||
@@ -1061,10 +1073,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
if (handlingFolder == null) return;
|
if (handlingFolder == null) return;
|
||||||
|
|
||||||
_ = ExecuteUIThread(async () =>
|
// ClearAsync already handles UI threading internally
|
||||||
{
|
await MailCollection.ClearAsync();
|
||||||
await MailCollection.ClearAsync();
|
|
||||||
|
|
||||||
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
_mailDialogService.InfoBarMessage(Translator.AccountCacheReset_Title, Translator.AccountCacheReset_Message, InfoBarMessageType.Warning);
|
_mailDialogService.InfoBarMessage(Translator.AccountCacheReset_Title, Translator.AccountCacheReset_Message, InfoBarMessageType.Warning);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
private const string PART_ScrollViewer = "ScrollViewer";
|
private const string PART_ScrollViewer = "ScrollViewer";
|
||||||
private ScrollViewer? internalScrollviewer;
|
private ScrollViewer? internalScrollviewer;
|
||||||
|
|
||||||
private double lastestRaisedOffset = 0;
|
|
||||||
private int lastItemSize = 0;
|
|
||||||
|
|
||||||
[GeneratedDependencyProperty]
|
[GeneratedDependencyProperty]
|
||||||
public partial bool IsThreadListView { get; set; }
|
public partial bool IsThreadListView { get; set; }
|
||||||
|
|
||||||
@@ -47,24 +44,55 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
double progress = internalScrollviewer.VerticalOffset / internalScrollviewer.ScrollableHeight;
|
double progress = internalScrollviewer.VerticalOffset / internalScrollviewer.ScrollableHeight;
|
||||||
|
|
||||||
// Trigger when scrolled past 90% of total height
|
// Trigger when scrolled past 90% of total height
|
||||||
if (progress >= 0.9) LoadMoreCommand?.Execute(null);
|
if (progress >= 0.9)
|
||||||
|
{
|
||||||
|
bool canLoadMore = LoadMoreCommand?.CanExecute(null) ?? false;
|
||||||
|
|
||||||
|
if (canLoadMore)
|
||||||
|
{
|
||||||
|
LoadMoreCommand?.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
|
protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
|
||||||
{
|
{
|
||||||
base.PrepareContainerForItemOverride(element, item);
|
base.PrepareContainerForItemOverride(element, item);
|
||||||
|
|
||||||
|
// Ensure the container's selection state matches the model's state
|
||||||
|
// This is crucial for virtualization scenarios where containers are recycled
|
||||||
|
|
||||||
|
if (item is MailItemViewModel mailItemViewModel
|
||||||
|
&& element is WinoMailItemViewModelListViewItem container
|
||||||
|
&& container.Item != mailItemViewModel)
|
||||||
|
{
|
||||||
|
container.Item = mailItemViewModel;
|
||||||
|
container.IsSelected = mailItemViewModel.IsSelected;
|
||||||
|
}
|
||||||
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel
|
||||||
|
&& element is WinoThreadMailItemViewModelListViewItem threadContainer
|
||||||
|
&& threadContainer.Item != threadMailItemViewModel)
|
||||||
|
{
|
||||||
|
threadContainer.Item = threadMailItemViewModel;
|
||||||
|
threadContainer.IsSelected = threadMailItemViewModel.IsSelected;
|
||||||
|
threadContainer.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ClearContainerForItemOverride(DependencyObject element, object item)
|
||||||
|
{
|
||||||
|
base.ClearContainerForItemOverride(element, item);
|
||||||
|
|
||||||
if (item is MailItemViewModel mailItemViewModel && element is WinoMailItemViewModelListViewItem container)
|
if (item is MailItemViewModel mailItemViewModel && element is WinoMailItemViewModelListViewItem container)
|
||||||
{
|
{
|
||||||
// Ensure the container's selection state matches the model's state
|
container.Item = null;
|
||||||
// This is crucial for virtualization scenarios where containers are recycled
|
container.IsSelected = false;
|
||||||
|
|
||||||
container.IsSelected = mailItemViewModel.IsSelected;
|
|
||||||
}
|
}
|
||||||
else if (item is ThreadMailItemViewModel threadMailItemViewModel && element is WinoThreadMailItemViewModelListViewItem threadContainer)
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel && element is WinoThreadMailItemViewModelListViewItem threadContainer)
|
||||||
{
|
{
|
||||||
threadContainer.IsSelected = threadMailItemViewModel.IsSelected;
|
threadContainer.Item = null;
|
||||||
threadContainer.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded;
|
threadContainer.IsSelected = false;
|
||||||
|
threadContainer.IsThreadExpanded = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,24 +121,6 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
public WinoThreadMailItemViewModelListViewItem? GetThreadMailItemContainer(ThreadMailItemViewModel threadMailItemViewModel)
|
public WinoThreadMailItemViewModelListViewItem? GetThreadMailItemContainer(ThreadMailItemViewModel threadMailItemViewModel)
|
||||||
=> ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
|
=> ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
|
||||||
|
|
||||||
public void ToggleItemContainer(IMailListItem mailListItem)
|
|
||||||
{
|
|
||||||
DispatcherQueue.TryEnqueue(() =>
|
|
||||||
{
|
|
||||||
if (mailListItem is MailItemViewModel mailItemViewModel)
|
|
||||||
{
|
|
||||||
var container = GetMailItemContainer(mailItemViewModel);
|
|
||||||
container?.IsSelected = mailItemViewModel.IsSelected;
|
|
||||||
}
|
|
||||||
else if (mailListItem is ThreadMailItemViewModel threadMailItemViewModel)
|
|
||||||
{
|
|
||||||
var container = GetThreadMailItemContainer(threadMailItemViewModel);
|
|
||||||
container?.IsSelected = threadMailItemViewModel.IsSelected;
|
|
||||||
container?.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel)
|
public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel)
|
||||||
{
|
{
|
||||||
WinoMailItemViewModelListViewItem? itemContainer = null;
|
WinoMailItemViewModelListViewItem? itemContainer = null;
|
||||||
@@ -178,6 +188,7 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Cleanup()
|
public void Cleanup()
|
||||||
{
|
{
|
||||||
DragItemsStarting -= ItemDragStarting;
|
DragItemsStarting -= ItemDragStarting;
|
||||||
|
|||||||
@@ -13,11 +13,4 @@ public partial class WinoMailItemViewModelListViewItem : ListViewItem
|
|||||||
{
|
{
|
||||||
DefaultStyleKey = typeof(WinoMailItemViewModelListViewItem);
|
DefaultStyleKey = typeof(WinoMailItemViewModelListViewItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnContentChanged(object oldContent, object newContent)
|
|
||||||
{
|
|
||||||
base.OnContentChanged(oldContent, newContent);
|
|
||||||
|
|
||||||
if (newContent is MailItemViewModel mailItemViewModel) Item = mailItemViewModel;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using CommunityToolkit.WinUI;
|
using CommunityToolkit.WinUI;
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Wino.Controls;
|
using Wino.Controls;
|
||||||
using Wino.Helpers;
|
using Wino.Helpers;
|
||||||
@@ -16,59 +15,12 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem
|
|||||||
[GeneratedDependencyProperty]
|
[GeneratedDependencyProperty]
|
||||||
public partial ThreadMailItemViewModel? Item { get; set; }
|
public partial ThreadMailItemViewModel? Item { get; set; }
|
||||||
|
|
||||||
protected override void OnContentChanged(object oldContent, object newContent)
|
|
||||||
{
|
|
||||||
base.OnContentChanged(oldContent, newContent);
|
|
||||||
|
|
||||||
if (newContent is ThreadMailItemViewModel threadMailItemViewModel) Item = threadMailItemViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public WinoThreadMailItemViewModelListViewItem()
|
public WinoThreadMailItemViewModelListViewItem()
|
||||||
{
|
{
|
||||||
DefaultStyleKey = typeof(WinoThreadMailItemViewModelListViewItem);
|
DefaultStyleKey = typeof(WinoThreadMailItemViewModelListViewItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsThreadExpandedChanged(bool newValue)
|
|
||||||
{
|
|
||||||
// 1. Reflect expansion changes to WinoExpander.
|
|
||||||
// 2. Automatically select first item on expansion, if none selected.
|
|
||||||
// 3. Unselect all items on collapse.
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnIsThreadExpandedChanged(DependencyObject sender, DependencyPropertyChangedEventArgs dp)
|
|
||||||
{
|
|
||||||
// 1. Reflect expansion changes to WinoExpander.
|
|
||||||
// 2. Automatically select first item on expansion, if none selected.
|
|
||||||
// 3. Unselect all items on collapse.
|
|
||||||
|
|
||||||
//var control = sender as WinoThreadMailItemViewModelListViewItem;
|
|
||||||
|
|
||||||
//var innerControl = control?.GetWinoListViewControl();
|
|
||||||
//var expander = control?.GetExpander();
|
|
||||||
|
|
||||||
//if (innerControl == null || control == null || expander == null) return;
|
|
||||||
|
|
||||||
|
|
||||||
//// 2
|
|
||||||
//if (control.IsThreadExpanded && innerControl.SelectedItems.Count == 0 && innerControl.Items.Count > 0)
|
|
||||||
//{
|
|
||||||
// innerControl.SelectedItems.Clear();
|
|
||||||
|
|
||||||
// // Make item selected, container might not be realized yet, so set on the model.
|
|
||||||
// // It'll appear selected when container is realized.
|
|
||||||
|
|
||||||
// var firstItem = innerControl.Items.FirstOrDefault() as MailItemViewModel;
|
|
||||||
|
|
||||||
// firstItem?.IsSelected = true;
|
|
||||||
//}
|
|
||||||
|
|
||||||
//// 1
|
|
||||||
//expander.IsExpanded = control.IsThreadExpanded;
|
|
||||||
|
|
||||||
//// 3
|
|
||||||
//if (!control.IsSelected) innerControl?.SelectedItems.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public WinoListView? GetWinoListViewControl()
|
public WinoListView? GetWinoListViewControl()
|
||||||
{
|
{
|
||||||
var expander = GetExpander();
|
var expander = GetExpander();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="58272BurakKSE.WinoMailPreview"
|
Name="58272BurakKSE.WinoMailPreview"
|
||||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
||||||
Version="0.0.4.0" />
|
Version="0.0.7.0" />
|
||||||
|
|
||||||
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
|
||||||
|
|||||||
@@ -154,17 +154,20 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
|
|
||||||
private void WinoListViewChoosingItemContainer(ListViewBase sender, ChoosingItemContainerEventArgs args)
|
private void WinoListViewChoosingItemContainer(ListViewBase sender, ChoosingItemContainerEventArgs args)
|
||||||
{
|
{
|
||||||
if (args.Item is ThreadMailItemViewModel)
|
if (args.Item is ThreadMailItemViewModel && args.ItemContainer is not WinoThreadMailItemViewModelListViewItem)
|
||||||
{
|
{
|
||||||
args.ItemContainer = new WinoThreadMailItemViewModelListViewItem();
|
args.ItemContainer = new WinoThreadMailItemViewModelListViewItem()
|
||||||
|
{
|
||||||
|
Item = args.Item as ThreadMailItemViewModel
|
||||||
|
};
|
||||||
}
|
}
|
||||||
else if (args.Item is MailItemViewModel)
|
else if (args.Item is MailItemViewModel && args.ItemContainer is not WinoMailItemViewModelListViewItem)
|
||||||
{
|
{
|
||||||
args.ItemContainer = new WinoMailItemViewModelListViewItem();
|
args.ItemContainer = new WinoMailItemViewModelListViewItem()
|
||||||
|
{
|
||||||
|
Item = args.Item as MailItemViewModel
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the preparation in PrepareContainerForItemOverride
|
|
||||||
args.IsContainerPrepared = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void MailItemContextRequested(UIElement sender, ContextRequestedEventArgs args)
|
private async void MailItemContextRequested(UIElement sender, ContextRequestedEventArgs args)
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace Wino.Messaging.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Emitted when synchronizer state is updated.
|
/// Emitted when synchronizer state is updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="synchronizer">Account Synchronizer</param>
|
/// <param name="AccountId">Account id</param>
|
||||||
/// <param name="newState">New state.</param>
|
/// <param name="NewState">New synchronizer state</param>
|
||||||
public record AccountSynchronizerStateChanged(Guid AccountId, AccountSynchronizerState NewState) : UIMessageBase<AccountSynchronizerStateChanged>;
|
/// <param name="TotalItemsToSync">Total items to sync (0 for indeterminate)</param>
|
||||||
|
/// <param name="RemainingItemsToSync">Remaining items to sync</param>
|
||||||
|
/// <param name="SynchronizationStatus">Current synchronization status message</param>
|
||||||
|
public record AccountSynchronizerStateChanged(
|
||||||
|
Guid AccountId,
|
||||||
|
AccountSynchronizerState NewState,
|
||||||
|
int TotalItemsToSync = 0,
|
||||||
|
int RemainingItemsToSync = 0,
|
||||||
|
string SynchronizationStatus = "") : UIMessageBase<AccountSynchronizerStateChanged>;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ public class ServerContext :
|
|||||||
IRecipient<AccountSynchronizerStateChanged>,
|
IRecipient<AccountSynchronizerStateChanged>,
|
||||||
IRecipient<RefreshUnreadCountsMessage>,
|
IRecipient<RefreshUnreadCountsMessage>,
|
||||||
IRecipient<ServerTerminationModeChanged>,
|
IRecipient<ServerTerminationModeChanged>,
|
||||||
IRecipient<AccountSynchronizationProgressUpdatedMessage>,
|
|
||||||
IRecipient<AccountFolderConfigurationUpdated>,
|
IRecipient<AccountFolderConfigurationUpdated>,
|
||||||
IRecipient<CopyAuthURLRequested>,
|
IRecipient<CopyAuthURLRequested>,
|
||||||
IRecipient<NewMailSynchronizationRequested>,
|
IRecipient<NewMailSynchronizationRequested>,
|
||||||
@@ -154,8 +153,6 @@ public class ServerContext :
|
|||||||
|
|
||||||
public async void Receive(AccountSynchronizerStateChanged message) => await SendMessageAsync(MessageType.UIMessage, message);
|
public async void Receive(AccountSynchronizerStateChanged message) => await SendMessageAsync(MessageType.UIMessage, message);
|
||||||
|
|
||||||
public async void Receive(AccountSynchronizationProgressUpdatedMessage message) => await SendMessageAsync(MessageType.UIMessage, message);
|
|
||||||
|
|
||||||
public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message);
|
public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message);
|
||||||
|
|
||||||
public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message);
|
public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message);
|
||||||
|
|||||||
@@ -797,6 +797,9 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
public Task<int> GetMailItemQueueCountAsync(Guid accountId)
|
public Task<int> GetMailItemQueueCountAsync(Guid accountId)
|
||||||
=> Connection.Table<MailItemQueue>().Where(a => a.AccountId == accountId).CountAsync();
|
=> Connection.Table<MailItemQueue>().Where(a => a.AccountId == accountId).CountAsync();
|
||||||
|
|
||||||
|
public Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
|
||||||
|
=> Connection.Table<MailItemQueue>().Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId).CountAsync();
|
||||||
|
|
||||||
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
|
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
|
||||||
{
|
{
|
||||||
if (queueItems == null || !queueItems.Any())
|
if (queueItems == null || !queueItems.Any())
|
||||||
@@ -824,6 +827,16 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
|
||||||
|
{
|
||||||
|
// For Outlook per-folder sync
|
||||||
|
return Connection.Table<MailItemQueue>()
|
||||||
|
.Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId && !a.IsProcessed)
|
||||||
|
.OrderBy(a => a.CreatedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
|
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
|
||||||
|
|||||||
Reference in New Issue
Block a user