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
+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;
}
}