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:
+125
-1
@@ -265,8 +265,15 @@ pub async fn fetch_changes(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
since: i64,
|
||||
shares: bool,
|
||||
) -> Result<wire::ChangesPage, String> {
|
||||
let url = format!("{base_url}/api/sync/changes?since={since}");
|
||||
// `shares=1` only to a server advertising `shares`: it asks for the notes shared
|
||||
// with this account and the `revoked` list, which an older server would ignore.
|
||||
let url = if shares {
|
||||
format!("{base_url}/api/sync/changes?since={since}&shares=1")
|
||||
} else {
|
||||
format!("{base_url}/api/sync/changes?since={since}")
|
||||
};
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
@@ -416,6 +423,123 @@ pub async fn push_changes<T: Serialize>(
|
||||
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
// --- sharing (#5175) -----------------------------------------------------------
|
||||
//
|
||||
// The Share dialog on a linked device asks the server directly, over the device
|
||||
// token: who is on the instance, and who a note is shared with. Shares are the
|
||||
// server's, so there is nothing to keep offline and nothing to queue.
|
||||
|
||||
/// Someone on the instance a note can be shared with.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Member {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// One person a note is shared with, and at what level (`view` or `edit`).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct NoteShare {
|
||||
pub id: String,
|
||||
pub member: Member,
|
||||
pub permission: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MembersReply {
|
||||
members: Vec<Member>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SharesReply {
|
||||
shares: Vec<NoteShare>,
|
||||
}
|
||||
|
||||
/// A note made on this device that hasn't been pushed yet is a 404 to the server,
|
||||
/// and so is one this account doesn't own.
|
||||
const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it.";
|
||||
|
||||
async fn read_reply<T: serde::de::DeserializeOwned>(
|
||||
base_url: &str,
|
||||
request: RequestBuilder,
|
||||
) -> Result<T, String> {
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Err(SHARE_NOT_FOUND.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
// The server's own words when it gave some ("choose someone to share with").
|
||||
let reason = response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
|
||||
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Everyone on the instance but this account.
|
||||
pub async fn directory(base_url: &str, token: &str) -> Result<Vec<Member>, String> {
|
||||
let url = format!("{base_url}/api/users/directory");
|
||||
let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
||||
Ok(reply.members)
|
||||
}
|
||||
|
||||
pub async fn list_shares(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
||||
let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
/// Share with one member, or change their permission. Answers the note's shares.
|
||||
pub async fn share_note(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares");
|
||||
let body = serde_json::json!({ "user_id": user_id, "permission": permission });
|
||||
let reply: SharesReply = read_reply(
|
||||
base_url,
|
||||
prepare(http()?.post(url), Some(token)).json(&body),
|
||||
)
|
||||
.await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
pub async fn unshare_note(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
share_id: &str,
|
||||
) -> Result<Vec<NoteShare>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}");
|
||||
let reply: SharesReply =
|
||||
read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?;
|
||||
Ok(reply.shares)
|
||||
}
|
||||
|
||||
/// The public, unauthenticated endpoint carrying the handshake.
|
||||
fn config_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/config")
|
||||
|
||||
Reference in New Issue
Block a user