From d990da5bdae76da9f19250c71b2ef6a9c1ab2300 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 23 Apr 2026 08:11:11 -0400 Subject: [PATCH] Fix: imported DMs silently hidden from Messages tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs ganging up: 1) Import ignored the intent field. `ExportedPost.intent` was always serialized on export but the import path hardcoded every encrypted post to `VisibilityIntent::Friends` (import.rs:308-311), discarding whatever `ep.intent` said. DMs got misfiled as Friends. 2) The Messages tab filter only surfaces posts whose `intentKind` is `direct` (or `unknown` with the right visibility shape). Posts with `intentKind = friends` skip the filter — DMs became invisible after an "everything" import, even though the rows were in the DB and the per-persona decrypt loop would have resolved them to plaintext. Fixes: - `parse_exported_intent(raw, vis)` in import.rs: parses the Debug-format intent string the export writes, handling Public / Friends / Circle / Direct / Control / Profile / Announcement / GroupKeyDistribute. For `Direct`, recovers the recipient list from `PostVisibility::Encrypted` since the Debug format for `Vec` isn't cleanly parseable. - Heuristic fallback when the export carries no intent (pre-v0.6.1 source DBs, where the intent column wasn't populated): Encrypted posts with <=3 recipients are classified as `Direct`, larger recipient lists stay `Friends`. DMs typically wrap to 1-2 people; Friends wraps to every public follow. - `StagedImport.posts` tuple grows an `intent` slot; the store step uses the parsed/inferred intent instead of the hardcoded default. One-time startup migration: - Sweeps existing posts where `visibility_intent = "Friends"` and visibility is Encrypted with <=3 recipients; rewrites to Direct. Guarded by `mig_import_dm_fixup_v1` settings key so it runs once per DB. Handles already-imported corrupt state so users don't need to re-import. Tests: 124 / 124 core tests pass. --- crates/core/src/import.rs | 96 +++++++++++++++++++++++++++++++++----- crates/core/src/storage.rs | 59 +++++++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index ce63862..ed12d2a 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -15,7 +15,75 @@ use crate::blob::BlobStore; use crate::content::compute_post_id; use crate::export::{ExportManifest, ExportedPost}; use crate::storage::StoragePool; -use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity}; +use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity, VisibilityIntent}; + +/// Parse the Debug-formatted intent string that the export writes +/// (`format!("{:?}", visibility_intent)`) back into a `VisibilityIntent`. +/// +/// Falls back to a visibility-shape heuristic when the export carries no +/// intent (pre-v0.6.1 source DBs never populated the intent column, so the +/// export writes `None`). Without this fallback, old exports imported as a +/// persona would bucket every encrypted post under `Friends` and DMs would +/// silently disappear from the Messages tab. +fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityIntent { + // Try a clean prefix match on the Debug form first. + if let Some(s) = raw { + let s = s.trim(); + if s == "Public" { return VisibilityIntent::Public; } + if s == "Friends" { return VisibilityIntent::Friends; } + if s.starts_with("Circle(") { + // `Circle("name")` — pull the quoted string. + if let Some(q) = s.find('"') { + if let Some(end) = s.rfind('"') { + if end > q { + let name = &s[q + 1..end]; + return VisibilityIntent::Circle(name.to_string()); + } + } + } + return VisibilityIntent::Circle(String::new()); + } + if s.starts_with("Direct(") { + // Debug form of Vec is messy to parse reliably. Recover + // the recipient list from the PostVisibility if available; the + // set is semantically equivalent (both derive from the same + // wrapping list). + if let PostVisibility::Encrypted { recipients } = vis { + let ids: Vec = recipients.iter().map(|wk| wk.recipient).collect(); + return VisibilityIntent::Direct(ids); + } + return VisibilityIntent::Direct(vec![]); + } + if s == "Control" { return VisibilityIntent::Control; } + if s == "Profile" { return VisibilityIntent::Profile; } + if s == "Announcement" { return VisibilityIntent::Announcement; } + if s == "GroupKeyDistribute" { return VisibilityIntent::GroupKeyDistribute; } + } + // No intent recorded — infer from the visibility shape. + match vis { + PostVisibility::Public => VisibilityIntent::Public, + PostVisibility::Encrypted { recipients } => { + // Heuristic: DMs typically wrap to 1-2 people (recipient + self); + // Friends posts wrap to every public follow (usually many). + // Use 3 as a threshold that treats small-group conversations as + // Direct while letting broadcast-to-friends stay Friends. + // + // Rationale: the pre-intent export was built before we could + // distinguish these cases, so we prefer a best-effort partition + // that gets DMs into the Messages tab (where the user will look + // for them) rather than filing them under Friends. + if recipients.len() <= 3 { + VisibilityIntent::Direct(recipients.iter().map(|wk| wk.recipient).collect()) + } else { + VisibilityIntent::Friends + } + } + PostVisibility::GroupEncrypted { .. } => { + // No way to recover the circle name from wire visibility alone. + VisibilityIntent::Circle(String::new()) + } + } +} /// Extract posting_identities.json from an export ZIP and upsert each entry /// into storage. Called during import so multi-persona users restore all @@ -126,8 +194,8 @@ struct StagedImport { /// Posting identities to register (includes the bundle's identity.key and /// any entries from posting_identities.json). Deduped by node_id. posting_identities: Vec, - /// Posts in the form (post_id, Post, PostVisibility, blobs). - posts: Vec<(crate::types::PostId, Post, PostVisibility, Vec<(Attachment, Vec)>)>, + /// Posts in the form (post_id, Post, PostVisibility, intent, blobs). + posts: Vec<(crate::types::PostId, Post, PostVisibility, crate::types::VisibilityIntent, Vec<(Attachment, Vec)>)>, /// Follows to add to current identity's follow list. follows: Vec, /// Profiles to upsert (keyed by their own node_id, which becomes one of @@ -220,6 +288,14 @@ pub async fn import_as_personas( timestamp_ms: ep.timestamp_ms, }; + // Preserve the original visibility intent from the export. + // Export stored it as a Debug-format string + // (`format!("{:?}", intent)`), so we parse a prefix match. + // When the export predates intent storage (source DB never + // populated `visibility_intent`), fall back to a heuristic + // based on the visibility shape. + let intent = parse_exported_intent(ep.intent.as_deref(), &vis); + // Read attached blobs let mut blobs = Vec::new(); for att in &attachments { @@ -230,7 +306,7 @@ pub async fn import_as_personas( blobs.push((att.clone(), data)); } } - staged_posts.push((post_id, post, vis, blobs)); + staged_posts.push((post_id, post, vis, intent, blobs)); } // follows.json (optional) @@ -296,20 +372,16 @@ pub async fn import_as_personas( } // Posts + blobs. Content keeps its original post_id, author, signatures. - for (post_id, post, vis, blobs) in &staged.posts { + for (post_id, post, vis, intent, blobs) in &staged.posts { let s = storage.get().await; if s.get_post(post_id).ok().flatten().is_some() { skipped_posts += 1; continue; } if crate::content::verify_post_id(post_id, post) { - // Bundle doesn't carry intent — fall back to Public for public posts, - // Friends for encrypted (closest match for re-surfacing via circles). - let intent = match &vis { - PostVisibility::Public => crate::types::VisibilityIntent::Public, - _ => crate::types::VisibilityIntent::Friends, - }; - s.store_post_with_intent(post_id, post, vis, &intent)?; + // Intent was parsed from the export (or heuristic-inferred when + // the export predates intent storage). See `parse_exported_intent`. + s.store_post_with_intent(post_id, post, vis, intent)?; imported_posts += 1; } else { warn!(post_id = hex::encode(post_id), "Skipping post with invalid signature during import"); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 304e6a0..fe4041e 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -674,6 +674,65 @@ impl Storage { "CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" )?; + // v0.6.2 import bugfix: the import pipeline up through f b0e293 + // filed EVERY encrypted post as VisibilityIntent::Friends, including + // DMs. The Messages tab in the UI only shows posts whose intent is + // `Direct` (or `unknown` with the right visibility shape), so DMs + // imported from an "everything" bundle silently disappeared. + // + // This one-time migration reclassifies short-recipient encrypted + // posts filed as Friends back to Direct, using the same small-list + // heuristic as the import code (recipients <= 3 → Direct). A guard + // flag in the settings kv ensures this sweep runs once per DB. + let imported_dm_fixup_done = self.get_setting("mig_import_dm_fixup_v1")?.is_some(); + if !imported_dm_fixup_done { + let mut fixed = 0i64; + let mut stmt = self.conn.prepare( + "SELECT id, visibility FROM posts + WHERE visibility_intent = '\"Friends\"' + AND visibility LIKE '%Encrypted%'" + )?; + let rows: Vec<(Vec, String)> = stmt + .query_map([], |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + for (id_bytes, vis_json) in rows { + // Parse out the recipients array from the Encrypted + // visibility. Short recipient list (≤ 3) = likely DM. + let vis: Result = serde_json::from_str(&vis_json); + let should_fix = match vis { + Ok(PostVisibility::Encrypted { recipients }) => recipients.len() <= 3, + _ => false, + }; + if !should_fix { continue; } + // Build a Direct-intent value with the recipient list recovered + // from the visibility — semantically equivalent to what the + // user's original send-side intent would have been. + let vis_parsed: PostVisibility = match serde_json::from_str(&vis_json) { + Ok(v) => v, + Err(_) => continue, + }; + let recipients: Vec = match vis_parsed { + PostVisibility::Encrypted { recipients } => { + recipients.iter().map(|wk| wk.recipient).collect() + } + _ => continue, + }; + let intent = crate::types::VisibilityIntent::Direct(recipients); + let intent_json = serde_json::to_string(&intent).unwrap_or_default(); + let n = self.conn.execute( + "UPDATE posts SET visibility_intent = ?1 WHERE id = ?2", + params![intent_json, id_bytes], + )?; + fixed += n as i64; + } + self.set_setting("mig_import_dm_fixup_v1", &fixed.to_string())?; + if fixed > 0 { + tracing::info!(count = fixed, "Migrated imported DMs from Friends-intent to Direct-intent"); + } + } + // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'"