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