feat: Smart Folders sidebar, category heatmap, sidebar counts API
- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard - Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections - Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage - Mailbox section: 11 Gmail mailbox items with icons and live count badges - Smart Folders section: 10 category folders sorted by count desc - Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint - Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping - Sidebar collapses to icon-only; section states persist to localStorage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ public class GmailApiService : IGmailService
|
||||
private readonly ILogger<GmailApiService> _logger;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
|
||||
// Cache the authenticated client per user so parallel message fetches don't
|
||||
// each rebuild it (which would hit the shared DbContext concurrently).
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
private Google.Apis.Gmail.v1.GmailService? _cachedClient;
|
||||
private Guid _cachedUserId;
|
||||
|
||||
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
|
||||
{
|
||||
_factory = factory;
|
||||
@@ -56,9 +62,26 @@ public class GmailApiService : IGmailService
|
||||
or HttpStatusCode.ServiceUnavailable
|
||||
or HttpStatusCode.GatewayTimeout;
|
||||
|
||||
/// <summary>Builds the Gmail client once per user and reuses it (thread-safe).</summary>
|
||||
private async Task<Google.Apis.Gmail.v1.GmailService> GetClientAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
if (_cachedClient is not null && _cachedUserId == userId) return _cachedClient;
|
||||
await _clientLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedClient is null || _cachedUserId != userId)
|
||||
{
|
||||
_cachedClient = await _factory.CreateAsync(userId, ct);
|
||||
_cachedUserId = userId;
|
||||
}
|
||||
return _cachedClient;
|
||||
}
|
||||
finally { _clientLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var profile = await _pipeline.ExecuteAsync(async token =>
|
||||
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
|
||||
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
|
||||
@@ -66,7 +89,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var page = await _pipeline.ExecuteAsync(async token =>
|
||||
{
|
||||
var req = client.Users.Messages.List("me");
|
||||
@@ -82,7 +105,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var msg = await _pipeline.ExecuteAsync(async token =>
|
||||
{
|
||||
var req = client.Users.Messages.Get("me", gmailMessageId);
|
||||
@@ -96,7 +119,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var history = await _pipeline.ExecuteAsync(async token =>
|
||||
{
|
||||
var req = client.Users.History.List("me");
|
||||
@@ -117,7 +140,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task<IReadOnlyList<DomainLabel>> ListLabelsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var resp = await _pipeline.ExecuteAsync(async token =>
|
||||
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
|
||||
|
||||
@@ -132,7 +155,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var body = new BatchModifyMessagesRequest
|
||||
{
|
||||
Ids = messageIds.ToList(),
|
||||
@@ -148,7 +171,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
foreach (var id in messageIds)
|
||||
{
|
||||
await _pipeline.ExecuteAsync(async token =>
|
||||
@@ -161,7 +184,7 @@ public class GmailApiService : IGmailService
|
||||
|
||||
public async Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _factory.CreateAsync(userId, ct);
|
||||
var client = await GetClientAsync(userId, ct);
|
||||
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
|
||||
await _pipeline.ExecuteAsync(async token =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user