core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
The core pulls with shares from a server offering them (protocol 6): a note says how it is held (owner, edit, view) and who shared it, and a revoked note leaves the device. The first such pull starts the feed over once, so notes shared before this build arrive. The store refuses what a share doesn't allow (view: everything; edit: anything but the text), push sends only the text of someone else's note, and their notes stay out of trash, reminders and reordering. Unlinking drops them. The Share dialog's calls go to the linked server over the device token, as Tauri commands and through the FFI. The desktop now offers Share and "Shared with me"; unlinked, the dialog says sharing needs a server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
//! Sharing a note from a linked device (#5175).
|
||||
//!
|
||||
//! Shares belong to the server: who is on the instance and who a note is shared
|
||||
//! with are never kept here, so each call goes straight to the server over the
|
||||
//! device token. The one thing kept locally is the note's `shared` flag, set from
|
||||
//! the server's answer so the card's chip changes at once rather than at the next
|
||||
//! sync (which brings the same value).
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::client::{self, Member, NoteShare};
|
||||
use super::state;
|
||||
use crate::local::Db;
|
||||
|
||||
/// What an unlinked device says when asked to share. Sharing is between accounts on
|
||||
/// a server, so there is nothing to do offline and nothing worth queueing.
|
||||
pub const NEEDS_SERVER: &str =
|
||||
"Sharing is between people on a server. Link this device to one in Sync to share notes.";
|
||||
|
||||
fn link(db: &Db) -> Result<(String, String), String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
let link = state::read(&conn).map_err(|e| e.to_string())?;
|
||||
match (link.server_url, link.device_token) {
|
||||
(Some(url), Some(token)) => Ok((url, token)),
|
||||
_ => Err(NEEDS_SERVER.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the local note's `shared` flag from the server's list of its shares. Not a
|
||||
/// local edit, so it leaves `dirty` and `updated_at` alone.
|
||||
fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE notes SET shared = ?2 WHERE id = ?1 AND permission = 'owner'",
|
||||
params![note_id, !shares.is_empty()],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn directory(db: &Db) -> Result<Vec<Member>, String> {
|
||||
let (url, token) = link(db)?;
|
||||
client::directory(&url, &token).await
|
||||
}
|
||||
|
||||
pub async fn list(db: &Db, note_id: &str) -> Result<Vec<NoteShare>, String> {
|
||||
let (url, token) = link(db)?;
|
||||
let shares = client::list_shares(&url, &token, note_id).await?;
|
||||
mark_shared(db, note_id, &shares)?;
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
pub async fn share(
|
||||
db: &Db,
|
||||
note_id: &str,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let (url, token) = link(db)?;
|
||||
let shares = client::share_note(&url, &token, note_id, user_id, permission).await?;
|
||||
mark_shared(db, note_id, &shares)?;
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
pub async fn unshare(db: &Db, note_id: &str, share_id: &str) -> Result<Vec<NoteShare>, String> {
|
||||
let (url, token) = link(db)?;
|
||||
let shares = client::unshare_note(&url, &token, note_id, share_id).await?;
|
||||
mark_shared(db, note_id, &shares)?;
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn db() -> Db {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
Db(Mutex::new(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlinked_device_explains_that_sharing_needs_a_server() {
|
||||
assert_eq!(link(&db()).unwrap_err(), NEEDS_SERVER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shared_flag_follows_the_servers_answer_without_dirtying_the_note() {
|
||||
let db = db();
|
||||
{
|
||||
let conn = db.0.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, dirty)
|
||||
VALUES ('n1', 'x', '2026-01-01', '2026-01-01', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let one = NoteShare {
|
||||
id: "s1".into(),
|
||||
member: Member {
|
||||
id: "u2".into(),
|
||||
display_name: "Sam".into(),
|
||||
email: "sam@example.test".into(),
|
||||
},
|
||||
permission: "view".into(),
|
||||
created_at: None,
|
||||
};
|
||||
let read = |db: &Db| -> (bool, i64) {
|
||||
let conn = db.0.lock().unwrap();
|
||||
conn.query_row("SELECT shared, dirty FROM notes WHERE id = 'n1'", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
mark_shared(&db, "n1", &[one]).unwrap();
|
||||
assert_eq!(read(&db), (true, 0));
|
||||
mark_shared(&db, "n1", &[]).unwrap();
|
||||
assert_eq!(read(&db), (false, 0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user