ux+fix: rename Network Identity → Device Address (UI) + redundancy authors

#6: UI rename — Network Identity → Device Address.
Just labels/wording, no backend changes. Tauri command names
(list_identities, create_identity, etc.) and DTO fields stay as-is;
the rename is purely user-facing. Settings labels, lightbox titles,
toasts, danger-zone text, and welcome-screen "Import an Identity"
button all updated. Distinguishes more clearly from Personas (which
ARE the posting identity peers see).

#7: Bug fix — redundancy showed 0 for new posts.
Root cause: get_redundancy_summary queried
  SELECT p.id FROM posts WHERE p.author = ?1
with ?1 = self.node_id (the device's network NodeId). After the
v0.6.0 network/posting-ID split, posts are authored by posting
identities (personas), not the network NodeId — so the query
returned 0 of my posts and the redundancy panel showed all zeros.

Fix: storage::get_redundancy_summary now takes &[NodeId] and uses
WHERE author IN (?, ?, ...) over every persona on the device.
Node::get_redundancy_summary gathers them via
list_posting_identities() before delegating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 22:49:54 -06:00
parent 83fd30753f
commit 100ea55a15
4 changed files with 50 additions and 29 deletions

View file

@ -3442,7 +3442,12 @@ impl Node {
pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> { pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> {
let storage = self.storage.get().await; let storage = self.storage.get().await;
storage.get_redundancy_summary(&self.node_id, 3_600_000) // Posts are authored by posting identities (personas), not the
// network NodeId. Use every persona on this device so the
// summary counts all of my posts across personas.
let author_ids: Vec<NodeId> = storage.list_posting_identities()?
.into_iter().map(|p| p.node_id).collect();
storage.get_redundancy_summary(&author_ids, 3_600_000)
} }
// ---- Networking ---- // ---- Networking ----

View file

@ -3129,17 +3129,30 @@ impl Storage {
/// Get a summary of redundancy across all our authored posts. /// Get a summary of redundancy across all our authored posts.
/// Returns (total, zero_replicas, one_replica, two_plus_replicas). /// Returns (total, zero_replicas, one_replica, two_plus_replicas).
/// Redundancy summary across every post authored by ANY of the
/// device's posting identities (personas). Pre-v0.6.0 this matched
/// on the device's network NodeId, but the network/posting-ID
/// split moved authorship to the posting identity — so the old
/// query returned 0 of my posts and the UI showed 0 redundancy
/// for new posts.
pub fn get_redundancy_summary( pub fn get_redundancy_summary(
&self, &self,
our_node_id: &NodeId, author_ids: &[NodeId],
staleness_ms: u64, staleness_ms: u64,
) -> anyhow::Result<(usize, usize, usize, usize)> { ) -> anyhow::Result<(usize, usize, usize, usize)> {
if author_ids.is_empty() {
return Ok((0, 0, 0, 0));
}
let cutoff = now_ms() - staleness_ms as i64; let cutoff = now_ms() - staleness_ms as i64;
let mut stmt = self.conn.prepare( let placeholders: Vec<&str> = (0..author_ids.len()).map(|_| "?").collect();
"SELECT p.id FROM posts p WHERE p.author = ?1", let sql = format!(
)?; "SELECT p.id FROM posts p WHERE p.author IN ({})",
placeholders.join(","),
);
let mut stmt = self.conn.prepare(&sql)?;
let params_iter = rusqlite::params_from_iter(author_ids.iter().map(|n| n.to_vec()));
let post_ids: Vec<PostId> = { let post_ids: Vec<PostId> = {
let mut rows = stmt.query(params![our_node_id.as_slice()])?; let mut rows = stmt.query(params_iter)?;
let mut ids = Vec::new(); let mut ids = Vec::new();
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
ids.push(blob_to_postid(row.get(0)?)?); ids.push(blob_to_postid(row.get(0)?)?);

View file

@ -3543,11 +3543,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
const key = await invoke('export_identity'); const key = await invoke('export_identity');
try { try {
await navigator.clipboard.writeText(key); await navigator.clipboard.writeText(key);
toast('Identity key copied to clipboard. KEEP IT SECRET!'); toast('Device address key copied to clipboard. KEEP IT SECRET!');
} catch (clipErr) { } catch (clipErr) {
// Clipboard API may fail in some webview contexts — show the key instead // Clipboard API may fail in some webview contexts — show the key instead
console.error('Clipboard write failed:', clipErr); console.error('Clipboard write failed:', clipErr);
prompt('Copy your identity key (KEEP IT SECRET!):', key); prompt('Copy your device address key (KEEP IT SECRET!):', key);
} }
} catch (e) { } catch (e) {
console.error('export_identity failed:', e); console.error('export_identity failed:', e);
@ -3586,14 +3586,16 @@ document.querySelectorAll('.text-size-opt').forEach(btn => {
}); });
}); });
// --- Identity management --- // --- Device address management (formerly "Identity") ---
// The underlying Tauri commands keep their `identity` names for now —
// only the user-facing labels rename. Backend rename can follow.
async function loadIdentities() { async function loadIdentities() {
const list = $('#identities-list'); const list = $('#identities-list');
if (!list) return; if (!list) return;
try { try {
const identities = await invoke('list_identities'); const identities = await invoke('list_identities');
if (identities.length === 0) { if (identities.length === 0) {
list.innerHTML = '<p class="empty-hint">No identities</p>'; list.innerHTML = '<p class="empty-hint">No device addresses</p>';
return; return;
} }
list.innerHTML = identities.map(id => { list.innerHTML = identities.map(id => {
@ -3616,7 +3618,7 @@ async function loadIdentities() {
btn.textContent = 'Switching...'; btn.textContent = 'Switching...';
try { try {
await invoke('switch_identity', { nodeIdHex: btn.dataset.id }); await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
toast('Identity switched — reloading...'); toast('Device address switched — reloading...');
setTimeout(() => location.reload(), 1000); setTimeout(() => location.reload(), 1000);
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; } } catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
}); });
@ -3625,10 +3627,10 @@ async function loadIdentities() {
// Wire delete buttons // Wire delete buttons
list.querySelectorAll('.delete-id-btn').forEach(btn => { list.querySelectorAll('.delete-id-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Delete this identity? This cannot be undone.')) return; if (!confirm('Delete this device address? This cannot be undone.')) return;
try { try {
await invoke('delete_identity', { nodeIdHex: btn.dataset.id }); await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
toast('Identity deleted'); toast('Device address deleted');
loadIdentities(); loadIdentities();
} catch (e) { toast('Error: ' + e); } } catch (e) { toast('Error: ' + e); }
}); });
@ -3644,8 +3646,9 @@ $('#create-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default'; overlay.style.cursor = 'default';
overlay.innerHTML = ` overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center"> <div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3> <h3 style="color:#7fdbca;margin:0 0 0.5rem">New Device Address</h3>
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" /> <p style="font-size:0.72rem;color:#888;margin-bottom:0.75rem">A new QUIC network endpoint for this device. Your personas are unaffected.</p>
<input id="new-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center"> <div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button> <button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button> <button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
@ -3654,10 +3657,10 @@ $('#create-identity-btn').addEventListener('click', () => {
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.querySelector('#new-id-create').addEventListener('click', async () => { overlay.querySelector('#new-id-create').addEventListener('click', async () => {
const name = overlay.querySelector('#new-id-name').value.trim(); const name = overlay.querySelector('#new-id-name').value.trim();
if (!name) { toast('Name is required'); return; } if (!name) { toast('Label is required'); return; }
try { try {
const nodeId = await invoke('create_identity', { name }); const nodeId = await invoke('create_identity', { name });
toast(`Identity created: ${nodeId.substring(0, 12)}`); toast(`Device address created: ${nodeId.substring(0, 12)}`);
overlay.remove(); overlay.remove();
loadIdentities(); loadIdentities();
} catch (e) { toast('Error: ' + e); } } catch (e) { toast('Error: ' + e); }
@ -3672,10 +3675,10 @@ $('#import-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default'; overlay.style.cursor = 'default';
overlay.innerHTML = ` overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center"> <div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3> <h3 style="color:#7fdbca;margin:0 0 0.5rem">Import Device Address Key</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p> <p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas &mdash; use Export/Import personas above for that.</p>
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" /> <input id="import-id-key" type="text" placeholder="Device address key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" /> <input id="import-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center"> <div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button> <button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button> <button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
@ -3688,7 +3691,7 @@ $('#import-identity-btn').addEventListener('click', () => {
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; } if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
try { try {
const nodeId = await invoke('import_identity_key', { keyHex, name }); const nodeId = await invoke('import_identity_key', { keyHex, name });
toast(`Identity imported: ${nodeId.substring(0, 12)}`); toast(`Device address imported: ${nodeId.substring(0, 12)}`);
overlay.remove(); overlay.remove();
loadIdentities(); loadIdentities();
} catch (e) { toast('Error: ' + e); } } catch (e) { toast('Error: ' + e); }
@ -4406,7 +4409,7 @@ async function init() {
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p> <p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
<div style="display:flex;flex-direction:column;gap:0.75rem"> <div style="display:flex;flex-direction:column;gap:0.75rem">
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button> <button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button> <button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import from another device</button>
</div> </div>
</div>`; </div>`;
document.body.appendChild(chooser); document.body.appendChild(chooser);

View file

@ -236,7 +236,7 @@
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem;line-height:1.5"> <p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem;line-height:1.5">
<strong style="color:#7fdbca">Personas</strong> are who you are to peers &mdash; the keys you post and message with. Most people only need one. To move your account to a new device, you <em>export your personas</em> from this device and <em>import them</em> on the new one. <strong style="color:#7fdbca">Personas</strong> are who you are to peers &mdash; the keys you post and message with. Most people only need one. To move your account to a new device, you <em>export your personas</em> from this device and <em>import them</em> on the new one.
<br><br> <br><br>
<strong style="color:#888">Identities</strong> below are this device's own network address &mdash; usually not what you want to move. Leave them alone unless you know why you're touching them. <strong style="color:#888">Device Address</strong> below is this device's own network endpoint &mdash; usually not what you want to move. Leave it alone unless you know why you're touching it.
</p> </p>
</div> </div>
@ -259,14 +259,14 @@
</div> </div>
<div class="section-card" style="text-align:center"> <div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Identities (advanced)</h3> <h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Device Address (advanced)</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem"> <p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
This device's network address. Changing this is rarely useful &mdash; it lets you move the device's QUIC endpoint, NOT your posting identity. This device's network endpoint &mdash; the QUIC address peers use to reach you. Changing this rotates the device's network identifier but does NOT change your posting identity (personas). Rarely useful.
</p> </p>
<div id="identities-list" style="margin-bottom:0.5rem"></div> <div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap"> <div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Identity</button> <button id="create-identity-btn" class="btn btn-ghost btn-sm">New Device Address</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Key</button> <button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Address Key</button>
</div> </div>
</div> </div>
@ -316,7 +316,7 @@
<div class="section-card"> <div class="section-card">
<h3>Danger Zone</h3> <h3>Danger Zone</h3>
<p class="empty-hint">Delete all local data. Identity key preserved.</p> <p class="empty-hint">Delete all local data. Device address key preserved.</p>
<button id="reset-data-btn" class="btn btn-danger btn-full">Reset All Data</button> <button id="reset-data-btn" class="btn btn-danger btn-full">Reset All Data</button>
</div> </div>
</section> </section>