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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user