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
+1 -1
View File
@@ -46,13 +46,13 @@ public sealed class ShoSha256
{
if (_absorbing) throw new InvalidOperationException("ShoSha256: must ratchet before squeezing");
var output = new byte[outlen];
Span<byte> ctr = stackalloc byte[8]; // hoisted out of the loop; fully rewritten each iteration
for (int i = 0; i * HashLen < outlen; i++)
{
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
h.AppendData(new byte[] { 0x01 });
h.AppendData(_cv);
Span<byte> ctr = stackalloc byte[8];
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
h.AppendData(ctr);
byte[] digest = h.GetHashAndReset();
@@ -16,7 +16,8 @@ public static class CredentialSystem
public sealed class SystemParams
{
public Ristretto255 GW, GWprime, GX0, GX1, GV, GZ;
// Always populated by Generate()'s object initializer (null! silences the constructor-exit check).
public Ristretto255 GW = null!, GWprime = null!, GX0 = null!, GX1 = null!, GV = null!, GZ = null!;
public Ristretto255[] GY = new Ristretto255[NumSupportedAttrs];
public static readonly SystemParams Hardcoded = Generate();
@@ -84,7 +85,7 @@ public sealed class Credential
public sealed class CredentialPrivateKey
{
public Scalar25519 W, Wprime, X0, X1;
public Ristretto255 BigW;
public Ristretto255 BigW = null!; // set by Generate()/FromPrivate() before use
public Scalar25519[] Y = new Scalar25519[CredentialSystem.NumSupportedAttrs];
public static CredentialPrivateKey Generate(byte[] randomness)
@@ -120,7 +121,7 @@ public sealed class CredentialPrivateKey
/// <summary>The server's public credential key the client uses to receive + present credentials.</summary>
public sealed class CredentialPublicKey
{
public Ristretto255 CW;
public Ristretto255 CW = null!; // set by FromPrivate() before use
public Ristretto255[] I = new Ristretto255[CredentialSystem.NumSupportedAttrs - 1]; // I_2 .. I_7
/// <summary>I for a credential with <paramref name="numAttrs"/> attribute points (≥2).</summary>
+11
View File
@@ -74,6 +74,17 @@ public sealed class ContactsStore
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
}
/// <summary>The contact's phone number (E.164), if we synced/imported one — a friendlier last-resort
/// label than a raw ACI when no name is available.</summary>
public string? NumberFor(string aci)
{
using SqliteConnection conn = Open();
using SqliteCommand cmd = conn.CreateCommand();
cmd.CommandText = "SELECT number FROM contacts WHERE aci = $aci;";
cmd.Parameters.AddWithValue("$aci", aci);
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
}
/// <summary>Contacts whose name or number contains <paramref name="query"/> (empty = all named),
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
@@ -0,0 +1,77 @@
using System.Runtime.Versioning;
using Microsoft.Data.Sqlite;
namespace Wingnal.Service.Account;
/// <summary>
/// SQLite store of profile display names we've fetched and decrypted per ACI (via <c>ProfileCipher</c>),
/// so the conversation list can name contacts who aren't in the primary's system address book. This is
/// distinct from <see cref="ContactsStore"/> (which holds names synced from the primary): profile names
/// are resolved on demand from each peer's profile key. Names are encrypted at rest with
/// <see cref="LocalCipher"/> (%LOCALAPPDATA%\Wingnal\profile_names.db). Keyed by ACI.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ProfileNameStore
{
private readonly string _connectionString;
private readonly LocalCipher _cipher;
public ProfileNameStore(string? path = null, LocalCipher? cipher = null)
{
if (path is null)
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
Directory.CreateDirectory(dir);
path = Path.Combine(dir, "profile_names.db");
}
_cipher = cipher ?? LocalCipher.Default();
_connectionString = $"Data Source={path}";
Initialize();
}
private void Initialize()
{
using SqliteConnection conn = Open();
using SqliteCommand cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS profile_names (aci TEXT PRIMARY KEY, name TEXT NOT NULL);";
cmd.ExecuteNonQuery();
}
public void Store(string aci, string name)
{
if (string.IsNullOrWhiteSpace(name)) return;
using SqliteConnection conn = Open();
using SqliteCommand cmd = conn.CreateCommand();
cmd.CommandText =
"INSERT INTO profile_names (aci, name) VALUES ($a, $n) ON CONFLICT(aci) DO UPDATE SET name = $n;";
cmd.Parameters.AddWithValue("$a", aci);
cmd.Parameters.AddWithValue("$n", _cipher.Protect(name));
cmd.ExecuteNonQuery();
}
public string? Get(string aci)
{
using SqliteConnection conn = Open();
using SqliteCommand cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM profile_names WHERE aci = $a;";
cmd.Parameters.AddWithValue("$a", aci);
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
}
/// <summary>Removes all resolved profile names (used when unlinking).</summary>
public void Clear()
{
using SqliteConnection conn = Open();
using SqliteCommand cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM profile_names;";
cmd.ExecuteNonQuery();
}
private SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
return conn;
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Text;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Service.Crypto;
/// <summary>
/// Decrypts Signal profile fields (name, about) that a recipient encrypted with their 32-byte profile
/// key. The blob layout matches libsignal's <c>ProfileCipher</c>: <c>nonce(12) || AES-256-GCM ciphertext
/// || tag(16)</c>. The decrypted name plaintext is NUL-separated <c>given \0 family</c>, zero-padded to a
/// fixed bucket length, so decoding strips the padding and rejoins the parts.
/// </summary>
public static class ProfileCipher
{
/// <summary>Decrypts a base64 profile-name blob to a display name. Returns null when the input is
/// empty/malformed, the profile key is the wrong length, or GCM authentication fails (a stale or
/// mismatched profile key) — callers treat that as "no name available".</summary>
public static string? DecryptName(byte[] profileKey, string? base64Name)
{
if (profileKey.Length != 32 || string.IsNullOrEmpty(base64Name)) return null;
byte[] blob;
try { blob = Convert.FromBase64String(base64Name); }
catch (FormatException) { return null; }
if (blob.Length < 12 + 16) return null; // must hold at least a nonce and a tag
var nonce = new byte[12];
Array.Copy(blob, 0, nonce, 0, 12);
var ciphertextAndTag = new byte[blob.Length - 12];
Array.Copy(blob, 12, ciphertextAndTag, 0, ciphertextAndTag.Length);
byte[] plaintext;
try { plaintext = CryptoPrimitives.AesGcmDecrypt(profileKey, nonce, ciphertextAndTag); }
catch { return null; } // tag mismatch / bad key
return DecodePaddedName(plaintext);
}
/// <summary>Plaintext is <c>given \0 family</c> followed by zero padding. Split on the first NUL (the
/// family segment may be empty), UTF-8 decode each part, and rejoin. Returns null if the result is
/// blank.</summary>
private static string? DecodePaddedName(byte[] plaintext)
{
int split = Array.IndexOf(plaintext, (byte)0);
string given, family;
if (split < 0)
{
given = Encoding.UTF8.GetString(plaintext);
family = string.Empty;
}
else
{
given = Encoding.UTF8.GetString(plaintext, 0, split);
int rest = split + 1;
int end = Array.IndexOf(plaintext, (byte)0, rest);
if (end < 0) end = plaintext.Length;
family = Encoding.UTF8.GetString(plaintext, rest, end - rest);
}
string name = $"{given} {family}".Trim();
return name.Length == 0 ? null : name;
}
}
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
namespace Wingnal.Service.Net;
/// <summary>The subset of <c>GET /v1/profile/{id}</c> we consume: the encrypted display <c>name</c>
/// (base64 of <c>nonce || ciphertext || tag</c>, decryptable with the recipient's profile key). Other
/// fields (about, avatar, capabilities, …) are ignored.</summary>
public sealed record ProfileResponse
{
public string? Name { get; init; }
public string? About { get; init; }
}
+13
View File
@@ -74,6 +74,19 @@ public sealed class SignalRestClient : IDisposable
?? throw new InvalidOperationException("empty prekey response");
}
/// <summary>Fetches a recipient's profile (GET /v1/profile/{serviceId}). The <c>name</c> field is
/// base64 of a ProfileCipher blob encrypted under the recipient's profile key. Returns null when the
/// profile isn't accessible (e.g. 401/403/404) so callers can fall back to a placeholder name rather
/// than surfacing an error. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
public async Task<ProfileResponse?> GetProfileAsync(string serviceId, string authToken, CancellationToken ct)
{
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/profile/{serviceId}");
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<ProfileResponse>(Json, ct).ConfigureAwait(false);
}
/// <summary>Sends a list of per-device encrypted messages to a destination. Returns the raw body on
/// a device-mismatch (409) / stale-devices (410) so the caller can react; throws on other failures.</summary>
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
+1 -1
View File
@@ -944,7 +944,7 @@ message ContactDetails {
optional Avatar avatar = 3;
reserved /* color */ 4;
reserved /* verified */ 5;
reserved /* profileKey */ 6;
optional bytes profileKey = 6; // used to fetch + decrypt the contact's profile name
reserved /* blocked */ 7;
optional uint32 expireTimer = 8;
optional uint32 expireTimerVersion = 12;
+11 -1
View File
@@ -18,12 +18,15 @@ public sealed class BackupImporter
{
private readonly MessageStore _messages;
private readonly ContactsStore _contacts;
private readonly ProfileKeyStore? _profileKeys;
private readonly string _ownAci;
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci)
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci,
ProfileKeyStore? profileKeys = null)
{
_messages = messages;
_contacts = contacts;
_profileKeys = profileKeys;
_ownAci = ownAci.ToLowerInvariant();
}
@@ -56,6 +59,13 @@ public sealed class BackupImporter
recipients[r.Id] = new ResolvedPeer(aci, name);
string? number = r.Contact.E164 != 0 ? "+" + r.Contact.E164 : null;
_contacts.Upsert(new StoreContact(aci, number, name, InboxPosition: 0));
// Keep the profile key so an un-named contact's profile name can still be fetched
// and decrypted later (the modern contacts sync no longer carries these).
if (_profileKeys is not null && r.Contact.HasProfileKey)
{
byte[] pk = r.Contact.ProfileKey.ToByteArray();
if (pk.Length == 32) _profileKeys.Store(aci, pk);
}
contactCount++;
break;
default:
@@ -24,13 +24,16 @@ public sealed class MessageHistoryImporter
private readonly SignalRestClient _rest;
private readonly MessageStore _messages;
private readonly ContactsStore _contacts;
private readonly ProfileKeyStore? _profileKeys;
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages, ContactsStore contacts)
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages,
ContactsStore contacts, ProfileKeyStore? profileKeys = null)
{
_account = account;
_rest = rest;
_messages = messages;
_contacts = contacts;
_profileKeys = profileKeys;
}
/// <param name="ShouldRetry">True when the failure was transient (timed out / network / parse error)
@@ -80,7 +83,7 @@ public sealed class MessageHistoryImporter
MessageBackupKey key = BackupKey.ForLinkAndSync(ephemeral, aci);
BackupContents backup = BackupReader.Read(file, key);
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci).Import(backup);
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci, _profileKeys).Import(backup);
FileLog.Write($"link'n'sync: imported {summary.Messages} message(s), {summary.Contacts} contact(s)");
return new Result(true, summary, $"imported {summary.Messages} messages, {summary.Contacts} contacts");
+3 -1
View File
@@ -1,7 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Windows-only in practice: the local stores use DPAPI (ProtectedData). Declaring the platform
here keeps CA1416 quiet for the store callers, matching the app + test projects. -->
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Wingnal.Service</RootNamespace>
@@ -0,0 +1,80 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Wingnal.Protocol.Crypto;
using Wingnal.Service.Crypto;
using Xunit;
namespace Wingnal.Tests.Crypto;
public class ProfileCipherTests
{
// Builds a Signal profile-name blob: nonce(12) || AES-256-GCM(profileKey, nonce, padded) || tag(16),
// base64-encoded — the same layout the profile endpoint returns in its "name" field.
private static string EncryptName(byte[] profileKey, string given, string family, int bucket = 53)
{
byte[] givenBytes = Encoding.UTF8.GetBytes(given);
byte[] familyBytes = Encoding.UTF8.GetBytes(family);
var padded = new byte[bucket]; // zero-filled → trailing padding
Array.Copy(givenBytes, 0, padded, 0, givenBytes.Length);
int familyStart = givenBytes.Length + 1; // one NUL separates given/family
Array.Copy(familyBytes, 0, padded, familyStart, familyBytes.Length);
byte[] nonce = RandomNumberGenerator.GetBytes(12);
byte[] ciphertextAndTag = CryptoPrimitives.AesGcmEncrypt(profileKey, nonce, padded);
var blob = new byte[nonce.Length + ciphertextAndTag.Length];
Array.Copy(nonce, 0, blob, 0, nonce.Length);
Array.Copy(ciphertextAndTag, 0, blob, nonce.Length, ciphertextAndTag.Length);
return Convert.ToBase64String(blob);
}
[Fact]
public void DecryptName_RoundTripsGivenAndFamily()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string blob = EncryptName(key, "Alice", "Smith");
Assert.Equal("Alice Smith", ProfileCipher.DecryptName(key, blob));
}
[Fact]
public void DecryptName_GivenNameOnly_HasNoTrailingSpace()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
string blob = EncryptName(key, "Bob", "");
Assert.Equal("Bob", ProfileCipher.DecryptName(key, blob));
}
[Fact]
public void DecryptName_WrongKey_ReturnsNull()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] otherKey = RandomNumberGenerator.GetBytes(32);
string blob = EncryptName(key, "Alice", "Smith");
Assert.Null(ProfileCipher.DecryptName(otherKey, blob)); // GCM tag mismatch
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("not-valid-base64!!")]
[InlineData("QUJD")] // valid base64 but far too short to hold a nonce + tag
public void DecryptName_InvalidInput_ReturnsNull(string? input)
{
byte[] key = RandomNumberGenerator.GetBytes(32);
Assert.Null(ProfileCipher.DecryptName(key, input));
}
[Fact]
public void DecryptName_WrongKeyLength_ReturnsNull()
{
byte[] shortKey = RandomNumberGenerator.GetBytes(16);
byte[] realKey = RandomNumberGenerator.GetBytes(32);
string blob = EncryptName(realKey, "Alice", "Smith");
Assert.Null(ProfileCipher.DecryptName(shortKey, blob));
}
}
@@ -8,8 +8,9 @@ namespace Wingnal.Tests.Messaging;
/// <summary>
/// Live diagnostic: loads the persisted account and connects the authenticated chat socket for a few
/// seconds, letting ChatReceiver log connect/frames/close to wingnal.log. Excluded from default runs.
/// Run: dotnet test --filter "Category=Live". Requires a linked account on this machine.
/// seconds, letting ChatReceiver log connect/frames/close to wingnal.log. Skipped by default because it
/// logs in as the real account — which disconnects the running app ("Connected elsewhere") and spams its
/// log. To run it manually, remove the Skip below. Requires a linked account on this machine.
/// </summary>
[Trait("Category", "Live")]
public class LiveChatConnectTests
@@ -17,7 +18,7 @@ public class LiveChatConnectTests
private readonly ITestOutputHelper _output;
public LiveChatConnectTests(ITestOutputHelper output) => _output = output;
[Fact]
[Fact(Skip = "Live diagnostic — connects as the real account. Remove this Skip to run manually.")]
public async Task ConnectsAuthenticatedSocket_AndLogs()
{
// The packaged WinUI app redirects LocalApplicationData into its package container.
+38 -17
View File
@@ -73,7 +73,7 @@
<!-- ════ Conversation sidebar ════ -->
<Grid x:Name="SidebarPane" Grid.Column="0" Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderThickness="0,0,1,0" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
Padding="12,8,12,12" RowSpacing="10">
Padding="6,8,6,12" RowSpacing="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -98,28 +98,40 @@
</Button>
</Grid>
<!-- Right padding reserves a gutter so the scrollbar doesn't overlap the rows. -->
<ListView Grid.Row="1" x:Name="ConversationList" SelectionMode="Single"
SelectionChanged="OnConversationSelected" Padding="0,2">
<ListView.Resources>
<!-- Clean selected row: a subtle accent-tinted rounded fill — no left "pill", no grey box. -->
<x:Boolean x:Key="ListViewItemSelectionIndicatorVisualEnabled">False</x:Boolean>
<CornerRadius x:Key="ListViewItemCornerRadius">8</CornerRadius>
<SolidColorBrush x:Key="ListViewItemBackgroundSelected"
Color="{ThemeResource SystemAccentColor}" Opacity="0.16" />
<SolidColorBrush x:Key="ListViewItemBackgroundSelectedPointerOver"
Color="{ThemeResource SystemAccentColor}" Opacity="0.22" />
<SolidColorBrush x:Key="ListViewItemBackgroundSelectedPressed"
Color="{ThemeResource SystemAccentColor}" Opacity="0.12" />
</ListView.Resources>
SelectionChanged="OnConversationSelected" Padding="0,2,8,2">
<ListView.ItemContainerStyle>
<!-- The container is a bare, transparent content host: the row's own fill (hover/
selected) is drawn by the Border in the item template, so it always wraps the
whole row. Margin is the gap between rows; the row's inner padding lives on that
Border. Stretch so the Border fills the full row width. -->
<Style TargetType="ListViewItem">
<Setter Property="Padding" Value="8,6" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,1" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<ContentPresenter Background="Transparent"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="local:ConversationItem">
<!-- This Border is the row fill: it wraps every component, so the hover/selected
grey always contains the avatar, text, time and unread dot. -->
<Border Background="{x:Bind RowBackground, Mode=OneWay}" CornerRadius="8"
Padding="12,10"
PointerEntered="OnConversationPointerEntered"
PointerExited="OnConversationPointerExited">
<Grid ColumnSpacing="12">
<Grid.ContextFlyout>
<MenuFlyout>
@@ -142,20 +154,29 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind Title, Mode=OneWay}"
FontWeight="{x:Bind TitleFontWeight, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<InfoBadge Grid.Column="1" Value="{x:Bind UnreadCount, Mode=OneWay}"
Visibility="{x:Bind UnreadVisibility, Mode=OneWay}"
<!-- Right-aligned time, then a small accent dot when there are unread messages. -->
<TextBlock Grid.Column="1" Text="{x:Bind TimeLabel, Mode=OneWay}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
VerticalAlignment="Center" />
<Ellipse Grid.Column="2" Width="8" Height="8" VerticalAlignment="Center"
Fill="{ThemeResource AccentFillColorDefaultBrush}"
Visibility="{x:Bind UnreadVisibility, Mode=OneWay}"
ToolTipService.ToolTip="Unread" />
</Grid>
<TextBlock Text="{x:Bind Preview, Mode=OneWay}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
TextWrapping="NoWrap" MaxLines="1"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
+78 -7
View File
@@ -41,6 +41,7 @@ namespace Wingnal
private readonly MessageStore _store = new();
private readonly ContactsStore _contacts = new();
private readonly ProfileKeyStore _profileKeys = new();
private readonly ProfileNameStore _profileNames = new(); // names fetched+decrypted from profiles
private readonly SqliteSenderKeyStore _senderKeys = new(); // GroupsV2 G1 receive
private readonly Wingnal.Service.Groups.GroupStore _groups = new(); // GroupsV2 group state
private readonly Wingnal.Service.Attachments.AttachmentService _attachments = new();
@@ -49,6 +50,7 @@ namespace Wingnal
private CancellationTokenSource? _importCts;
private SignalAccount? _account;
private SignalRestClient? _rest;
private ProfileService? _profiles;
private SyncProcessor? _syncProcessor;
// One durable per-peer session store shared by BOTH send and receive (sessions keyed by peer
// address, so a single store serves the whole conversation without initiator/responder clobber).
@@ -129,7 +131,7 @@ namespace Wingnal
_account = account;
_protocolStore = new SqliteSignalProtocolStore(account, "protocol.db",
onChanged: () => new AccountStore().Save(account));
_syncProcessor = new SyncProcessor(_contacts);
_syncProcessor = new SyncProcessor(_contacts, _profileKeys);
_store.Deduplicate(); // clean up any duplicate rows left by an earlier double-import
@@ -151,6 +153,10 @@ namespace Wingnal
_ = StartReceiveAsync(account, _protocolStore, _cts.Token);
// Ask the primary to push contacts/blocked/configuration so the list can show names.
_ = RequestSyncAsync(account, _protocolStore, _cts.Token);
// For conversations we still can't name from synced contacts, resolve a name from each
// peer's Signal profile (best-effort; refreshes titles as names come back).
foreach (ConversationItem item in _conversations)
MaybeResolveProfile(item.Peer);
// If this device was just re-linked with link+sync, show the import screen + backfill once.
if (account.EphemeralBackupKey is { Length: 32 })
ImportOverlay.Visibility = Visibility.Visible;
@@ -171,10 +177,27 @@ namespace Wingnal
private void OnConversationSelected(object sender, SelectionChangedEventArgs e)
{
// Keep each row's IsSelected in sync so its Border draws the selected fill.
foreach (object removed in e.RemovedItems)
if (removed is ConversationItem prev) prev.IsSelected = false;
foreach (object added in e.AddedItems)
if (added is ConversationItem next) next.IsSelected = true;
if (ConversationList.SelectedItem is not ConversationItem item) return;
SelectPeer(item.Peer, item.Title);
}
// Hover state drives the row's subtle fill (the Border reads IsHovered via RowBackground).
private void OnConversationPointerEntered(object sender, PointerRoutedEventArgs e)
{
if (sender is FrameworkElement { DataContext: ConversationItem item }) item.IsHovered = true;
}
private void OnConversationPointerExited(object sender, PointerRoutedEventArgs e)
{
if (sender is FrameworkElement { DataContext: ConversationItem item }) item.IsHovered = false;
}
private void SelectPeer(string peer, string title)
{
_selectedPeer = peer;
@@ -566,7 +589,7 @@ namespace Wingnal
try
{
_rest ??= new SignalRestClient();
var importer = new MessageHistoryImporter(account, _rest, _store, _contacts);
var importer = new MessageHistoryImporter(account, _rest, _store, _contacts, _profileKeys);
MessageHistoryImporter.Result result = await Task.Run(
() => importer.ImportAsync(ct: _importCts.Token), _importCts.Token);
@@ -714,7 +737,10 @@ namespace Wingnal
if (!stored.Outgoing && !IsGroupKey(stored.Peer)
&& !string.Equals(stored.Peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrEmpty(_contacts.NameFor(stored.Peer)))
{
MaybeResyncContacts();
MaybeResolveProfile(stored.Peer); // try their Signal profile name too
}
});
}
@@ -750,8 +776,14 @@ namespace Wingnal
{
if (_syncProcessor is null) return;
await _syncProcessor.ProcessAsync(sync).ConfigureAwait(false);
// Contacts may now have names — refresh conversation titles on the UI thread.
_dispatcher.TryEnqueue(RefreshTitles);
// Contacts may now have names (and profile keys). Refresh titles, then try to resolve a
// profile name for anyone we still can't name — the sync may have just given us their key.
_dispatcher.TryEnqueue(() =>
{
RefreshTitles();
foreach (ConversationItem item in _conversations)
MaybeResolveProfile(item.Peer);
});
}
// ── delivery/read receipts + typing ──
@@ -945,10 +977,49 @@ namespace Wingnal
string? title = _groups.Load(gid)?.Group.Title;
return string.IsNullOrWhiteSpace(title) ? $"Group {gid[..Math.Min(8, gid.Length)]}" : title;
}
// Prefer the primary's synced/imported name; else a name we fetched+decrypted from the peer's
// Signal profile; else their phone number; else a "Note to Self"/shortened-ACI placeholder.
string? name = _contacts.NameFor(peer);
return string.IsNullOrWhiteSpace(name)
? ConversationItem.TitleFor(peer, _account?.Aci ?? "")
: name;
if (!string.IsNullOrWhiteSpace(name)) return name;
string? profileName = _profileNames.Get(peer);
if (!string.IsNullOrWhiteSpace(profileName)) return profileName;
string? number = _contacts.NumberFor(peer);
if (!string.IsNullOrWhiteSpace(number)) return number;
return ConversationItem.TitleFor(peer, _account?.Aci ?? "");
}
/// <summary>Lazily builds the profile-name resolver (shares the on-demand REST client).</summary>
private ProfileService? EnsureProfiles()
{
if (_account is null) return null;
_rest ??= new SignalRestClient();
return _profiles ??= new ProfileService(_rest, _profileKeys, _profileNames, _account.BasicAuthToken());
}
/// <summary>If we can't name <paramref name="peer"/> from synced contacts and haven't already
/// resolved a profile name, fetch+decrypt one in the background and refresh titles on success.
/// Best-effort: no key / inaccessible profile / decrypt failure just leaves the placeholder.</summary>
private void MaybeResolveProfile(string peer)
{
if (IsGroupKey(peer)) return;
if (string.Equals(peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)) return;
if (!string.IsNullOrWhiteSpace(_contacts.NameFor(peer))) return; // address-book name wins
if (!string.IsNullOrWhiteSpace(_profileNames.Get(peer))) return; // already resolved
ProfileService? profiles = EnsureProfiles();
CancellationTokenSource? cts = _cts;
if (profiles is null || cts is null) return;
_ = Task.Run(async () =>
{
try
{
string? name = await profiles.ResolveAsync(peer, cts.Token).ConfigureAwait(false);
if (name is not null) _dispatcher.TryEnqueue(RefreshTitles);
}
catch (Exception ex)
{
FileLog.Write($"profile: resolve {peer} failed {ex.GetType().Name}: {ex.Message}");
}
});
}
// ── GroupsV2 conversation helpers ──
+50 -1
View File
@@ -68,7 +68,22 @@ namespace Wingnal
public long LastTimestamp
{
get => _lastTimestamp;
set { _lastTimestamp = value; OnChanged(nameof(Subtitle)); }
set { _lastTimestamp = value; OnChanged(nameof(Subtitle)); OnChanged(nameof(TimeLabel)); }
}
/// <summary>Compact right-aligned timestamp for the row, Teams/Outlook-style: a time today, a
/// weekday within the last week, else a short date. Empty when there's no activity yet.</summary>
public string TimeLabel
{
get
{
if (_lastTimestamp == 0) return "";
DateTime dt = DateTimeOffset.FromUnixTimeMilliseconds(_lastTimestamp).LocalDateTime;
DateTime today = DateTime.Now.Date;
if (dt.Date == today) return dt.ToString("t"); // today → 3:45 PM
if ((today - dt.Date).TotalDays < 7) return dt.ToString("ddd"); // this week → Mon
return dt.ToString("M/d/yyyy"); // else → 8/29/2024
}
}
/// <summary>Initials/avatar glyph — first character of the title.</summary>
@@ -77,6 +92,40 @@ namespace Wingnal
/// <summary>Stable avatar colour for this peer.</summary>
public Brush AvatarBrush => BrushFor(Peer);
private bool _isSelected;
private bool _isHovered;
/// <summary>True while this row is the selected conversation (kept in sync by the page).</summary>
public bool IsSelected
{
get => _isSelected;
set { if (_isSelected == value) return; _isSelected = value; OnChanged(nameof(RowBackground)); }
}
/// <summary>True while the pointer is over this row.</summary>
public bool IsHovered
{
get => _isHovered;
set { if (_isHovered == value) return; _isHovered = value; OnChanged(nameof(RowBackground)); }
}
private static readonly SolidColorBrush TransparentBrush = new(Color.FromArgb(0, 0, 0, 0));
/// <summary>The row's fill brush, wrapping the whole row so it always contains the avatar, text
/// and unread dot: a raised neutral surface when selected, a subtle fill on hover, else transparent.
/// (Drawn by the row template's Border, not the ListViewItemPresenter, whose fill sizing/rounding
/// is unreliable in this Windows App SDK.)</summary>
public Brush RowBackground
{
get
{
string? key = _isSelected ? "ControlFillColorSecondaryBrush"
: _isHovered ? "SubtleFillColorSecondaryBrush"
: null;
return key is not null && Application.Current.Resources[key] is Brush b ? b : TransparentBrush;
}
}
/// <summary>Deterministic avatar colour for a peer key (shared by the list rows and the thread
/// header so the same contact is always the same colour). Cached per palette slot. In High
/// Contrast, custom colours break the system's contrast guarantees, so every avatar uses the
+2 -1
View File
@@ -41,7 +41,8 @@ namespace Wingnal
{
var accountStore = new AccountStore();
using var rest = new SignalRestClient();
var linker = new LinkingManager(accountStore, rest);
// Shown in Signal's linked-devices list, e.g. "Wingnal: DESKTOP-1234".
var linker = new LinkingManager(accountStore, rest, $"Wingnal: {Environment.MachineName}");
try
{
+1 -1
View File
@@ -11,7 +11,7 @@
<Identity
Name="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e"
Publisher="CN=micro"
Version="1.0.1.0" />
Version="1.0.2.0" />
<mp:PhoneIdentity PhoneProductId="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
+1
View File
@@ -99,6 +99,7 @@ namespace Wingnal
try { new MessageStore().Clear(); } catch { }
try { new ContactsStore().Clear(); } catch { }
try { new ProfileKeyStore().Clear(); } catch { }
try { new ProfileNameStore().Clear(); } catch { } // resolved profile names
try { new SqliteSenderKeyStore().Clear(); } catch { } // group sender keys (GroupsV2)
try { new Wingnal.Service.Groups.GroupStore().Clear(); } catch { } // group state (GroupsV2)
new AccountStore().Delete();
+5 -2
View File
@@ -1,5 +1,6 @@
using System;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Navigation;
namespace Wingnal
@@ -59,9 +60,11 @@ namespace Wingnal
// The title-bar contact search belongs to Chats only.
App.SearchVisibilitySink?.Invoke(target == typeof(ChatPage));
// Avoid re-navigating to the page we're already on.
// Avoid re-navigating to the page we're already on. Suppress the frame's content
// transition: its entrance animation competes with the rail's selection-indicator
// animation on the UI thread, which made the indicator stutter between tabs.
if (target is not null && ContentFrame.CurrentSourcePageType != target)
ContentFrame.Navigate(target);
ContentFrame.Navigate(target, null, new SuppressNavigationTransitionInfo());
}
}
}