From 89cd1c76bbd2a9c61aeb44e9ac9c24ff853f57ab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 17:13:58 -0400 Subject: [PATCH] core, web: pin, archive and reorder a note someone shared with you Schema v12 adds notes.state_at: a recipient's own pin, archive and order are stamped there instead of on updated_at, which stays the text's time. Push sends them only to a server advertising `shared_state` (push::Accepts), a view share included; the first pull at that level starts the feed over once so held copies drop their owner's pins. The client speaks protocol 7 and lists `shares` and `shared_state` among the features a server may lack. The web card and editor offer pin, archive and drag on shared notes; share and trash stay the owner's, and the board's trash key skips notes you don't own. Co-Authored-By: Claude Opus 5.5 --- core/src/local/schema.rs | 20 ++- core/src/local/store.rs | 117 +++++++++++++++--- core/src/sync/compat.rs | 15 ++- core/src/sync/engine.rs | 19 ++- core/src/sync/push.rs | 161 +++++++++++++++++-------- core/src/sync/state.rs | 44 +++++-- frontend/src/components/NoteCard.vue | 11 +- frontend/src/components/NoteEditor.vue | 4 +- frontend/src/views/BoardView.vue | 4 +- 9 files changed, 304 insertions(+), 91 deletions(-) diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 3c4a295..38ade58 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -311,6 +311,20 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT; ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0; "#; +// v12 (#5176): a shared note's pin, archive and position are this account's own. +// +// `state_at` is when they last changed here, on a note someone else owns. It is kept +// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy +// whose text is behind the owner's must not make that text look like the newer +// edit when it is pushed. Null on our own notes, where `updated_at` covers both. +// +// `sync_state.shares_synced` now holds a level rather than a flag (see +// `state::SHARED_STATE`), and a device reaching this level pulls everything once +// more: the shared notes it holds carry their owner's pins until it does. +const SCHEMA_V12: &str = r#" +ALTER TABLE notes ADD COLUMN state_at TEXT; +"#; + pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; @@ -358,6 +372,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V11)?; conn.execute_batch("PRAGMA user_version = 11;")?; } + if version < 12 { + conn.execute_batch(SCHEMA_V12)?; + conn.execute_batch("PRAGMA user_version = 12;")?; + } Ok(()) } @@ -472,7 +490,7 @@ mod tests { let version: i64 = conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .expect("version"); - assert_eq!(version, 11); + assert_eq!(version, 12); } /// Every attachment that predates v10 came down the feed, so it is already on the diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 68b06a9..d3c22e4 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -193,12 +193,14 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { // ---- notes shared with this account (#5175) --------------------------------- // // A note someone else owns arrives with `permission` `edit` (its text may change -// here) or `view` (nothing may). Everything else about it — pin, archive, trash, -// reminders, labels, files — stays its owner's. The server enforces the same split; +// here) or `view` (it may not). Its pin, archive and place on this board are this +// account's own either way (#5176). Everything else about it — trash, reminders, +// labels, files — stays its owner's. The server enforces the same split; // refusing here as well tells the person at once, instead of letting an edit be made, // queued, and then thrown away by the next sync. -pub const VIEW_ONLY: &str = "This note was shared with you to view, so it can't be changed here."; +pub const VIEW_ONLY: &str = + "This note was shared with you to view, so its text can't be changed here."; pub const OWNER_ONLY: &str = "Only the note's owner can change that."; /// A refusal carrying words to show. Wrapped so it travels as a `rusqlite::Error` @@ -233,7 +235,8 @@ fn require_text(conn: &Connection, id: &str) -> rusqlite::Result { Ok(permission) } -/// Anything but the text: only the owner. +/// Anything but the text and this account's own pin, archive and place: only the +/// owner. fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> { match permission_of(conn, id)?.as_str() { "owner" => Ok(()), @@ -242,6 +245,20 @@ fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> { } } +/// This account's own pin, archive or place changed on a note someone else owns. +/// Stamped apart from `updated_at`, which is the time of the note's text; see +/// SCHEMA_V12 for why the two must not share a clock. +fn touch_own_state(conn: &Connection, id: &str) -> rusqlite::Result<()> { + conn.execute( + "UPDATE notes SET state_at = ?1, dirty = 1 WHERE id = ?2", + params![now(), id], + )?; + Ok(()) +} + +/// The fields of a note anyone it is shared with may set: their own. +const OWN_FIELDS: [&str; 2] = ["pinned", "archived"]; + fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> { conn.execute( "UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2", @@ -596,9 +613,18 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re let obj = changes .as_object() .ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?; - let permission = require_text(conn, id)?; + let text = obj.contains_key("body"); + let permission = if text { + require_text(conn, id)? + } else { + permission_of(conn, id)? + }; let owned = permission == "owner"; - if !owned && obj.keys().any(|k| k != "body") { + if !owned + && obj + .keys() + .any(|k| k != "body" && !OWN_FIELDS.contains(&k.as_str())) + { return Err(refuse(OWNER_ONLY)); } @@ -656,7 +682,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re } } - touch(conn, id)?; + if owned || text { + touch(conn, id)?; + } + if !owned && OWN_FIELDS.iter().any(|k| obj.contains_key(*k)) { + touch_own_state(conn, id)?; + } load_note(conn, id) } @@ -895,11 +926,16 @@ pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> { let total = ordered_ids.len() as i64; + let at = now(); for (i, id) in ordered_ids.iter().enumerate() { - // Order is the owner's; a shared note keeps its place on their board. + // A note someone shared with us moves on this account's board only (#5176), + // stamped as its own state rather than as an edit. conn.execute( - "UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2 AND permission = 'owner'", - params![total - i as i64, id], + "UPDATE notes + SET position = ?1, dirty = 1, + state_at = CASE WHEN permission = 'owner' THEN state_at ELSE ?3 END + WHERE id = ?2", + params![total - i as i64, id, at], )?; } Ok(()) @@ -1340,17 +1376,21 @@ mod tests { assert!(dirty(&conn, "n")); add_item(&conn, "n", "eggs").expect("an item is text"); - let pinned = update_note(&conn, "n", &json!({ "pinned": true })) - .err() - .expect("refused"); - assert_eq!(pinned.to_string(), OWNER_ONLY); - assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err()); + let reminded = update_note( + &conn, + "n", + &json!({ "remind_at": "2026-02-01T00:00:00.000Z" }), + ) + .err() + .expect("refused"); + assert_eq!(reminded.to_string(), OWNER_ONLY); + assert!(update_note(&conn, "n", &json!({ "body": "x", "recurrence": "daily" })).is_err()); assert!(trash(&conn, "n").is_err()); assert!(complete_reminder(&conn, "n").is_err()); } #[test] - fn shared_notes_stay_out_of_trash_reminders_and_reordering() { + fn shared_notes_stay_out_of_trash_and_reminders() { let conn = db(); shared(&conn, "theirs", "edit"); conn.execute( @@ -1367,9 +1407,52 @@ mod tests { .expect("restored"); assert!(reminders(&conn).expect("reminders").is_empty()); assert!(due_reminders(&conn, i64::MAX).expect("due").is_empty()); + } + #[test] + fn a_recipient_pins_archives_and_orders_their_own_copy() { + let conn = db(); + shared(&conn, "theirs", "view"); + let state_at = |conn: &Connection| -> Option { + conn.query_row("SELECT state_at FROM notes WHERE id = 'theirs'", [], |r| { + r.get(0) + }) + .expect("state_at") + }; + + let pinned = update_note( + &conn, + "theirs", + &json!({ "pinned": true, "archived": true }), + ) + .expect("a view share may still be pinned"); + assert!(pinned.pinned && pinned.archived); + assert!(dirty(&conn, "theirs")); + // Stamped as this account's own state, never as an edit to the owner's text. + assert_eq!( + pinned.updated_at.as_deref(), + Some("2026-01-01T00:00:00.000Z") + ); + let first = state_at(&conn).expect("stamped"); + + conn.execute("UPDATE notes SET dirty = 0, state_at = NULL", []) + .expect("synced"); reorder(&conn, &["theirs".to_string()]).expect("reorder"); - assert!(!dirty(&conn, "theirs"), "order is the owner's"); + assert!(dirty(&conn, "theirs")); + assert!(state_at(&conn).is_some_and(|at| at >= first)); + assert_eq!(get_note(&conn, "theirs").expect("get").position, 1); + + // An own note's order is still its own edit, with no separate stamp. + let mine = note(&conn, "mine"); + reorder(&conn, &[mine.id.clone()]).expect("reorder mine"); + let own: Option = conn + .query_row( + "SELECT state_at FROM notes WHERE id = ?1", + [&mine.id], + |r| r.get(0), + ) + .expect("own state_at"); + assert!(own.is_none()); } #[test] diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index 3ce2495..a8a73c5 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -25,7 +25,10 @@ use std::sync::OnceLock; /// on [`MIN_SERVER_PROTOCOL_VERSION`]. /// v5 (#5168): files attached here upload, and removed attachments and previews are /// pushed. Additive: both go only to a server advertising `attachment_sync`. -pub const CLIENT_PROTOCOL_VERSION: u32 = 5; +/// v6 (#5175): notes shared with this account, asked for with `shares`. +/// v7 (#5176): this account's own pin, archive and position on a shared note, sent +/// only to a server advertising `shared_state`. +pub const CLIENT_PROTOCOL_VERSION: u32 = 7; /// The oldest server protocol this client can drive — the symmetric half of the /// server's `min_client_protocol_version`. @@ -49,8 +52,14 @@ pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"]; /// Capabilities whose absence costs a feature but not the link. Listing these /// explicitly (rather than diffing against whatever the server happens to send) is /// what lets the UI name exactly what the user will be missing. -pub const OPTIONAL_FEATURES: &[&str] = - &["attachments", "tombstones", "revisions", "attachment_sync"]; +pub const OPTIONAL_FEATURES: &[&str] = &[ + "attachments", + "tombstones", + "revisions", + "attachment_sync", + "shares", + "shared_state", +]; /// The handshake fields of `GET /api/config`. /// diff --git a/core/src/sync/engine.rs b/core/src/sync/engine.rs index bde59d9..378577f 100644 --- a/core/src/sync/engine.rs +++ b/core/src/sync/engine.rs @@ -50,13 +50,26 @@ pub async fn run_cycle( // Notes shared with this account come only from a server offering `shares`, and // an unanswered probe reads as "not offered", like `attachment_sync` above. let shares = server.as_ref().is_some_and(|s| s.has_feature("shares")); + // A recipient's own pin, archive and order on a shared note go only to a server + // that keeps them per person; an older one would apply them to nobody. + let accepts = push::Accepts { + attachment_sync, + shared_state: server + .as_ref() + .is_some_and(|s| s.has_feature("shared_state")), + }; - let push = push::run(db, blobs, base_url, token, attachment_sync).await?; + let push = push::run(db, blobs, base_url, token, accepts).await?; if shares { // After the push, so nothing unsent is waiting when the full pull lands. + let level = if accepts.shared_state { + state::SHARED_STATE + } else { + state::SHARES + }; let conn = db.0.lock().map_err(|e| e.to_string())?; - if state::begin_shares(&conn).map_err(|e| e.to_string())? { - log::info!("the server shares notes now; pulling everything once to find them"); + if state::begin_shares(&conn, level).map_err(|e| e.to_string())? { + log::info!("the server shares more of notes now; pulling everything once"); } } let pull = pull::run(db, blobs, base_url, token, shares).await?; diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index 74ee7d1..3925629 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -95,6 +95,12 @@ pub struct Change { pub created_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + /// When this account last pinned, archived or moved a note someone else owns + /// (#5176): `pinned`, `archived` and `position` on such a note are its own, and + /// the server weighs them against this rather than `edited_at`, which stays the + /// time of the text. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_at: Option, } impl Change { @@ -115,6 +121,7 @@ impl Change { label_ids: None, created_at: None, name: None, + state_at: None, } } } @@ -143,24 +150,38 @@ pub struct PushResult { // --- collecting -------------------------------------------------------------- +/// What the server takes beyond the base protocol, read from the features it +/// advertises. What it doesn't take stays queued here, untouched, until a server +/// that does: an older one would refuse it, or worse, accept it and keep nothing. +#[derive(Debug, Clone, Copy, Default)] +pub struct Accepts { + /// Attachment and preview removals, and file uploads (`attachment_sync`). An + /// older server would answer "unknown entity" and the removal would read as a + /// failure. + pub attachment_sync: bool, + /// This account's own pin, archive and position on a note someone else owns + /// (`shared_state`, #5176). + pub shared_state: bool, +} + +impl Accepts { + /// Everything this client can send. + pub const ALL: Accepts = Accepts { + attachment_sync: true, + shared_state: true, + }; +} + /// Everything waiting to go up, oldest edit first so a truncated batch still makes /// forward progress in a sensible order. -/// -/// `attachment_sync` is whether the server takes attachment and preview removals. -/// Without it they stay queued here, untouched, until a server that does — an older -/// one would answer "unknown entity" and the removal would read as a failure. -pub fn collect( - conn: &Connection, - limit: usize, - attachment_sync: bool, -) -> rusqlite::Result> { +pub fn collect(conn: &Connection, limit: usize, accepts: Accepts) -> rusqlite::Result> { let mut out = Vec::new(); - collect_deletes(conn, &mut out, limit, attachment_sync)?; + collect_deletes(conn, &mut out, limit, accepts.attachment_sync)?; if out.len() < limit { collect_labels(conn, &mut out, limit)?; } if out.len() < limit { - collect_notes(conn, &mut out, limit)?; + collect_notes(conn, &mut out, limit, accepts.shared_state)?; } Ok(out) } @@ -225,6 +246,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec, limit: usize) -> rus position: None, label_ids: None, created_at: None, + state_at: None, }) })?; for row in rows { @@ -233,21 +255,31 @@ fn collect_labels(conn: &Connection, out: &mut Vec, limit: usize) -> rus Ok(()) } -fn collect_notes(conn: &Connection, out: &mut Vec, limit: usize) -> rusqlite::Result<()> { +fn collect_notes( + conn: &Connection, + out: &mut Vec, + limit: usize, + shared_state: bool, +) -> rusqlite::Result<()> { let remaining = limit.saturating_sub(out.len()); let ids: Vec = { - // A note shared with us to view can't be changed here, so one that is dirty - // anyway (its share was narrowed while an edit waited) has nothing the server - // would take. The next pull puts the server's copy back over it. + // A note shared with us to view has only this account's own pin, archive and + // place to send, and only to a server that keeps them. Without one, a dirty + // view note has nothing the server would take, and the next pull puts the + // server's copy back over it. let mut stmt = conn.prepare( - "SELECT id FROM notes WHERE dirty = 1 AND permission <> 'view' + "SELECT id FROM notes + WHERE dirty = 1 + AND (permission <> 'view' OR (?2 AND state_at IS NOT NULL)) ORDER BY updated_at LIMIT ?1", )?; - let rows = stmt.query_map(params![remaining as i64], |r| r.get::<_, String>(0))?; + let rows = stmt.query_map(params![remaining as i64, shared_state], |r| { + r.get::<_, String>(0) + })?; rows.collect::>>()? }; for id in ids { - out.push(note_change(conn, &id)?); + out.push(note_change(conn, &id, shared_state)?); } Ok(()) } @@ -264,14 +296,16 @@ struct NoteRow { recurrence: Option, created_at: String, updated_at: String, - /// `owner`, or `edit` for a note someone shared with us (#5175). + /// `owner`, or `edit` or `view` for a note someone shared with us (#5175). permission: String, + /// When this account last pinned, archived or moved it, if it isn't ours (#5176). + state_at: Option, } fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row( "SELECT body, position, pinned, archived, trashed, - remind_at, recurrence, created_at, updated_at, permission + remind_at, recurrence, created_at, updated_at, permission, state_at FROM notes WHERE id = ?1", params![id], |r| { @@ -286,34 +320,39 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { created_at: r.get(7)?, updated_at: r.get(8)?, permission: r.get(9)?, + state_at: r.get(10)?, }) }, ) } -fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { +fn note_change(conn: &Connection, id: &str, shared_state: bool) -> rusqlite::Result { let row = note_row(conn, id)?; - // Someone else's note: only its text is ours to change, so only its text goes. - // Pin, archive, trash, reminders, order and labels are the owner's, and this - // account's labels were never on it. + // Someone else's note: its text if it was shared to edit, and this account's own + // pin, archive and place once they have been changed here, to a server that keeps + // them. Trash, reminders and labels are the owner's, and this account's labels + // were never on it. if row.permission != "owner" { + let state_at = row.state_at.filter(|_| shared_state); + let own = state_at.is_some(); return Ok(Change { entity: "note", id: id.to_string(), op: "upsert", edited_at: row.updated_at, - body: Some(row.body), + body: (row.permission == "edit").then_some(row.body), color: None, - pinned: None, - archived: None, + pinned: own.then_some(row.pinned), + archived: own.then_some(row.archived), trashed: None, remind_at: None, recurrence: None, - position: None, + position: own.then_some(row.position), label_ids: None, created_at: None, name: None, + state_at, }); } @@ -346,6 +385,7 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { label_ids: Some(label_ids), created_at: Some(row.created_at), name: None, + state_at: None, }) } @@ -616,21 +656,21 @@ async fn upload_pending( /// Send everything pending, in batches, applying each batch's results before the /// next is collected — then the files, once their notes are there to hold them. /// -/// `attachment_sync`: whether the server advertises it (see [`collect`]). Without -/// it, uploads wait too. +/// `accepts`: what the server advertises (see [`Accepts`]). Without +/// `attachment_sync`, uploads wait too. pub async fn run( db: &Db, blobs: &BlobStore, base_url: &str, token: &str, - attachment_sync: bool, + accepts: Accepts, ) -> Result { let mut total = PushSummary::default(); loop { let batch = { let conn = db.0.lock().map_err(|e| e.to_string())?; - collect(&conn, BATCH, attachment_sync).map_err(|e| e.to_string())? + collect(&conn, BATCH, accepts).map_err(|e| e.to_string())? }; if batch.is_empty() { break; @@ -658,7 +698,7 @@ pub async fn run( } } - if attachment_sync { + if accepts.attachment_sync { total.absorb(upload_pending(db, blobs, base_url, token).await?); } @@ -736,7 +776,7 @@ mod tests { let conn = db(); seed_note(&conn, "clean", 0); seed_note(&conn, "dirty", 1); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); assert_eq!(batch.len(), 1); assert_eq!(batch[0].id, "dirty"); assert_eq!(batch[0].op, "upsert"); @@ -761,7 +801,7 @@ mod tests { ) .expect("seed membership"); } - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); let note = batch.iter().find(|c| c.entity == "note").expect("note"); assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..])); } @@ -771,7 +811,7 @@ mod tests { let conn = db(); seed_note(&conn, "n1", 0); store::delete_forever(&conn, "n1").expect("delete"); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); assert_eq!(batch.len(), 1); assert_eq!(batch[0].op, "delete"); assert_eq!(batch[0].entity, "note"); @@ -782,7 +822,7 @@ mod tests { fn applied_clears_dirty_and_records_the_revision() { let conn = db(); seed_note(&conn, "n1", 1); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); apply_results(&conn, &batch, &[ok("applied", Some(42))]).expect("apply"); assert_eq!(dirty_count(&conn), 0); let rev: i64 = conn @@ -799,7 +839,7 @@ mod tests { // every time; the following pull adopts the server's version instead. let conn = db(); seed_note(&conn, "n1", 1); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); let summary = apply_results(&conn, &batch, &[ok("kept", Some(99))]).expect("apply"); assert_eq!(summary.kept, 1); assert_eq!(dirty_count(&conn), 0); @@ -813,7 +853,7 @@ mod tests { let conn = db(); seed_note(&conn, "n1", 1); state::set_cursor(&conn, 100).expect("cursor"); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply"); assert_eq!(state::read(&conn).expect("state").last_cursor, 39); } @@ -823,7 +863,7 @@ mod tests { let conn = db(); seed_note(&conn, "n1", 1); state::set_cursor(&conn, 10).expect("cursor"); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply"); assert_eq!( state::read(&conn).expect("state").last_cursor, @@ -836,7 +876,7 @@ mod tests { fn rejected_stays_dirty_and_is_reported() { let conn = db(); seed_note(&conn, "n1", 1); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); let mut bad = ok("rejected", None); bad.error = Some("name in use".into()); let summary = apply_results(&conn, &batch, &[bad]).expect("apply"); @@ -850,7 +890,7 @@ mod tests { let conn = db(); seed_note(&conn, "n1", 0); store::delete_forever(&conn, "n1").expect("delete"); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); apply_results(&conn, &batch, &[ok("applied", Some(7))]).expect("apply"); assert!(!has_pending(&conn).expect("pending")); } @@ -861,7 +901,7 @@ mod tests { let conn = db(); seed_note(&conn, "n1", 1); store::delete_forever(&conn, "n1").expect("delete"); - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); apply_results(&conn, &batch, &[ok("noop", None)]).expect("apply"); assert!(!has_pending(&conn).expect("pending")); } @@ -895,7 +935,7 @@ mod tests { seed_note(&conn, "n1", 1); conn.execute("UPDATE notes SET permission = 'edit' WHERE id = 'n1'", []) .unwrap(); - let changes = collect(&conn, 10, true).unwrap(); + let changes = collect(&conn, 10, Accepts::ALL).unwrap(); assert_eq!(changes.len(), 1); let wire = serde_json::to_value(&changes[0]).unwrap(); let mut keys: Vec<&str> = wire @@ -909,12 +949,35 @@ mod tests { } #[test] - fn a_note_shared_to_view_is_never_pushed() { + fn a_note_shared_to_view_sends_only_this_accounts_own_state() { let conn = db(); seed_note(&conn, "n1", 1); conn.execute("UPDATE notes SET permission = 'view' WHERE id = 'n1'", []) .unwrap(); - assert!(collect(&conn, 10, true).unwrap().is_empty()); + // Dirty with nothing of ours changed on it: nothing to send. + assert!(collect(&conn, 10, Accepts::ALL).unwrap().is_empty()); + + conn.execute( + "UPDATE notes SET pinned = 1, position = 4, + state_at = '2026-07-27T00:00:00.000Z' WHERE id = 'n1'", + [], + ) + .unwrap(); + let changes = collect(&conn, 10, Accepts::ALL).unwrap(); + let wire = serde_json::to_value(&changes[0]).unwrap(); + assert!(wire.get("body").is_none(), "a view share sends no text"); + assert_eq!(wire["pinned"], true); + assert_eq!(wire["archived"], false); + assert_eq!(wire["position"], 4); + assert_eq!(wire["state_at"], "2026-07-27T00:00:00.000Z"); + assert_eq!(wire["edited_at"], "2026-07-26T00:00:00.000Z"); + + // A server that doesn't keep them gets nothing, and it waits here. + let older = Accepts { + shared_state: false, + ..Accepts::ALL + }; + assert!(collect(&conn, 10, older).unwrap().is_empty()); } #[test] @@ -1003,14 +1066,14 @@ mod tests { store::record_pending_delete(&conn, "note", "n9").expect("tombstone"); // An older server would answer "unknown entity" to each of them. - let older: Vec<_> = collect(&conn, 100, false) + let older: Vec<_> = collect(&conn, 100, Accepts::default()) .expect("collect") .iter() .map(|c| c.entity) .collect(); assert_eq!(older, vec!["note"]); - let mut newer: Vec<_> = collect(&conn, 100, true) + let mut newer: Vec<_> = collect(&conn, 100, Accepts::ALL) .expect("collect") .iter() .map(|c| (c.entity, c.op)) @@ -1071,7 +1134,7 @@ mod tests { store::delete_attachment(&conn, "n1", "a1").expect("remove"); // Push: the removal and the touched note go up, and the server applies both. - let batch = collect(&conn, 100, true).expect("collect"); + let batch = collect(&conn, 100, Accepts::ALL).expect("collect"); let results: Vec = batch .iter() .map(|c| ok("applied", (c.entity == "note").then_some(5))) diff --git a/core/src/sync/state.rs b/core/src/sync/state.rs index 3d42340..47db739 100644 --- a/core/src/sync/state.rs +++ b/core/src/sync/state.rs @@ -115,17 +115,27 @@ fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } -/// Start the change feed over the first time this device pulls with shares (#5175). +/// How much of sharing this device's copy of the feed reflects, kept in +/// `sync_state.shares_synced`. Each level changed what the feed says about notes a +/// device may already hold, below the cursor it already has. /// -/// Notes shared before this build sit below the cursor the device already holds, so -/// without a restart they would only arrive once something next changed them. -/// Returns whether it restarted. Once per link: `set_link` to another server and -/// `clear_link` both reset the flag. -pub fn begin_shares(conn: &Connection) -> rusqlite::Result { +/// Shared notes arrive at all (#5175). +pub const SHARES: i64 = 1; +/// A shared note's pin, archive and position are this account's own (#5176). Before, +/// the feed sent the owner's, and a device holding those would keep showing them +/// until something next changed the note. +pub const SHARED_STATE: i64 = 2; + +/// Start the change feed over the first time this device pulls at a sharing `level` +/// the server offers, so what it already holds is re-read in that level's terms. +/// +/// Returns whether it restarted. Once per level per link: `set_link` to another +/// server and `clear_link` both go back to none. +pub fn begin_shares(conn: &Connection, level: i64) -> rusqlite::Result { let restarted = conn.execute( - "UPDATE sync_state SET last_cursor = NULL, shares_synced = 1 - WHERE id = 1 AND shares_synced = 0", - [], + "UPDATE sync_state SET last_cursor = NULL, shares_synced = ?1 + WHERE id = 1 AND shares_synced < ?1", + params![level], )?; Ok(restarted > 0) } @@ -342,14 +352,24 @@ mod tests { let conn = db(); set_link(&conn, "https://a.example.com", "tok-1").expect("link"); set_cursor(&conn, 500).expect("cursor"); - assert!(begin_shares(&conn).expect("begin")); + assert!(begin_shares(&conn, SHARES).expect("begin")); assert_eq!(read(&conn).expect("read").last_cursor, 0); set_cursor(&conn, 600).expect("cursor"); - assert!(!begin_shares(&conn).expect("again"), "only the first time"); + assert!( + !begin_shares(&conn, SHARES).expect("again"), + "only the first time" + ); assert_eq!(read(&conn).expect("read").last_cursor, 600); + // A server offering more of sharing starts it over once more, and only once. + assert!(begin_shares(&conn, SHARED_STATE).expect("a newer server")); + assert_eq!(read(&conn).expect("read").last_cursor, 0); + set_cursor(&conn, 700).expect("cursor"); + assert!(!begin_shares(&conn, SHARED_STATE).expect("again")); + assert!(!begin_shares(&conn, SHARES).expect("an older level")); + assert_eq!(read(&conn).expect("read").last_cursor, 700); // Another server is a fresh start, shares included. set_link(&conn, "https://b.example.com", "tok-2").expect("relink"); - assert!(begin_shares(&conn).expect("new server")); + assert!(begin_shares(&conn, SHARES).expect("new server")); } #[test] diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 17550e1..6df7af4 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -124,7 +124,8 @@ const root = ref(null); // touch — on a phone this did nothing at all. One code path now covers mouse, touch // and stylus. See composables/useCardDrag.ts for why the state is shared. --- // Board order is the owner's; a shared note's place is the recipient's from #5176. -const canDrag = () => !!props.reorderable && !props.note.trashed && own.value; +// A shared note moves on the recipient's board only (#5176), so anyone may drag it. +const canDrag = () => !!props.reorderable && !props.note.trashed; const dragging = computed(() => draggingId.value === props.note.id); const dragOver = computed(() => overId.value === props.note.id); @@ -458,9 +459,10 @@ const tagColors = computed>(() => { - +
-