Profile-post backfill + prune disposable first-run persona on import
Two bugs the v0.6.2 Discover story was going to expose: 1) Many named personas don't have a profile post yet, so the planned profile-post-driven Discover listing would show them as headless. Existing personas predate the Phase 2d profile-post primitive, and imported personas arrive with a display_name but no matching post. 2) Fresh install always generates a blank disposable persona before the user can pick fresh-vs-import. If the user picks import, that blank persona lingers forever — visible in the Personas list, a potential default, confusing. Fixes: Profile-post backfill (node.rs): - New `Node::backfill_profile_posts_for_named_personas`: scans posting identities, skips unnamed or already-covered ones, and emits a signed profile post at the persona's own `created_at` timestamp. Run once from `Node::open_with_bind` after migrations. Idempotent via the new `Storage::has_profile_post_by_author` check. Chronology is preserved (post timestamp matches persona creation), so a later genuine profile update the user authors always wins the `> old_ts` monotonicity check on receivers. - `Node::create_posting_identity(name)`: if `name` is non-empty, emits the profile post inline so new named personas are Discover-able the moment they're created. Uses the new `publish_profile_post_as` helper, which signs with the persona's own secret (not the default posting secret) and propagates via `update_neighbor_manifests_as`. First-run persona marker + targeted prune (node.rs + storage.rs + import): - `Node::open_with_bind`'s auto-gen block now also writes `first_run_auto_persona_id = <hex>` into the settings kv. This marks the specific disposable persona from the fresh-install flow; later prune logic uses this id, not a "any empty persona" rule, so manual empty personas are never touched. - `Node::try_prune_first_run_auto_persona`: deletes the marked id iff all four gates pass — still exists, no display_name, no authored posts, no authored reactions/comments, and it's no longer the current default. Any one failure → clear the marker and keep the persona. New storage helpers `has_any_post_by_author`, `has_any_engagement_by_author` back the check. - `set_profile` clears the marker when a non-empty name lands on the marked persona (user claimed it). - `storage.delete_setting(key)` — new one-line helper. - `import_as_personas_cmd` in tauri-app calls the prune after import completes; the cmd's return message reports "(cleared blank starter persona)" when it fires. - New `get_first_run_auto_persona_id` Tauri command so the frontend can filter the blank persona out of the Personas list while it still exists. - Frontend `loadPersonas` filters the marker id out of `personasCache` before rendering. Tests: 124 / 124 core tests pass.
This commit is contained in:
parent
d990da5bda
commit
e74bd4e6c6
4 changed files with 308 additions and 6 deletions
|
|
@ -133,6 +133,11 @@ impl Node {
|
|||
created_at: now,
|
||||
})?;
|
||||
s.set_default_posting_id(&nid)?;
|
||||
// Mark this as the disposable auto-gen persona from the
|
||||
// fresh-install flow. If the user subsequently imports, we
|
||||
// prune this id iff it's still pristine (no name, no posts,
|
||||
// no engagement). See `try_prune_first_run_auto_persona`.
|
||||
let _ = s.set_setting("first_run_auto_persona_id", &hex::encode(nid));
|
||||
info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)");
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +228,14 @@ impl Node {
|
|||
budget_last_reset_ms,
|
||||
};
|
||||
|
||||
// Startup backfill: any named persona without a profile post gets
|
||||
// one synthesized at its own `created_at`. Makes legacy / imported
|
||||
// named personas Discover-able without requiring a manual rename.
|
||||
// Swallow errors — backfill is best-effort; no reason to block init.
|
||||
if let Err(e) = node.backfill_profile_posts_for_named_personas().await {
|
||||
warn!(error = %e, "Profile-post backfill failed; continuing init");
|
||||
}
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
|
|
@ -649,16 +662,213 @@ impl Node {
|
|||
let identity = crate::types::PostingIdentity {
|
||||
node_id,
|
||||
secret_seed: seed,
|
||||
display_name,
|
||||
display_name: display_name.clone(),
|
||||
created_at: now,
|
||||
};
|
||||
{
|
||||
let s = self.storage.get().await;
|
||||
s.upsert_posting_identity(&identity)?;
|
||||
// Auto-follow this persona so its own posts reach its own feed.
|
||||
s.add_follow(&node_id)?;
|
||||
}
|
||||
|
||||
// If the user supplied a non-empty display name at creation time,
|
||||
// emit a signed profile post immediately. This makes the persona
|
||||
// Discover-able by other nodes even before the user posts anything
|
||||
// under it. `publish_profile_post_as` signs with the persona's own
|
||||
// secret (not the default posting secret) and propagates via the
|
||||
// normal neighbor-manifest CDN path.
|
||||
if !display_name.is_empty() {
|
||||
if let Err(e) = self.publish_profile_post_as(&node_id, &seed, &display_name, "", None).await {
|
||||
warn!(persona = hex::encode(node_id), error = %e, "Failed to emit initial profile post for new persona");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Build + store + propagate a `VisibilityIntent::Profile` post authored
|
||||
/// by the given persona (not the default posting identity). Extracted so
|
||||
/// both `create_posting_identity` and the startup backfill can use it.
|
||||
async fn publish_profile_post_as(
|
||||
&self,
|
||||
posting_id: &NodeId,
|
||||
posting_secret: &[u8; 32],
|
||||
display_name: &str,
|
||||
bio: &str,
|
||||
avatar_cid: Option<[u8; 32]>,
|
||||
) -> anyhow::Result<()> {
|
||||
let profile_post = crate::profile::build_profile_post(
|
||||
posting_id,
|
||||
posting_secret,
|
||||
display_name,
|
||||
bio,
|
||||
avatar_cid,
|
||||
);
|
||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||
let timestamp_ms = profile_post.timestamp_ms;
|
||||
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
&profile_post_id,
|
||||
&profile_post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Profile,
|
||||
)?;
|
||||
crate::profile::apply_profile_post_if_applicable(
|
||||
&*storage,
|
||||
&profile_post,
|
||||
Some(&VisibilityIntent::Profile),
|
||||
)?;
|
||||
}
|
||||
self.update_neighbor_manifests_as(
|
||||
posting_id,
|
||||
posting_secret,
|
||||
&profile_post_id,
|
||||
timestamp_ms,
|
||||
).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backfill: for every posting identity with a non-empty display_name
|
||||
/// that doesn't already have a `VisibilityIntent::Profile` post,
|
||||
/// synthesize one so the persona becomes Discover-able. Uses the
|
||||
/// persona's `created_at` as the post timestamp so chronology matches
|
||||
/// the persona's history.
|
||||
///
|
||||
/// Called once from `Node::open_with_bind` after all migrations. Safe to
|
||||
/// re-run: the `has_profile_post_by_author` check makes it idempotent.
|
||||
async fn backfill_profile_posts_for_named_personas(&self) -> anyhow::Result<usize> {
|
||||
let personas = {
|
||||
let storage = self.storage.get().await;
|
||||
storage.list_posting_identities()?
|
||||
};
|
||||
let mut backfilled = 0usize;
|
||||
for pi in personas {
|
||||
if pi.display_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
if storage.has_profile_post_by_author(&pi.node_id)? {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Build a profile post whose internal timestamp equals the
|
||||
// persona's created_at. This stops the backfilled post from
|
||||
// later losing a monotonicity check against a real profile
|
||||
// update the user authors in the future.
|
||||
let signature = crate::crypto::sign_profile(
|
||||
&pi.secret_seed,
|
||||
&pi.display_name,
|
||||
"",
|
||||
&None,
|
||||
pi.created_at,
|
||||
);
|
||||
let content = crate::types::ProfilePostContent {
|
||||
display_name: pi.display_name.clone(),
|
||||
bio: String::new(),
|
||||
avatar_cid: None,
|
||||
timestamp_ms: pi.created_at,
|
||||
signature,
|
||||
};
|
||||
let post = Post {
|
||||
author: pi.node_id,
|
||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||
attachments: vec![],
|
||||
timestamp_ms: pi.created_at,
|
||||
};
|
||||
let post_id = crate::content::compute_post_id(&post);
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
&post_id,
|
||||
&post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Profile,
|
||||
)?;
|
||||
crate::profile::apply_profile_post_if_applicable(
|
||||
&*storage,
|
||||
&post,
|
||||
Some(&VisibilityIntent::Profile),
|
||||
)?;
|
||||
}
|
||||
self.update_neighbor_manifests_as(
|
||||
&pi.node_id,
|
||||
&pi.secret_seed,
|
||||
&post_id,
|
||||
pi.created_at,
|
||||
).await;
|
||||
backfilled += 1;
|
||||
}
|
||||
if backfilled > 0 {
|
||||
info!(count = backfilled, "Backfilled profile posts for named personas without one");
|
||||
}
|
||||
Ok(backfilled)
|
||||
}
|
||||
|
||||
/// If the fresh-install auto-gen persona is still pristine (no name, no
|
||||
/// posts, no engagement, not the current default), delete it. Called at
|
||||
/// the end of `import_as_personas` so an "import as persona" flow
|
||||
/// doesn't leave an orphan blank persona around.
|
||||
///
|
||||
/// Any of four sticky conditions prevents deletion:
|
||||
/// - the user set a display_name
|
||||
/// - the user authored a post under this persona
|
||||
/// - the user authored a reaction or comment under this persona
|
||||
/// - this persona is still the current default (no imported identity
|
||||
/// replaced it)
|
||||
pub async fn try_prune_first_run_auto_persona(&self) -> anyhow::Result<bool> {
|
||||
let (marker_hex, current_default) = {
|
||||
let s = self.storage.get().await;
|
||||
let m = s.get_setting("first_run_auto_persona_id")?;
|
||||
let d = s.get_default_posting_id()?;
|
||||
(m, d)
|
||||
};
|
||||
let Some(hex_str) = marker_hex else { return Ok(false); };
|
||||
let Ok(marker_id) = crate::parse_node_id_hex(&hex_str) else {
|
||||
// Corrupt marker — clear and move on.
|
||||
let s = self.storage.get().await;
|
||||
let _ = s.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let storage = self.storage.get().await;
|
||||
|
||||
// Still the default? Import didn't replace it — keep.
|
||||
if current_default == Some(marker_id) {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
}
|
||||
// Persona still exists?
|
||||
let Some(pi) = storage.get_posting_identity(&marker_id)? else {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
};
|
||||
// User named it? Keep.
|
||||
if !pi.display_name.is_empty() {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
}
|
||||
// User authored anything under it? Keep.
|
||||
if storage.has_any_post_by_author(&marker_id)? {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
}
|
||||
if storage.has_any_engagement_by_author(&marker_id)? {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
return Ok(false);
|
||||
}
|
||||
// All gates passed — persona is definitively pristine and no longer
|
||||
// the default. Safe to drop.
|
||||
storage.delete_posting_identity(&marker_id)?;
|
||||
let _ = storage.remove_follow(&marker_id);
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
info!(persona = %hex_str, "Pruned pristine fresh-install persona after import");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Delete a posting identity. Refuses to delete the currently default
|
||||
/// posting identity unless the caller has already switched the default.
|
||||
pub async fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||
|
|
@ -1263,6 +1473,8 @@ impl Node {
|
|||
let timestamp_ms = profile_post.timestamp_ms;
|
||||
|
||||
// Store post with VisibilityIntent::Profile + apply (upserts profile row).
|
||||
// If naming the fresh-install auto-gen persona with a non-empty name,
|
||||
// clear the disposability marker — user has claimed this persona.
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
|
|
@ -1276,6 +1488,13 @@ impl Node {
|
|||
&profile_post,
|
||||
Some(&VisibilityIntent::Profile),
|
||||
)?;
|
||||
if !display_name.is_empty() {
|
||||
if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") {
|
||||
if marker == hex::encode(posting_id) {
|
||||
let _ = storage.delete_setting("first_run_auto_persona_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate via neighbor-manifest header diffs like any other post.
|
||||
|
|
|
|||
|
|
@ -1677,6 +1677,11 @@ impl Storage {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_setting(&self, key: &str) -> anyhow::Result<()> {
|
||||
self.conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Seen engagement tracking ---
|
||||
|
||||
/// Get the seen engagement counts for a post (react_count, comment_count).
|
||||
|
|
@ -2662,6 +2667,49 @@ impl Storage {
|
|||
}
|
||||
|
||||
/// Find posts authored by us that were intended for a specific circle.
|
||||
/// True if the given author has at least one `VisibilityIntent::Profile`
|
||||
/// post stored. Used by the startup backfill to avoid re-emitting a
|
||||
/// profile post for personas that already have one.
|
||||
pub fn has_profile_post_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts
|
||||
WHERE author = ?1 AND visibility_intent = '\"Profile\"'
|
||||
LIMIT 1",
|
||||
params![author.as_slice()],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// True if `author` has any row in the posts table (any intent / visibility).
|
||||
/// Used to decide whether the fresh-install auto-gen persona has been
|
||||
/// "used" and should therefore NOT be pruned on import.
|
||||
pub fn has_any_post_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM posts WHERE author = ?1 LIMIT 1",
|
||||
params![author.as_slice()],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// True if `author` has authored a reaction or a comment. Same purpose as
|
||||
/// `has_any_post_by_author` but for the engagement tables.
|
||||
pub fn has_any_engagement_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
|
||||
let reacts: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM reactions WHERE reactor = ?1 LIMIT 1",
|
||||
params![author.as_slice()],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
if reacts > 0 { return Ok(true); }
|
||||
let comments: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM comments WHERE author = ?1 LIMIT 1",
|
||||
params![author.as_slice()],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
Ok(comments > 0)
|
||||
}
|
||||
|
||||
pub fn find_posts_by_circle_intent(
|
||||
&self,
|
||||
circle_name: &str,
|
||||
|
|
|
|||
|
|
@ -1416,6 +1416,20 @@ async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String>
|
|||
.unwrap_or_else(|| "stable".to_string()))
|
||||
}
|
||||
|
||||
/// Return the hex id of the disposable fresh-install persona (if it still
|
||||
/// exists and hasn't been claimed). Frontend uses this to filter the blank
|
||||
/// starter out of the Personas list so the user never sees a "ghost"
|
||||
/// persona waiting between install and their first-run choice.
|
||||
/// Returns an empty string when there's no disposable persona to hide.
|
||||
#[tauri::command]
|
||||
async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result<String, String> {
|
||||
let node = get_node(&state).await;
|
||||
let storage = node.storage.get().await;
|
||||
Ok(storage.get_setting("first_run_auto_persona_id")
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Persist the user's preferred update channel.
|
||||
#[tauri::command]
|
||||
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
|
||||
|
|
@ -2804,7 +2818,17 @@ async fn import_as_personas_cmd(
|
|||
&node.storage,
|
||||
&node.blob_store,
|
||||
).await.map_err(|e| e.to_string())?;
|
||||
Ok(result.message)
|
||||
// Drop the disposable fresh-install persona if it's still pristine.
|
||||
// Safe-by-construction: the helper bails unless ALL of [no name, no
|
||||
// posts, no engagement, no longer the default] hold.
|
||||
let pruned = node.try_prune_first_run_auto_persona().await
|
||||
.unwrap_or(false);
|
||||
let msg = if pruned {
|
||||
format!("{} (cleared blank starter persona)", result.message)
|
||||
} else {
|
||||
result.message
|
||||
};
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -3023,6 +3047,7 @@ pub fn run() {
|
|||
get_update_channel,
|
||||
set_update_channel,
|
||||
open_url_external,
|
||||
get_first_run_auto_persona_id,
|
||||
list_connections,
|
||||
worm_lookup,
|
||||
list_social_routes,
|
||||
|
|
|
|||
|
|
@ -3521,6 +3521,16 @@ async function loadPersonas() {
|
|||
personasCache = [];
|
||||
console.error('list_posting_identities:', e);
|
||||
}
|
||||
// Filter out the fresh-install disposable persona. It's auto-created
|
||||
// before the user has picked fresh-vs-import, and will be pruned on
|
||||
// import if still pristine. Hiding it from the Personas UI stops the
|
||||
// user from seeing a ghost "blank" persona during the first-run flow.
|
||||
try {
|
||||
const hiddenId = await invoke('get_first_run_auto_persona_id');
|
||||
if (hiddenId) {
|
||||
personasCache = personasCache.filter(p => p.nodeId !== hiddenId);
|
||||
}
|
||||
} catch (_) {}
|
||||
renderPersonasList();
|
||||
renderComposePersonaPicker();
|
||||
renderFeedPersonaFilter();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue