Contacts, thread animation and image preview control improvements.

This commit is contained in:
Burak Kaan Köse
2026-02-09 22:39:30 +01:00
parent e559a79506
commit 0999c71578
26 changed files with 1636 additions and 756 deletions
+125 -20
View File
@@ -6,6 +6,7 @@ using MimeKit;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Contacts;
using Wino.Services.Extensions;
namespace Wino.Services;
@@ -38,32 +39,91 @@ public class ContactService : BaseDatabaseService, IContactService
public async Task SaveAddressInformationAsync(MimeMessage message)
{
var recipients = message
.GetRecipients(true)
.Where(a => !string.IsNullOrEmpty(a.Name) && !string.IsNullOrEmpty(a.Address));
if (message == null) return;
var addressInformations = recipients.Select(a => new AccountContact() { Name = a.Name, Address = a.Address });
var contacts = message
.GetRecipients(true)
.Where(a => !string.IsNullOrWhiteSpace(a.Address))
.Select(a => new AccountContact
{
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address : a.Name,
Address = a.Address
});
foreach (var info in addressInformations)
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
}
public async Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts)
{
if (contacts == null) return;
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
}
private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> contacts)
{
var addressInformations = contacts
.Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address))
.Select(a => new AccountContact
{
Address = a.Address.Trim(),
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address.Trim() : a.Name.Trim()
})
.GroupBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (addressInformations.Count == 0) return;
try
{
var currentContact = await GetAddressInformationByAddressAsync(info.Address).ConfigureAwait(false);
// Batch-fetch all existing contacts in one query.
var addresses = addressInformations.Select(a => a.Address).ToList();
var placeholders = string.Join(",", addresses.Select((_, i) => "?"));
var existingContacts = await Connection.QueryAsync<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
addresses.Cast<object>().ToArray()
).ConfigureAwait(false);
try
var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
var toInsert = new List<AccountContact>();
var toUpdate = new List<AccountContact>();
foreach (var info in addressInformations)
{
if (currentContact == null)
if (!existingLookup.TryGetValue(info.Address, out var existing))
{
await Connection.InsertAsync(info, typeof(AccountContact)).ConfigureAwait(false);
toInsert.Add(info);
}
else if (!currentContact.IsRootContact && !currentContact.IsOverridden) // Don't update root contacts or overridden contacts.
else if (!existing.IsRootContact && !existing.IsOverridden)
{
await Connection.InsertOrReplaceAsync(info, typeof(AccountContact)).ConfigureAwait(false);
// Only update if the new name is more informative (not just the email address)
// and actually different from the current name.
if (info.Name != info.Address && existing.Name != info.Name)
{
existing.Name = info.Name;
toUpdate.Add(existing);
}
}
}
catch (Exception ex)
if (toInsert.Count > 0 || toUpdate.Count > 0)
{
Log.Error("Failed to add contact information to the database.", ex);
await Connection.RunInTransactionAsync(conn =>
{
if (toInsert.Count > 0)
conn.InsertAll(toInsert, typeof(AccountContact));
foreach (var contact in toUpdate)
conn.Update(contact, typeof(AccountContact));
}).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to batch save contact information to the database.");
}
}
public Task<List<AccountContact>> GetAllContactsAsync()
@@ -81,20 +141,61 @@ public class ContactService : BaseDatabaseService, IContactService
return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
}
public async Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false)
{
offset = Math.Max(0, offset);
pageSize = Math.Max(1, pageSize);
var whereClauses = new List<string>();
var parameters = new List<object>();
if (excludeRootContacts)
{
whereClauses.Add("IsRootContact = 0");
}
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var pattern = $"%{searchQuery.Trim()}%";
whereClauses.Add("(Address LIKE ? OR Name LIKE ?)");
parameters.Add(pattern);
parameters.Add(pattern);
}
var whereSql = whereClauses.Count > 0
? $" WHERE {string.Join(" AND ", whereClauses)}"
: string.Empty;
var countQuery = $"SELECT COUNT(*) FROM AccountContact{whereSql}";
var totalCount = await Connection.ExecuteScalarAsync<int>(countQuery, parameters.ToArray()).ConfigureAwait(false);
var pageParameters = new List<object>(parameters)
{
pageSize,
offset
};
var pageQuery = $"SELECT * FROM AccountContact{whereSql} ORDER BY COALESCE(Name, Address) COLLATE NOCASE, Address COLLATE NOCASE LIMIT ? OFFSET ?";
var contacts = await Connection.QueryAsync<AccountContact>(pageQuery, pageParameters.ToArray()).ConfigureAwait(false);
var hasMore = offset + contacts.Count < totalCount;
return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize);
}
public async Task<AccountContact> UpdateContactAsync(AccountContact contact)
{
// Mark the contact as overridden when manually updated
contact.IsOverridden = true;
await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
return contact;
}
public async Task DeleteContactAsync(string address)
{
var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false);
if (contact != null && !contact.IsRootContact)
{
await Connection.DeleteAsync<AccountContact>(contact.Address).ConfigureAwait(false);
@@ -103,9 +204,13 @@ public class ContactService : BaseDatabaseService, IContactService
public async Task DeleteContactsAsync(IEnumerable<string> addresses)
{
foreach (var address in addresses)
{
await DeleteContactAsync(address).ConfigureAwait(false);
}
var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (addressList.Count == 0) return;
var placeholders = string.Join(",", addressList.Select((_, i) => "?"));
await Connection.ExecuteAsync(
$"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0",
addressList.Cast<object>().ToArray()
).ConfigureAwait(false);
}
}
+35 -4
View File
@@ -715,11 +715,12 @@ public class MailService : BaseDatabaseService, IMailService
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
mailCopy.FolderId = mailItemFolder.Id;
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
var contactSaveTask = _contactService.SaveAddressInformationAsync(mimeMessage);
var insertMailTask = InsertMailAsync(mailCopy);
await Task.WhenAll(mimeSaveTask, contactSaveTask, insertMailTask).ConfigureAwait(false);
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
}
public async Task CreateMailAsyncEx(Guid accountId, NewMailItemPackage package)
@@ -780,10 +781,11 @@ public class MailService : BaseDatabaseService, IMailService
}
}
// Save contact information.
await _contactService.SaveAddressInformationAsync(mimeMessage).ConfigureAwait(false);
}
// Save contact information extracted from provider API or MIME before insert/update.
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
// Create mail copy in the database.
// Update if exists.
@@ -814,6 +816,35 @@ public class MailService : BaseDatabaseService, IMailService
}
}
private async Task SaveContactsForPackageAsync(NewMailItemPackage package)
{
if (package == null) return;
if (package.Mime != null)
{
await _contactService.SaveAddressInformationAsync(package.Mime).ConfigureAwait(false);
return;
}
var contacts = package.ExtractedContacts?
.Where(c => c != null && !string.IsNullOrWhiteSpace(c.Address))
.ToList() ?? new List<AccountContact>();
var senderAddress = package.Copy?.FromAddress;
if (!string.IsNullOrWhiteSpace(senderAddress))
{
contacts.Add(new AccountContact
{
Address = senderAddress,
Name = string.IsNullOrWhiteSpace(package.Copy?.FromName) ? senderAddress : package.Copy.FromName
});
}
if (contacts.Count == 0) return;
await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false);
}
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
{
// This unique id is stored in mime headers for Wino to identify remote message with local copy.