Teams-style chat list, contact name resolution, and fixes

UI:
- Rework the conversation list to a Teams/Outlook-style row: single-line
  preview, right-aligned timestamp, unread dot, and a neutral elevated
  rounded fill (drawn by a Border that wraps the whole row) for hover/select.
- Smooth the Chats/Calls/Stories rail indicator by suppressing the content
  frame's transition, which competed with the indicator animation.

Contact names:
- Fetch + decrypt Signal profile names (ProfileCipher, ProfileService,
  ProfileNameStore, GET /v1/profile) when a peer has no synced name.
- Capture each contact's profileKey from the link'n'sync backup and the
  contacts sync (un-reserve ContactDetails.profileKey) so names can be
  resolved; fall back to the phone number before a raw ACI.

Other:
- Report the device to Signal as "Wingnal: {machine name}".
- Resolve build warnings: target Wingnal.Service at net8.0-windows (CA1416),
  initialize zkgroup credential fields (CS8618), hoist a stackalloc (CA2014).
- Skip the live chat-connect diagnostic test by default (it logged in as the
  real account and disconnected the app).
- Bump app version 1.0.1.0 -> 1.0.2.0.
This commit is contained in:
Austin
2026-07-09 16:22:15 -05:00
parent 77fb0c4204
commit 45b6d0291e
22 changed files with 532 additions and 43 deletions
@@ -0,0 +1,60 @@
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Wingnal.Service.Account;
using Wingnal.Service.Crypto;
using Wingnal.Service.Diagnostics;
using Wingnal.Service.Net;
namespace Wingnal.Service.Messaging;
/// <summary>
/// Resolves display names for peers we can't name from synced contacts, by fetching their Signal profile
/// (<c>GET /v1/profile</c>) and decrypting the name with the profile key learned from their inbound
/// messages (see <see cref="MessageDecryptor"/> → <see cref="ProfileKeyStore"/>). Resolved names are
/// cached in <see cref="ProfileNameStore"/> so a name is fetched at most once per peer.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ProfileService
{
private readonly SignalRestClient _rest;
private readonly ProfileKeyStore _profileKeys;
private readonly ProfileNameStore _names;
private readonly string _authToken;
public ProfileService(SignalRestClient rest, ProfileKeyStore profileKeys, ProfileNameStore names, string authToken)
{
_rest = rest;
_profileKeys = profileKeys;
_names = names;
_authToken = authToken;
}
/// <summary>The cached profile name for a peer, if one has been resolved; otherwise null.</summary>
public string? NameFor(string aci) => _names.Get(aci);
/// <summary>Fetches, decrypts and caches the profile name for a peer. Returns the resolved name, or
/// null when we have no profile key for them, their profile isn't accessible, or decryption fails.
/// Best-effort and idempotent — a cached name short-circuits the network call.</summary>
public async Task<string?> ResolveAsync(string aci, CancellationToken ct)
{
string? cached = _names.Get(aci);
if (!string.IsNullOrEmpty(cached)) return cached;
byte[]? profileKey = _profileKeys.Get(aci);
if (profileKey is null) { FileLog.Write($"profile: {aci} skip — no profile key"); return null; }
ProfileResponse? profile = await _rest.GetProfileAsync(aci, _authToken, ct).ConfigureAwait(false);
if (profile is null) { FileLog.Write($"profile: {aci} — fetch returned null (inaccessible/HTTP error)"); return null; }
string? name = ProfileCipher.DecryptName(profileKey, profile.Name);
if (name is null)
{
FileLog.Write($"profile: {aci} — decrypt/empty (name field len={profile.Name?.Length ?? 0})");
return null;
}
_names.Store(aci, name);
FileLog.Write($"profile: {aci} resolved (len={name.Length})"); // length only — no PII in the log
return name;
}
}
+14 -2
View File
@@ -15,14 +15,17 @@ namespace Wingnal.Service.Messaging;
public sealed class SyncProcessor
{
private readonly ContactsStore _contacts;
private readonly ProfileKeyStore? _profileKeys;
private readonly AttachmentDownloader _downloader;
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
public event Action<string, long>? ReadReceiptReceived;
public SyncProcessor(ContactsStore contacts, AttachmentDownloader? downloader = null)
public SyncProcessor(ContactsStore contacts, ProfileKeyStore? profileKeys = null,
AttachmentDownloader? downloader = null)
{
_contacts = contacts;
_profileKeys = profileKeys;
_downloader = downloader ?? new AttachmentDownloader();
}
@@ -54,7 +57,7 @@ public sealed class SyncProcessor
/// (no network) — the offline-testable core of contacts sync.</summary>
public int ImportContacts(byte[] blob)
{
int count = 0;
int count = 0, keys = 0;
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
{
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
@@ -63,8 +66,17 @@ public sealed class SyncProcessor
string? name = string.IsNullOrWhiteSpace(record.Details.Name) ? null : record.Details.Name;
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
_contacts.Upsert(new Contact(aci, number, name, (int)record.Details.InboxPosition));
// Keep the contact's profile key so we can fetch + decrypt their profile name later, even
// when the primary had no system-contact name for them (so the row isn't left as a raw ACI).
if (_profileKeys is not null && record.Details.HasProfileKey)
{
byte[] pk = record.Details.ProfileKey.ToByteArray();
if (pk.Length == 32) { _profileKeys.Store(aci, pk); keys++; }
}
count++;
}
FileLog.Write($"sync: imported {count} contact(s), {keys} profile key(s)");
return count;
}