Fix: imported DMs silently hidden from Messages tab
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<NodeId>` 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.
This commit is contained in:
parent
fb0e293e2d
commit
d990da5bda
2 changed files with 143 additions and 12 deletions
|
|
@ -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<NodeId> 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<NodeId> = 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<PostingIdentity>,
|
||||
/// Posts in the form (post_id, Post, PostVisibility, blobs).
|
||||
posts: Vec<(crate::types::PostId, Post, PostVisibility, Vec<(Attachment, Vec<u8>)>)>,
|
||||
/// Posts in the form (post_id, Post, PostVisibility, intent, blobs).
|
||||
posts: Vec<(crate::types::PostId, Post, PostVisibility, crate::types::VisibilityIntent, Vec<(Attachment, Vec<u8>)>)>,
|
||||
/// Follows to add to current identity's follow list.
|
||||
follows: Vec<NodeId>,
|
||||
/// 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");
|
||||
|
|
|
|||
|
|
@ -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<u8>, String)> = stmt
|
||||
.query_map([], |row| Ok((row.get::<_, Vec<u8>>(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<PostVisibility, _> = 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<NodeId> = 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'"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue