mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
Someone who joins with a comment link (or its join code) sees the file live and adds, replies to, reacts to and resolves comment threads, but can't change the design: their app refuses other edits and the server accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones; the Share popover offers Can edit, Can comment and Can view, and the commenter's toolbar shows a Can comment badge with Move, Hand and Comment. Backward compatible: clients announce the comments word; one that doesn't is welcomed with a comment link as a viewer and gets comment links listed as view links, a level this build doesn't know reads as view, and an older server that can't make a comment link leaves the share without one.
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -29,7 +29,7 @@ Usage:
|
||||
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
|
||||
figcraft-server files remove FILE remove a file and its links for good
|
||||
figcraft-server links list FILE a file's share links
|
||||
figcraft-server links create FILE --access edit|view [--label TEXT]
|
||||
figcraft-server links create FILE --access edit|comment|view [--label TEXT]
|
||||
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
|
||||
figcraft-server plugins [list] the plugin directory: every plugin and its versions
|
||||
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
|
||||
@@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
"--listen" => config.listen = value("--listen")?,
|
||||
"--data" => config.data = PathBuf::from(value("--data")?),
|
||||
"--public-url" => config.public_url = Some(value("--public-url")?),
|
||||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
|
||||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?),
|
||||
"--label" => label = value("--label")?,
|
||||
"--rotate" => rotate = true,
|
||||
"--sort" => {
|
||||
@@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
|
||||
["links", "list", file] => Command::Links { file: file.to_string() },
|
||||
["links", "create", file] => {
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label }
|
||||
}
|
||||
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
|
||||
["plugins"] | ["plugins", "list"] => Command::Plugins,
|
||||
@@ -361,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
|
||||
};
|
||||
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
|
||||
let mut out = String::new();
|
||||
let what = if l.link.access == Access::View { "view" } else { "edit" };
|
||||
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
|
||||
let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file);
|
||||
let _ = writeln!(out, " {}", invite.link());
|
||||
let _ = writeln!(out, " {}", invite.url());
|
||||
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
|
||||
|
||||
@@ -57,7 +57,7 @@ fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
assert_eq!(links.matches("\"code\"").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -76,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert!(p.owner.is_some(), "the owner token is kept as before");
|
||||
// Others get links of their own (the owner keeps the file's first ones): four in all.
|
||||
// Others get links of their own (edit, view and comment; the owner keeps the file's first
|
||||
// ones): five in all.
|
||||
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
|
||||
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
|
||||
let ids = |dir: &std::path::Path| -> Vec<String> {
|
||||
@@ -84,7 +85,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
assert_eq!(before_stop.len(), 5);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
|
||||
// Someone types the edit code, the way people type.
|
||||
@@ -142,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
|
||||
assert_eq!(ids(dir.path()).len(), 4);
|
||||
assert_eq!(ids(dir.path()).len(), 5);
|
||||
ex(&mut b, "collab.join", json!({"code": edit2}));
|
||||
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
|
||||
// The old code is still gone.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Comment links end to end over loopback: sharing online makes a comment link and code beside the
|
||||
//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses
|
||||
//! their other edits, and the server refuses them too for a client that skips the check; a client
|
||||
//! that can't read comment links gets in as a viewer, and links listed to it read as view links.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Comment, Message};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// The welcome `raw` gets after saying hello with `client_version` through `link`.
|
||||
fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client {
|
||||
let invite = Invite::parse(link).unwrap();
|
||||
let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") };
|
||||
h.client_version = client_version.into();
|
||||
raw.send(&ClientMsg::Hello(h));
|
||||
loop {
|
||||
if let w @ ServerMsg::Welcome { .. } = raw.next() {
|
||||
return Client::join(w).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server says to the batch `raw` sends next.
|
||||
fn answer(raw: &mut Raw) -> ServerMsg {
|
||||
loop {
|
||||
if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_link_comments_and_the_server_refuses_its_edits() {
|
||||
let dir = TempDir::new("comment-link");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80}));
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let file = Invite::parse(&p.edit).unwrap().file;
|
||||
let endpoint = Endpoint::plain(addr.to_string());
|
||||
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
|
||||
let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() };
|
||||
assert_eq!(made.link.access, Access::Comment);
|
||||
let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link();
|
||||
// Cy joins with it and comments; Ada sees the thread.
|
||||
let mut c = join(&link, "Cy");
|
||||
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true)));
|
||||
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap();
|
||||
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
|
||||
ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"}));
|
||||
pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1));
|
||||
ex(&mut c, "comment.resolve", json!({"id": thread}));
|
||||
pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved));
|
||||
// Cy's app refuses anything else before it is sent.
|
||||
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("Can comment"), "{e}");
|
||||
// A client that skips the app's check: its comment goes through, its edit is refused.
|
||||
let mut raw = Raw::connect(addr);
|
||||
let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version());
|
||||
assert_eq!(client.access(), Access::Comment);
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
let id = d.new_comment_id();
|
||||
d.comments.push(Comment {
|
||||
id,
|
||||
page,
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
anchor: None,
|
||||
first: Message::new("Raw", "Hi", 0),
|
||||
resolved: false,
|
||||
replies: vec![],
|
||||
read_by: None,
|
||||
});
|
||||
raw.send(&client.edit(&d).unwrap().unwrap());
|
||||
let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") };
|
||||
client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap();
|
||||
let mut d = client.document().clone();
|
||||
d.node_mut(page).unwrap().name = "Sneaky".into();
|
||||
raw.send(&client.edit(&d).unwrap().unwrap());
|
||||
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
|
||||
// So is a forged batch that hides an edit among comment changes.
|
||||
let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() };
|
||||
let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() };
|
||||
raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] });
|
||||
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
|
||||
pump(&mut [&mut a], "the raw comment, nothing else", |s| {
|
||||
let d = s[0].doc().unwrap();
|
||||
d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky"
|
||||
});
|
||||
// A client from before comment links gets in as a viewer.
|
||||
let mut old = Raw::connect(addr);
|
||||
assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View);
|
||||
// And lists the link as a view link.
|
||||
let mut m = Raw::connect(addr);
|
||||
m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() }));
|
||||
assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready));
|
||||
m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() }));
|
||||
let ServerMsg::Admin(r) = m.next() else { panic!("no listing") };
|
||||
let AdminReply::Links(links) = *r else { panic!("{r:?}") };
|
||||
assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View));
|
||||
let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() };
|
||||
assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_offers_a_comment_code() {
|
||||
let dir = TempDir::new("comment-code");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/?key={}", server.addr(), key(dir.path()));
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.share", json!({"server": url}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true);
|
||||
let shared = a.live_summary()["share"].clone();
|
||||
let comment = shared["commentCode"].as_str().unwrap().to_string();
|
||||
assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}");
|
||||
assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str());
|
||||
// Someone types it: they can comment, not edit.
|
||||
let mut c = session("Cy");
|
||||
ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())}));
|
||||
ex(&mut c, "collab.join", json!({"code": comment}));
|
||||
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(c.live_summary()["access"], "comment");
|
||||
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap();
|
||||
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
|
||||
assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
|
||||
// Stopping revokes it with the others: Cy is out.
|
||||
ex(&mut a, "collab.stop", json!({}));
|
||||
pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty());
|
||||
server.stop();
|
||||
}
|
||||
@@ -95,6 +95,8 @@ mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod codes_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_link_tests;
|
||||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
|
||||
@@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() {
|
||||
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
||||
let first = list[0].as_ref().expect("its own token");
|
||||
assert_eq!(first.name, "Untitled");
|
||||
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
||||
assert_eq!(first.links.len(), 5, "the owner's two links and the three made for others");
|
||||
assert!(first.expires.is_some());
|
||||
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
||||
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
||||
|
||||
@@ -53,9 +53,7 @@ impl Exchange {
|
||||
/// `file` with its `owner` token.
|
||||
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
|
||||
let open = match key {
|
||||
Some(key) => {
|
||||
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() })
|
||||
}
|
||||
Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }),
|
||||
None => ClientMsg::OpenHosting(OpenHosting {
|
||||
protocol: PROTOCOL,
|
||||
client_version: message::client_version(),
|
||||
@@ -144,7 +142,7 @@ pub(crate) fn publish(job: Publish) -> Next {
|
||||
// The file's first links stay its owner's (this app joins with one): others get their
|
||||
// own, which stopping takes away again.
|
||||
let owner = published.owner.unwrap_or_default();
|
||||
let then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next> = Box::new(move |made| {
|
||||
let then: LinksThen = Box::new(move |made| {
|
||||
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
|
||||
Next::Done(events, Some(edit))
|
||||
});
|
||||
@@ -163,26 +161,45 @@ struct Sharing {
|
||||
create: bool,
|
||||
}
|
||||
|
||||
/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a
|
||||
/// connection of their own; `then` gets the links.
|
||||
fn share_links(s: Sharing, then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>) -> Next {
|
||||
/// What [`share_links`] hands the links to.
|
||||
type LinksThen = Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>;
|
||||
|
||||
/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their
|
||||
/// codes, each on a connection of its own; `then` gets the links.
|
||||
fn share_links(s: Sharing, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |answers| {
|
||||
let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
|
||||
Ok(Some(links)) => links,
|
||||
other => return then(other.map(|_| None)),
|
||||
};
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |codes| {
|
||||
// A server from before join codes: the links are shared without them.
|
||||
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
|
||||
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
|
||||
}),
|
||||
)
|
||||
Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
|
||||
Ok(Some(links)) => comment_link(s, links, then),
|
||||
other => then(other.map(|_| None)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for a comment link beside `links`, then for their codes.
|
||||
fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |made| {
|
||||
// A server from before comment links: the file is shared without one.
|
||||
let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display()));
|
||||
links.comment = made.ok().and_then(|a| requests::made_comment(a.replies));
|
||||
codes(s, links, then)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for the codes of `links`; `then` gets them with their codes.
|
||||
fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |codes| {
|
||||
// A server from before join codes: the links are shared without them.
|
||||
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
|
||||
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
|
||||
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
|
||||
// The comment link, on another.
|
||||
d.opened(t0);
|
||||
say(d, &hosting(), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Comment, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("c1", Access::Comment, "SaySaySaySaySaySaySayS"))), t0);
|
||||
// Their codes, on another.
|
||||
d.opened(t0);
|
||||
say(d, &ServerMsg::Pong, t0);
|
||||
@@ -136,6 +141,7 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
|
||||
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
|
||||
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
|
||||
say(d, &admin(AdminReply::Code { code: "VWXYZ0123".into() }), t0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -153,15 +159,18 @@ fn publishing_without_the_key_uploads_shares_then_joins() {
|
||||
say(&mut d, &hosted(), t0);
|
||||
make_links(&mut d, t0);
|
||||
let events = inbox.drain();
|
||||
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view) }))] = events.as_slice() else {
|
||||
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view), comment: Some(comment) }))] = events.as_slice()
|
||||
else {
|
||||
panic!("{events:#?}")
|
||||
};
|
||||
assert_eq!(h.operator, "ops@example.com");
|
||||
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
|
||||
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
|
||||
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
|
||||
assert_eq!((comment.id.as_str(), comment.code.as_deref()), ("c1", Some("VWXYZ0123")));
|
||||
assert!(comment.link.contains("token=SaySaySaySaySaySaySayS"));
|
||||
// Then the file, with the owner's edit link.
|
||||
assert_eq!(d.sock.opened.len(), 4);
|
||||
assert_eq!(d.sock.opened.len(), 5);
|
||||
d.opened(t0);
|
||||
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
|
||||
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
|
||||
@@ -270,7 +279,8 @@ fn sharing_that_fails_still_joins_the_file() {
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
|
||||
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
|
||||
// A server from before join codes shares the links without them.
|
||||
// A server from before join codes (and comment links) shares the edit and view links without
|
||||
// codes.
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
@@ -279,11 +289,15 @@ fn sharing_that_fails_still_joins_the_file() {
|
||||
say(&mut d, &hosting(), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
d.closed(1000, "that message couldn't be read", t0);
|
||||
for _ in ["the comment link", "the codes"] {
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
d.closed(1000, "that message couldn't be read", t0);
|
||||
}
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none())));
|
||||
assert!(
|
||||
matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none()) && s.comment.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
|
||||
|
||||
@@ -71,14 +71,15 @@ const BURST: usize = 256;
|
||||
enum Kind {
|
||||
/// In `file`'s session as client `id`, through link `link`.
|
||||
Joined { file: String, id: u32, link: Option<String> },
|
||||
/// Managing the server's files.
|
||||
Managing,
|
||||
/// Managing the server's files; `comments`: its client reads comment links.
|
||||
Managing { comments: bool },
|
||||
/// Using `file`'s team library through link `link` of `access` (revoking it disconnects).
|
||||
Library { file: String, access: Access, link: Option<String> },
|
||||
/// Using the plugin directory; `publisher`: with the server's key.
|
||||
Plugins { publisher: bool },
|
||||
/// Publishing without the key, or managing the file `owns` with its owner token.
|
||||
Hosting { owns: Option<String> },
|
||||
/// Publishing without the key, or managing the file `owns` with its owner token; `comments`:
|
||||
/// its client reads comment links.
|
||||
Hosting { owns: Option<String>, comments: bool },
|
||||
}
|
||||
|
||||
/// A connection whose first message the hub is looking at.
|
||||
@@ -234,7 +235,7 @@ impl<B: Backend> Hub<B> {
|
||||
let Some(c) = self.conns.get(&conn) else { return };
|
||||
let ip = c.ip;
|
||||
// The operator (with the server key) isn't held to the limits on addresses.
|
||||
if !matches!(c.kind, Kind::Managing) && !self.backend.message(ip, &msg) {
|
||||
if !matches!(c.kind, Kind::Managing { .. }) && !self.backend.message(ip, &msg) {
|
||||
log::info!("event=refused ip={ip} reason=messages_per_address");
|
||||
return self.kick(&[conn], "too many messages from your address: wait a minute and try again");
|
||||
}
|
||||
@@ -281,7 +282,7 @@ impl<B: Backend> Hub<B> {
|
||||
let (file, id) = (file.clone(), *id);
|
||||
self.client_message(&file, id, msg, Some(bytes));
|
||||
}
|
||||
Some(Kind::Hosting { owns }) => match msg {
|
||||
Some(Kind::Hosting { owns, .. }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = if owns.as_deref() == Some(file.as_str()) {
|
||||
self.removed_reply(&file, client)
|
||||
@@ -300,7 +301,7 @@ impl<B: Backend> Hub<B> {
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Managing) => match msg {
|
||||
Some(Kind::Managing { .. }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = self.removed_reply(&file, client);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
@@ -475,7 +476,8 @@ impl<B: Backend> Hub<B> {
|
||||
return refuse(&c.out, &why);
|
||||
}
|
||||
log::info!("live session: connection {conn} manages files");
|
||||
self.take_in(conn, c, Kind::Managing, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
let comments = message::reads_comments(&m.client_version);
|
||||
self.take_in(conn, c, Kind::Managing { comments }, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
}
|
||||
|
||||
fn open_library(&mut self, conn: u64, o: OpenLibrary, c: Pending) {
|
||||
@@ -483,7 +485,7 @@ impl<B: Backend> Hub<B> {
|
||||
return refuse(&c.out, &unsupported(o.protocol));
|
||||
}
|
||||
let (access, link) = match self.backend.admit(&o.file, &o.token) {
|
||||
Ok(a) => (a.access, a.link),
|
||||
Ok(a) => (a.access.for_reader(message::reads_comments(&o.client_version)), a.link),
|
||||
Err(why) => return refuse(&c.out, &why),
|
||||
};
|
||||
self.take_in(conn, c, Kind::Library { file: o.file, access, link }, &ServerMsg::Library(Box::new(LibraryReply::Ready { access })));
|
||||
@@ -515,7 +517,8 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
};
|
||||
let owns = (!o.owner.is_empty()).then(|| o.file.clone());
|
||||
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
|
||||
let comments = message::reads_comments(&o.client_version);
|
||||
self.take_in(conn, c, Kind::Hosting { owns, comments }, &ServerMsg::Hosting(Box::new(info)));
|
||||
}
|
||||
|
||||
/// A join code: the file it opens and the token to join it with, then the connection closes
|
||||
@@ -545,9 +548,12 @@ impl<B: Backend> Hub<B> {
|
||||
self.handled(conn, handled);
|
||||
}
|
||||
|
||||
/// Answer a managing or hosting request, and disconnect whoever it affects.
|
||||
/// Answer a managing or hosting request (as its client reads it), and disconnect whoever it
|
||||
/// affects.
|
||||
fn handled(&mut self, conn: u64, handled: Handled) {
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
|
||||
let comments =
|
||||
self.conns.get(&conn).is_some_and(|c| matches!(c.kind, Kind::Managing { comments: true } | Kind::Hosting { comments: true, .. }));
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply.for_reader(comments))));
|
||||
if let Some((file, link)) = handled.revoked {
|
||||
let with = |joined: bool| -> Vec<u64> {
|
||||
let uses = |c: &Conn| match &c.kind {
|
||||
|
||||
@@ -85,7 +85,7 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing { comments: true }, ip })]),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
|
||||
@@ -65,7 +65,7 @@ fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
|
||||
for id in 1..=3u64 {
|
||||
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
|
||||
receivers.push(rx);
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing { comments: true }, ip });
|
||||
}
|
||||
// Connection 1 doesn't read: it holds most of the budget.
|
||||
let big = Bytes::from(vec![0u8; 9_000]);
|
||||
|
||||
@@ -16,7 +16,7 @@ use tungstenite::{Bytes, Message, WebSocket};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::net::{Duplex, NetLimits, config, open};
|
||||
pub(crate) use crate::requests::REFUSED;
|
||||
use crate::requests::{code_requests, hosting_error, link_requests, made_links, shared};
|
||||
use crate::requests::{code_requests, comment_request, hosting_error, link_requests, made_comment, made_links, shared};
|
||||
|
||||
type Ws = WebSocket<Duplex>;
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
|
||||
ServerMsg::Admin(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::Manage(manage))?;
|
||||
match reply(&mut ws, limits, admin)? {
|
||||
@@ -97,8 +97,9 @@ pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Durat
|
||||
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
|
||||
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
|
||||
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
|
||||
/// make a new edit link and a new view link and ask for their join codes: what others join with.
|
||||
/// A server older than join codes makes the links without codes.
|
||||
/// make a new edit link, a new view link and a new comment link and ask for their join codes: what
|
||||
/// others join with. A server older than join codes makes the links without codes, and one older
|
||||
/// than comment links makes no comment link.
|
||||
pub fn share(
|
||||
server: &Endpoint,
|
||||
key: Option<&str>,
|
||||
@@ -113,7 +114,11 @@ pub fn share(
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let Some(links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
|
||||
let Some(mut links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
|
||||
// The comment link on a connection of its own: a server from before comment links can't read
|
||||
// the request and closes it (the file is shared without one then).
|
||||
let comment = ask(comment_request(file)).map_err(|e| log::info!("live session: no comment link from {}: {e}", server.display()));
|
||||
links.comment = made_comment(comment.unwrap_or_default());
|
||||
// Their codes, on a connection of its own: a server from before join codes can't read the
|
||||
// request and closes it (the links are shared without codes then).
|
||||
let codes =
|
||||
@@ -130,7 +135,7 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
|
||||
_ => None,
|
||||
};
|
||||
let invite = Invite::parse(link)?;
|
||||
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: message::client_version() };
|
||||
let mut ws = connect(&invite.server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenLibrary(open))?;
|
||||
match reply(&mut ws, limits, lib)? {
|
||||
|
||||
@@ -63,18 +63,42 @@ pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(
|
||||
Ok((edit, published))
|
||||
}
|
||||
|
||||
/// The links made for others: an edit and a view link, and a comment link from a server that
|
||||
/// makes them.
|
||||
pub(crate) struct Links {
|
||||
pub edit: NewLink,
|
||||
pub view: NewLink,
|
||||
pub comment: Option<NewLink>,
|
||||
}
|
||||
|
||||
/// The request for a new link of `access` to `file`, for others.
|
||||
fn link_request(file: &str, access: Access) -> Admin {
|
||||
Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) }
|
||||
}
|
||||
|
||||
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
|
||||
/// edit link and a new view link.
|
||||
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
requests.extend(create.then(|| [link_request(file, Access::Edit), link_request(file, Access::View)]).into_iter().flatten());
|
||||
requests
|
||||
}
|
||||
|
||||
/// The request for a comment link to `file`, asked on a connection of its own once
|
||||
/// [`made_links`] gave the others: a server from before comment links can't read it and closes
|
||||
/// the connection (the file is shared without one then).
|
||||
pub(crate) fn comment_request(file: &str) -> Vec<Admin> {
|
||||
vec![link_request(file, Access::Comment)]
|
||||
}
|
||||
|
||||
/// The comment link the answers to [`comment_request`] made, if any.
|
||||
pub(crate) fn made_comment(replies: Vec<AdminReply>) -> Option<NewLink> {
|
||||
replies.into_iter().find_map(|r| if let AdminReply::Link(l) = r { Some(l) } else { None })
|
||||
}
|
||||
|
||||
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
|
||||
/// or why the server didn't do it.
|
||||
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<(NewLink, NewLink)>, String> {
|
||||
/// or why the server didn't do it; the comment link comes after ([`made_comment`]).
|
||||
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<Links>, String> {
|
||||
let mut made = Vec::new();
|
||||
for reply in replies {
|
||||
match reply {
|
||||
@@ -86,7 +110,7 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
|
||||
}
|
||||
}
|
||||
match (made.pop(), made.pop()) {
|
||||
(Some(view), Some(edit)) => Ok(Some((edit, view))),
|
||||
(Some(view), Some(edit)) => Ok(Some(Links { edit, view, comment: None })),
|
||||
_ if create => Err("the server didn't make the links".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
@@ -94,21 +118,21 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
|
||||
|
||||
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
|
||||
/// their own: a server from before join codes can't read them and closes it).
|
||||
pub(crate) fn code_requests(file: &str, (edit, view): &(NewLink, NewLink)) -> Vec<Admin> {
|
||||
[edit, view].into_iter().map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
|
||||
pub(crate) fn code_requests(file: &str, links: &Links) -> Vec<Admin> {
|
||||
[&links.edit, &links.view].into_iter().chain(&links.comment).map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
|
||||
}
|
||||
|
||||
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
|
||||
/// [`code_requests`]; none from a server older than join codes).
|
||||
pub(crate) fn shared(server: &Endpoint, file: &str, (edit, view): (NewLink, NewLink), codes: Vec<AdminReply>) -> Shared {
|
||||
pub(crate) fn shared(server: &Endpoint, file: &str, Links { edit, view, comment }: Links, codes: Vec<AdminReply>) -> Shared {
|
||||
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut link = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = link(edit);
|
||||
Shared { edit, view: Some(link(view)) }
|
||||
let (edit, view) = (link(edit), Some(link(view)));
|
||||
Shared { edit, view, comment: comment.map(link) }
|
||||
}
|
||||
|
||||
/// A hosting answer as the app shows it: a new link whole, in place of its token.
|
||||
|
||||
@@ -318,6 +318,16 @@ impl Change {
|
||||
pub(crate) fn is_image(&self) -> bool {
|
||||
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
|
||||
}
|
||||
|
||||
/// Whether this only changes comment threads (a thread, their order, or all of them): what a
|
||||
/// comment link may send.
|
||||
pub fn is_comment(&self) -> bool {
|
||||
match self {
|
||||
Change::Put { entry, .. } | Change::Remove { entry } => entry.list() == Some(Family::Comments),
|
||||
Change::SetDoc { prop, .. } => matches!(prop.list(), Some(Ok(Family::Comments) | Err(Family::Comments))),
|
||||
Change::Create { .. } | Change::Set { .. } | Change::Delete { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
|
||||
@@ -229,6 +229,12 @@ impl Client {
|
||||
Ok(self.flush(&NoRecompute).pop())
|
||||
}
|
||||
|
||||
/// Whether `doc` differs from what this client shows only in comment threads (what a comment
|
||||
/// link may send; nothing differing counts too).
|
||||
pub fn changes_only_comments(&self, doc: &Document) -> bool {
|
||||
diff(&self.view, doc).is_ok_and(|d| d.changes.iter().all(Change::is_comment))
|
||||
}
|
||||
|
||||
/// Undo the user's last step that still changes something: write back the values it replaced
|
||||
/// where nobody else wrote since (their later writes win), delete what it created (unless
|
||||
/// others put layers inside) and bring back what it deleted. Returns the batches to send now
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Comment links: a commenter sees everything and may change comment threads (add, reply, react,
|
||||
//! resolve, remove, reorder), but a batch that changes anything else is refused with the reason
|
||||
//! and rolled back; a client that can't read comment links joins with one as a viewer, managing
|
||||
//! clients that can't read them get them listed as view links, and a level this build doesn't know
|
||||
//! reads as view.
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message, NodeKind};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::message::{
|
||||
self, Access, AdminReply, ClientMsg, Hello, LinkInfo, NewLink, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{COMMENT_ONLY, Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn hello(client_version: &str) -> Hello {
|
||||
let ClientMsg::Hello(mut h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h.client_version = client_version.into();
|
||||
h
|
||||
}
|
||||
|
||||
fn join_as(s: &mut Server, access: Access, client_version: &str) -> Client {
|
||||
let (_, out) = s.join_as(hello(client_version), access).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
|
||||
Client::join(welcome).unwrap()
|
||||
}
|
||||
|
||||
/// `c` edits its copy with `f`; the server's answer, delivered to everyone in `all`.
|
||||
fn send(s: &mut Server, c: &mut Client, f: impl FnOnce(&mut Document), all: &mut [&mut Client]) -> Vec<Outgoing> {
|
||||
let mut d = c.document().clone();
|
||||
f(&mut d);
|
||||
let m = c.edit(&d).unwrap().expect("a change");
|
||||
let out = s.receive(c.id(), m);
|
||||
for o in &out {
|
||||
let m = match o {
|
||||
Outgoing::All(m) => m,
|
||||
Outgoing::To(t, m) if *t == c.id() => m,
|
||||
_ => continue,
|
||||
};
|
||||
c.receive(m.clone(), &NoRecompute).unwrap();
|
||||
for other in all.iter_mut() {
|
||||
if matches!(o, Outgoing::All(_)) {
|
||||
other.receive(m.clone(), &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn accepted(out: &[Outgoing]) -> bool {
|
||||
matches!(out, [Outgoing::All(ServerMsg::Changes(_))])
|
||||
}
|
||||
|
||||
fn refused(out: &[Outgoing]) -> bool {
|
||||
matches!(out, [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == COMMENT_ONLY)
|
||||
}
|
||||
|
||||
fn comment(id: u64, d: &Document, text: &str) -> Comment {
|
||||
let page = d.first_page().unwrap();
|
||||
Comment { id, page, x: 10.0, y: 20.0, anchor: None, first: Message::new("Cy", text, 0), resolved: false, replies: vec![], read_by: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commenter_changes_comment_threads_and_nothing_else() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut editor = join_as(&mut s, Access::Edit, &message::client_version());
|
||||
let mut cy = join_as(&mut s, Access::Comment, &message::client_version());
|
||||
assert_eq!(cy.access(), Access::Comment, "the welcome says what the client may do");
|
||||
let page = s.document().first_page().unwrap();
|
||||
// A new thread reaches everyone; so do a reply, a resolve and a reaction to it.
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
let id = d.new_comment_id();
|
||||
let c = comment(id, d, "Bigger logo?");
|
||||
d.comments.push(c);
|
||||
},
|
||||
&mut [&mut editor],
|
||||
);
|
||||
assert!(accepted(&out), "{out:?}");
|
||||
assert_eq!(editor.document().comments.len(), 1);
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
let c = &mut d.comments[0];
|
||||
c.replies.push(Message::new("Cy", "Or a darker one", 1));
|
||||
c.resolved = true;
|
||||
c.first.react("👍", "Cy", None);
|
||||
},
|
||||
&mut [&mut editor],
|
||||
);
|
||||
assert!(accepted(&out), "{out:?}");
|
||||
assert_eq!(s.document().comments[0].replies.len(), 1);
|
||||
assert!(s.document().comments[0].resolved);
|
||||
// A layer, a page or any other change is refused with the reason and rolled back.
|
||||
let seq = s.seq();
|
||||
assert!(refused(&send(&mut s, &mut cy, |d| d.node_mut(page).unwrap().name = "Mine".into(), &mut [])));
|
||||
assert!(refused(&send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
},
|
||||
&mut []
|
||||
)));
|
||||
assert!(refused(&send(&mut s, &mut cy, |d| d.name = "Renamed".into(), &mut [])));
|
||||
// So is a batch that sneaks an edit in beside a comment.
|
||||
assert!(refused(&send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
d.comments[0].resolved = false;
|
||||
d.node_mut(page).unwrap().name = "Mine".into();
|
||||
},
|
||||
&mut []
|
||||
)));
|
||||
assert_eq!(s.seq(), seq, "nothing accepted");
|
||||
assert_eq!(cy.view(), s.replica(), "the commenter's copy is the server's again");
|
||||
assert!(s.document().comments[0].resolved);
|
||||
// Removing a thread is a comment change too.
|
||||
assert!(accepted(&send(&mut s, &mut cy, |d| d.comments.clear(), &mut [&mut editor])));
|
||||
assert!(editor.document().comments.is_empty());
|
||||
// The editor still edits.
|
||||
assert!(accepted(&send(&mut s, &mut editor, |d| d.node_mut(page).unwrap().name = "Home".into(), &mut [&mut cy])));
|
||||
assert_eq!(cy.document().node(page).unwrap().name, "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_cant_read_comment_links_joins_with_one_as_a_viewer() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut old = join_as(&mut s, Access::Comment, "0.12.1 signals hosting");
|
||||
assert_eq!(old.access(), Access::View);
|
||||
let page = s.document().first_page().unwrap();
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut old,
|
||||
|d| {
|
||||
let id = d.new_comment_id();
|
||||
let c = comment(id, d, "Hi");
|
||||
d.comments.push(c);
|
||||
},
|
||||
&mut [],
|
||||
);
|
||||
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == crate::VIEW_ONLY), "{out:?}");
|
||||
// Edit and view links are as they were for it.
|
||||
assert_eq!(join_as(&mut s, Access::Edit, "0.12.1").access(), Access::Edit);
|
||||
assert_eq!(join_as(&mut s, Access::View, "0.12.1").access(), Access::View);
|
||||
assert!(s.document().node(page).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_comment_thread_changes_count_as_comments() {
|
||||
let d = base();
|
||||
let c = comment(7, &d, "x");
|
||||
let value = Json::of(&c).unwrap();
|
||||
let page = d.first_page().unwrap();
|
||||
let comments = [
|
||||
Change::Put { entry: Entry::Comment(7), value: value.clone() },
|
||||
Change::Remove { entry: Entry::Comment(7) },
|
||||
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[7u64]).unwrap() },
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&[c]).unwrap() },
|
||||
];
|
||||
assert!(comments.iter().all(Change::is_comment));
|
||||
let others = [
|
||||
Change::Put { entry: Entry::Style("S:1".into()), value: value.clone() },
|
||||
Change::Remove { entry: Entry::Image("a".into()) },
|
||||
Change::SetDoc { prop: DocProp::Name, value: Json::of("x").unwrap() },
|
||||
Change::SetDoc { prop: DocProp::StyleOrder, value: Json::of(&["S:1"]).unwrap() },
|
||||
Change::Set { id: page, prop: Prop::Name, value: Json::of("x").unwrap() },
|
||||
Change::Delete { id: page },
|
||||
];
|
||||
assert!(!others.iter().any(Change::is_comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_access_reads_back_and_an_unknown_level_reads_as_view() {
|
||||
assert_eq!(Access::parse(" Comment "), Some(Access::Comment));
|
||||
assert_eq!(Access::Comment.id(), "comment");
|
||||
for a in [Access::Edit, Access::View, Access::Comment] {
|
||||
assert_eq!(decode::<Access>(&encode(&a).unwrap(), 64).unwrap(), a);
|
||||
assert_eq!(decode_json::<Access>(&encode_json(&a).unwrap(), 64).unwrap(), a);
|
||||
}
|
||||
// A level from a newer build.
|
||||
assert_eq!(decode::<Access>(&[WIRE_VERSION, 9], 64).unwrap(), Access::View);
|
||||
assert_eq!(decode_json::<Access>("\"owner\"", 64).unwrap(), Access::View);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managing_clients_that_cant_read_comment_links_get_view_links() {
|
||||
let link = |access| LinkInfo { id: "l".into(), file: "f".into(), access, label: String::new(), created: 0 };
|
||||
let listing = AdminReply::Links(vec![link(Access::Edit), link(Access::Comment), link(Access::View)]);
|
||||
let AdminReply::Links(old) = listing.clone().for_reader(false) else { panic!() };
|
||||
assert_eq!(old.iter().map(|l| l.access).collect::<Vec<_>>(), [Access::Edit, Access::View, Access::View]);
|
||||
assert_eq!(listing.clone().for_reader(true), listing);
|
||||
let made = AdminReply::Link(NewLink { link: link(Access::Comment), token: "t".into() });
|
||||
assert!(matches!(made.for_reader(false), AdminReply::Link(l) if l.link.access == Access::View));
|
||||
assert!(message::reads_comments(&message::client_version()) && !message::reads_comments("0.12.1 signals hosting"));
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub use figcraft_doc::{Document, NodeId};
|
||||
pub use history::{BatchKind, Notice};
|
||||
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg, Signal};
|
||||
pub use replica::{Applied, Mode, Replica};
|
||||
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
pub use server::{COMMENT_ONLY, Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
|
||||
/// Compare secrets (tokens, keys, their hashes) without stopping at the first difference.
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
@@ -131,6 +131,8 @@ mod client_tests;
|
||||
#[cfg(test)]
|
||||
mod codec_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_access_tests;
|
||||
#[cfg(test)]
|
||||
mod diff_tests;
|
||||
#[cfg(test)]
|
||||
mod entry_ids_tests;
|
||||
|
||||
@@ -23,19 +23,33 @@ pub const SIGNALS: &str = "signals";
|
||||
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
|
||||
pub const HOSTING: &str = "hosting";
|
||||
|
||||
/// The word a client puts in its [`Hello::client_version`] (or its managing connection's) when it
|
||||
/// reads [`Access::Comment`]: older ones get a comment link as a view link.
|
||||
pub const COMMENTS: &str = "comments";
|
||||
|
||||
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
|
||||
pub fn client_version() -> String {
|
||||
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
|
||||
format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Whether `client_version` announces `word`.
|
||||
fn announces(client_version: &str, word: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == word)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`SIGNALS`].
|
||||
pub fn reads_signals(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == SIGNALS)
|
||||
announces(client_version, SIGNALS)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`HOSTING`].
|
||||
pub fn reads_hosting(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == HOSTING)
|
||||
announces(client_version, HOSTING)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`COMMENTS`].
|
||||
pub fn reads_comments(client_version: &str) -> bool {
|
||||
announces(client_version, COMMENTS)
|
||||
}
|
||||
|
||||
/// Join a file's session.
|
||||
@@ -56,22 +70,72 @@ pub struct Hello {
|
||||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
/// What a participant may do: edit, or only view (a view link). A viewer gets everyone's changes
|
||||
/// and presence, and the server refuses every batch it sends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
/// What a participant may do: edit, only view (a view link), or view and comment (a comment
|
||||
/// link). A viewer gets everyone's changes and presence, and the server refuses every batch it
|
||||
/// sends; a commenter's batches are refused unless they only change comment threads. A level
|
||||
/// this build doesn't know reads as [`View`](Access::View), and a client that doesn't announce
|
||||
/// [`COMMENTS`] is welcomed with a comment link as a viewer.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Access {
|
||||
#[default]
|
||||
Edit,
|
||||
View,
|
||||
/// New levels go at the end: the binary form numbers them.
|
||||
Comment,
|
||||
}
|
||||
|
||||
/// Reads what [`Access`]'s derived `Serialize` writes (a variant's number in the binary form, its
|
||||
/// name in JSON); an unknown one reads as [`Access::View`].
|
||||
impl<'de> Deserialize<'de> for Access {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{EnumAccess, VariantAccess, Visitor};
|
||||
struct Level(Access);
|
||||
impl<'de> Deserialize<'de> for Level {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct Id;
|
||||
impl Visitor<'_> for Id {
|
||||
type Value = Level;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an access level")
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Level, E> {
|
||||
Ok(Level(match v {
|
||||
0 => Access::Edit,
|
||||
2 => Access::Comment,
|
||||
_ => Access::View,
|
||||
}))
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Level, E> {
|
||||
Ok(Level(Access::parse(v).unwrap_or(Access::View)))
|
||||
}
|
||||
}
|
||||
d.deserialize_identifier(Id)
|
||||
}
|
||||
}
|
||||
struct Unit;
|
||||
impl<'de> Visitor<'de> for Unit {
|
||||
type Value = Access;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an access level")
|
||||
}
|
||||
fn visit_enum<A: EnumAccess<'de>>(self, data: A) -> Result<Access, A::Error> {
|
||||
let (Level(a), v) = data.variant()?;
|
||||
v.unit_variant()?;
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
d.deserialize_enum("Access", &["edit", "view", "comment"], Unit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Access {
|
||||
/// `"edit"` or `"view"`.
|
||||
/// `"edit"`, `"view"` or `"comment"`.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Access::Edit => "edit",
|
||||
Access::View => "view",
|
||||
Access::Comment => "comment",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +144,16 @@ impl Access {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"edit" => Some(Access::Edit),
|
||||
"view" => Some(Access::View),
|
||||
"comment" => Some(Access::Comment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// This access as a client can read it: a comment link only views for one that doesn't
|
||||
/// announce [`COMMENTS`] (`reads_comments` false).
|
||||
pub fn for_reader(self, reads_comments: bool) -> Access {
|
||||
if self == Access::Comment && !reads_comments { Access::View } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reconnecting client tells the server in its [`Hello`].
|
||||
@@ -351,6 +422,18 @@ pub struct NewLink {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl AdminReply {
|
||||
/// This reply as a managing client can read it ([`Access::for_reader`]).
|
||||
pub fn for_reader(mut self, reads_comments: bool) -> AdminReply {
|
||||
match &mut self {
|
||||
AdminReply::Links(links) => links.iter_mut().for_each(|l| l.access = l.access.for_reader(reads_comments)),
|
||||
AdminReply::Link(l) => l.link.access = l.link.access.for_reader(reads_comments),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer to an [`Admin`] request (the first one on a connection answers [`Manage`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
+22
-10
@@ -7,7 +7,9 @@ use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
|
||||
use crate::message::{
|
||||
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
|
||||
};
|
||||
use crate::replica::{Mode, Replica, create_change};
|
||||
use crate::{Error, Limits, docprops, same_secret};
|
||||
|
||||
@@ -25,6 +27,9 @@ pub enum Outgoing {
|
||||
/// Why a viewer's batch is refused.
|
||||
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
|
||||
|
||||
/// Why a commenter's batch that changes more than comment threads is refused.
|
||||
pub const COMMENT_ONLY: &str = "you can only view and comment on this file (your link is a comment link)";
|
||||
|
||||
/// A new style, variable, collection or comment may not take an id in a space at or above this
|
||||
/// that isn't its creator's. Client ids start above every space in the document after a restart,
|
||||
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
|
||||
@@ -75,8 +80,8 @@ pub struct Server {
|
||||
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
|
||||
log_changes: usize,
|
||||
log_bytes: usize,
|
||||
/// The connected clients that may only view.
|
||||
viewers: BTreeSet<u32>,
|
||||
/// The connected clients that may not edit, and what they may do instead (view, or comment).
|
||||
limited: BTreeMap<u32, Access>,
|
||||
/// What changed the lasting state since it was last taken (`None`: not kept).
|
||||
journal: Option<Vec<Journal>>,
|
||||
/// The connected clients that read signals (cursor chat, spotlight).
|
||||
@@ -133,7 +138,7 @@ impl Server {
|
||||
log: VecDeque::new(),
|
||||
log_changes: 0,
|
||||
log_bytes: 0,
|
||||
viewers: BTreeSet::new(),
|
||||
limited: BTreeMap::new(),
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
spotlight: None,
|
||||
@@ -291,8 +296,11 @@ impl Server {
|
||||
self.join_as(hello, Access::Edit)
|
||||
}
|
||||
|
||||
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused.
|
||||
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused, and
|
||||
/// every batch of a commenter that changes more than comment threads. A client that can't
|
||||
/// read comment links joins with one as a viewer.
|
||||
pub fn join_as(&mut self, hello: Hello, access: Access) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
|
||||
let access = access.for_reader(reads_comments(&hello.client_version));
|
||||
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
|
||||
if hello.protocol != PROTOCOL {
|
||||
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
|
||||
@@ -338,10 +346,10 @@ impl Server {
|
||||
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
|
||||
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
|
||||
self.peers.insert(id, peer.clone());
|
||||
if access == Access::View {
|
||||
self.viewers.insert(id);
|
||||
if access == Access::Edit {
|
||||
self.limited.remove(&id);
|
||||
} else {
|
||||
self.viewers.remove(&id);
|
||||
self.limited.insert(id, access);
|
||||
}
|
||||
let mut outs = vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))];
|
||||
if signals {
|
||||
@@ -370,7 +378,7 @@ impl Server {
|
||||
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
|
||||
self.presence.remove(&id);
|
||||
self.acked.remove(&id);
|
||||
self.viewers.remove(&id);
|
||||
self.limited.remove(&id);
|
||||
self.signals.remove(&id);
|
||||
if self.spotlight == Some(id) {
|
||||
self.spotlight = None;
|
||||
@@ -392,7 +400,11 @@ impl Server {
|
||||
// client learned what happened to it from the catch-up.
|
||||
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
|
||||
ClientMsg::Changes { base_seq, batch_id, changes } => {
|
||||
let result = if self.viewers.contains(&from) { Err(Error::Invalid(VIEW_ONLY.into())) } else { self.accept(from, base_seq, &changes) };
|
||||
let result = match self.limited.get(&from) {
|
||||
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
|
||||
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
|
||||
_ => self.accept(from, base_seq, &changes),
|
||||
};
|
||||
if let Some(p) = self.issued.get_mut(&from) {
|
||||
p.note(batch_id, result.is_err());
|
||||
}
|
||||
|
||||
@@ -201,8 +201,9 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
}
|
||||
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only, and
|
||||
// commenting with a comment link.
|
||||
if let Some(why) = s.active_read_only().filter(|why| !crate::live::tool_left(why, t)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
}
|
||||
s.set_tool(t);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A server file joined with a comment link, against a scripted link: comments (new threads,
|
||||
//! replies, resolving, reactions) are made and sync through the server; every other edit, the
|
||||
//! editing tools and gestures are refused as with a view link, and a change made outside a command
|
||||
//! is put back unless it only touches comment threads.
|
||||
|
||||
use figcraft_collab::message::ClientMsg;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, COMMENT_ONLY, Event, Role, tool_left};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value) {
|
||||
match s.execute(cmd, ¶ms) {
|
||||
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, COMMENT_ONLY, "{cmd}"),
|
||||
other => panic!("{cmd}: expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_link_lets_you_comment_and_nothing_else() {
|
||||
let (mut s, script) = session();
|
||||
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
let mut doc = Document::new("Shared");
|
||||
let page = doc.first_page().unwrap();
|
||||
let card = doc.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::Comment);
|
||||
let mut me = info(Role::Guest, id);
|
||||
me.access = Access::Comment;
|
||||
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live().syncing());
|
||||
assert_eq!(s.active_read_only(), Some(COMMENT_ONLY));
|
||||
let summary = s.live_summary();
|
||||
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("comment"), Some(true)));
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
|
||||
// The comment tool is left, and commenting syncs through the server.
|
||||
s.execute("tool.set", &json!({"tool": "comment"})).unwrap();
|
||||
let thread = s.execute("comment.add", &json!({"x": 5, "y": 5, "text": "Bigger logo?"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("comment.reply", &json!({"id": thread, "text": "Or darker"})).unwrap();
|
||||
s.execute("comment.react", &json!({"id": thread, "emoji": "👍"})).unwrap();
|
||||
s.execute("comment.resolve", &json!({"id": thread})).unwrap();
|
||||
let answers = relay(&script, &mut server, id);
|
||||
let on_server = server.document().comment(thread).expect("the thread is on the server");
|
||||
assert!(on_server.resolved && on_server.replies.len() == 1 && !on_server.first.reactions.is_empty());
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
// Everything else is refused before anything is sent, as with a view link.
|
||||
let before = s.doc().unwrap().clone();
|
||||
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}));
|
||||
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}));
|
||||
refused(&mut s, "edit.delete", json!({"ids": [card.0]}));
|
||||
refused(&mut s, "tool.set", json!({"tool": "text"}));
|
||||
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing sent");
|
||||
// A change made outside a command: put back, unless it only touches comment threads.
|
||||
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
|
||||
s.live_sync();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
|
||||
s.ds_mut().unwrap().doc_mut().comment_mut(thread).unwrap().resolved = false;
|
||||
s.live_sync();
|
||||
relay(&script, &mut server, id);
|
||||
assert!(!server.document().comment(thread).unwrap().resolved, "sent");
|
||||
// Your other tabs stay yours.
|
||||
s.active = 0;
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_comment_link_leaves_the_comment_tool() {
|
||||
for t in [ToolId::Move, ToolId::Hand, ToolId::Comment] {
|
||||
assert!(tool_left(COMMENT_ONLY, t), "{t:?}");
|
||||
}
|
||||
assert!(!tool_left(COMMENT_ONLY, ToolId::Rectangle));
|
||||
assert!(!tool_left(crate::live::VIEW_ONLY, ToolId::Comment) && tool_left(crate::live::VIEW_ONLY, ToolId::Hand));
|
||||
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_share_has_no_comment_code() {
|
||||
let (mut s, _) = session();
|
||||
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can edit it"), "{e}");
|
||||
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
|
||||
assert!(e.contains("\"comment\""), "{e}");
|
||||
}
|
||||
@@ -235,6 +235,8 @@ mod collab_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_agent_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_link_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_mentions_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_polish_tests;
|
||||
|
||||
@@ -118,7 +118,7 @@ fn links(n: usize, codes: Option<(&str, &str)>) -> Shared {
|
||||
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
|
||||
code: code.map(str::to_string),
|
||||
};
|
||||
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
|
||||
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))), comment: None }
|
||||
}
|
||||
|
||||
/// The file is on the server and joined: its owner's links, and what others join with (codes
|
||||
@@ -264,7 +264,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null));
|
||||
assert_eq!(
|
||||
r["share"],
|
||||
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null})
|
||||
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "commentCode": null, "editLink": null, "viewLink": null, "commentLink": null})
|
||||
);
|
||||
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
|
||||
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
|
||||
|
||||
@@ -108,7 +108,7 @@ fn links(file: &str, n: usize) -> Shared {
|
||||
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
|
||||
code: Some(code),
|
||||
};
|
||||
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
|
||||
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))), comment: None }
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
|
||||
@@ -186,12 +186,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// A live session's document you may only view (or that is on its way to a server) is
|
||||
/// read-only: refuse an edit (it is rolled back).
|
||||
/// read-only: refuse an edit (it is rolled back). With a comment link, one that changes only
|
||||
/// comment threads goes through.
|
||||
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
match self.live_read_only(*uid) {
|
||||
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
|
||||
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) && !self.live_comment_edit(*uid, &ds.doc) => {
|
||||
Err(EngineError::Disabled(id.to_string(), why.into()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,24 @@ pub const NO_TEAM_LIBRARIES: &str = "team libraries on a server need the desktop
|
||||
|
||||
/// Why an edit of a server file you joined with a view link is refused.
|
||||
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
|
||||
/// Why an edit other than to comment threads of a server file you joined with a comment link is
|
||||
/// refused.
|
||||
pub const COMMENT_ONLY: &str = "Can comment: the link you joined with lets you see this file and comment on it, not change it";
|
||||
|
||||
/// Why a session document can't be edited with `access` (`None`: it can).
|
||||
pub(crate) fn limited_by(access: Access) -> Option<&'static str> {
|
||||
match access {
|
||||
Access::Edit => None,
|
||||
Access::View => Some(VIEW_ONLY),
|
||||
Access::Comment => Some(COMMENT_ONLY),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `tool` is one a file you can't edit now leaves you (`why`: what
|
||||
/// [`Session::live_read_only`] says): moving around and, with a comment link, commenting.
|
||||
pub fn tool_left(why: &str, tool: ToolId) -> bool {
|
||||
matches!(tool, ToolId::Move | ToolId::Hand) || (why == COMMENT_ONLY && tool == ToolId::Comment)
|
||||
}
|
||||
/// Why an edit of a file on its way to a server is refused.
|
||||
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::host::{clean_hosting, clean_shared};
|
||||
use super::types::{Event, PageInfo, Role, SessionId, Status};
|
||||
use super::{
|
||||
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
|
||||
ToolId, VIEW_ONLY, display_name, editing_hint, editing_of, sanitize,
|
||||
ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
@@ -410,8 +410,11 @@ impl Session {
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
// A view link: the editing tools are off.
|
||||
if client.access() == Access::View && self.tab() == Some(uid) && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
// A view or comment link: the editing tools are off.
|
||||
if let Some(why) = limited_by(client.access())
|
||||
&& self.tab() == Some(uid)
|
||||
&& !tool_left(why, self.tool.tool)
|
||||
{
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
let Some(live) = self.live.sessions.get_mut(&id) else { return };
|
||||
@@ -439,18 +442,25 @@ impl Session {
|
||||
self.message = Some(reason);
|
||||
}
|
||||
|
||||
/// Why document `uid` can't be edited now, if it can't: you may only view it in its session,
|
||||
/// or it is on its way to a server.
|
||||
/// Why document `uid` can't be edited now, if it can't: you may only view it (or view and
|
||||
/// comment on it: see [`live_comment_edit`](Self::live_comment_edit)) in its session, or it is
|
||||
/// on its way to a server.
|
||||
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
|
||||
let live = self.live.doc(uid)?;
|
||||
match &live.client {
|
||||
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
|
||||
Some(_) => None,
|
||||
Some(c) => limited_by(c.access()),
|
||||
None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `doc` is session document `uid` with nothing but comment threads changed, and the
|
||||
/// link it was joined with lets you comment: such an edit goes through where others are refused.
|
||||
pub(crate) fn live_comment_edit(&self, uid: u64, doc: &Document) -> bool {
|
||||
let client = self.live.doc(uid).and_then(|l| l.client.as_ref());
|
||||
client.is_some_and(|c| c.access() == Access::Comment && c.changes_only_comments(doc))
|
||||
}
|
||||
|
||||
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
|
||||
pub fn active_read_only(&self) -> Option<&'static str> {
|
||||
self.tab().and_then(|uid| self.live_read_only(uid))
|
||||
|
||||
@@ -82,8 +82,17 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Why a local network share has no view-only code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code";
|
||||
/// Why a local network share has no view-only or comment code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
|
||||
|
||||
/// Why a share has no comment code.
|
||||
const NO_COMMENT_LINK: &str =
|
||||
"this share has no comment code (its server is older than comment links, or it was shared before them): stop sharing and share again";
|
||||
|
||||
/// The `access` parameter of `cmd`, if given.
|
||||
fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
|
||||
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
|
||||
}
|
||||
|
||||
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.share";
|
||||
@@ -92,10 +101,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("local") => Via::Local,
|
||||
Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")),
|
||||
};
|
||||
let access = match string(p, "access") {
|
||||
None => None,
|
||||
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
|
||||
};
|
||||
let access = access_param(CMD, p)?;
|
||||
// A share of this file is there already: only which code is shown changes, or (the file is
|
||||
// on the server, not shared at the moment) it gets new links and codes. Nothing is published
|
||||
// twice.
|
||||
@@ -106,7 +112,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
|
||||
));
|
||||
}
|
||||
if access == Some(Access::View) && shared == Via::Local {
|
||||
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
}
|
||||
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
@@ -114,6 +120,9 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
shared_now(s, Some(clean_shared(links)));
|
||||
}
|
||||
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
|
||||
if sh.links.as_ref().is_some_and(|l| l.of(a).is_none()) {
|
||||
return Err(bad(CMD, NO_COMMENT_LINK));
|
||||
}
|
||||
sh.access = a;
|
||||
}
|
||||
return Ok(s.live_summary());
|
||||
@@ -121,7 +130,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let access = access.unwrap_or_default();
|
||||
let mut server = String::new();
|
||||
let id = match via {
|
||||
Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
||||
Via::Online => {
|
||||
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
||||
@@ -136,7 +145,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// what the link says: where the session is and its secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
|
||||
let code = code::Local::from_link(&i.link).map(|l| l.code());
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None }
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
|
||||
});
|
||||
live.share = Some(Share { via, server, access, links });
|
||||
}
|
||||
@@ -155,6 +164,7 @@ pub(crate) fn clean_shared(mut links: Shared) -> Shared {
|
||||
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
|
||||
clean(&mut links.edit);
|
||||
links.view.as_mut().map(clean);
|
||||
links.comment.as_mut().map(clean);
|
||||
links
|
||||
}
|
||||
|
||||
@@ -217,11 +227,7 @@ pub(super) fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false),
|
||||
"links" => Admin::Links { file: file.clone() },
|
||||
"createLink" => {
|
||||
let access = match string(p, "access").as_deref().map(str::trim) {
|
||||
None | Some("view") => Access::View,
|
||||
Some("edit") => Access::Edit,
|
||||
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
|
||||
};
|
||||
let access = access_param(CMD, p)?.unwrap_or(Access::View);
|
||||
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
|
||||
}
|
||||
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
|
||||
use super::types::{Published, Role, SessionId, Share, Status, join_target};
|
||||
use super::types::{Published, Role, SessionId, Share, Shared, Status, join_target};
|
||||
use super::{
|
||||
Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
|
||||
};
|
||||
@@ -53,7 +53,7 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
|
||||
// file. Anything else (a session this app hosts, one it joined, a file not shared now) ends.
|
||||
let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref());
|
||||
let ids: Vec<String> = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let ids: Vec<String> = shared.into_iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
|
||||
let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
|
||||
share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?;
|
||||
shared_now(s, None);
|
||||
@@ -174,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
@@ -216,7 +216,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.manageFile",
|
||||
"Manage published file",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | comment | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, commentCode, editLink, viewLink, commentLink} | {done}",
|
||||
false,
|
||||
manage_file
|
||||
),
|
||||
@@ -230,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::host::hosting_json;
|
||||
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
|
||||
use super::{
|
||||
Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
editing_of, json,
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone());
|
||||
if self.live_read_only(uid).is_some() {
|
||||
if self.live_read_only(uid).is_some() && !self.live_comment_edit(uid, &doc) {
|
||||
// Nothing goes out: what changed outside a command is put back.
|
||||
if let Some(s) = synced {
|
||||
self.apply_remote(uid, s);
|
||||
@@ -322,7 +322,8 @@ impl Session {
|
||||
"published": l.published,
|
||||
"code": l.share.as_ref().and_then(Share::code),
|
||||
"share": l.share.as_ref().map(|sh| {
|
||||
let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref()));
|
||||
let of = |a| sh.links.as_ref().and_then(|s| s.of(a));
|
||||
let (edit, view, comment) = (of(Access::Edit), of(Access::View), of(Access::Comment));
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"via": sh.via,
|
||||
@@ -330,8 +331,10 @@ impl Session {
|
||||
"shared": sh.links.is_some(),
|
||||
"editCode": code(edit),
|
||||
"viewCode": code(view),
|
||||
"commentCode": code(comment),
|
||||
"editLink": edit.map(|l| l.link.as_str()),
|
||||
"viewLink": view.map(|l| l.link.as_str()),
|
||||
"commentLink": comment.map(|l| l.link.as_str()),
|
||||
})
|
||||
}),
|
||||
"hosting": l.hosting.as_ref().map(hosting_json),
|
||||
|
||||
@@ -117,7 +117,8 @@ pub struct SessionInfo {
|
||||
pub address: String,
|
||||
/// Reachable from other machines (the host listens on a network address, not loopback).
|
||||
pub network: bool,
|
||||
/// What you may do: edit, or only view (a server's view link).
|
||||
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
|
||||
/// link).
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
@@ -164,12 +165,31 @@ pub struct SharedLink {
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
/// What others join a shared file with: to edit it and, on a server, to only view it.
|
||||
/// What others join a shared file with: to edit it and, on a server, to only view it or to view
|
||||
/// and comment on it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Shared {
|
||||
pub edit: SharedLink,
|
||||
pub view: Option<SharedLink>,
|
||||
/// `None`: a server older than comment links, or a share made before them.
|
||||
pub comment: Option<SharedLink>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// The way in that gives `access`, if there is one.
|
||||
pub fn of(&self, access: Access) -> Option<&SharedLink> {
|
||||
match access {
|
||||
Access::Edit => Some(&self.edit),
|
||||
Access::View => self.view.as_ref(),
|
||||
Access::Comment => self.comment.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way in (what stopping revokes).
|
||||
pub fn all(&self) -> impl Iterator<Item = &SharedLink> {
|
||||
std::iter::once(&self.edit).chain(&self.view).chain(&self.comment)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the
|
||||
@@ -190,8 +210,7 @@ pub struct Share {
|
||||
impl Share {
|
||||
/// The way in to show: the one for [`access`](Self::access).
|
||||
fn shown(&self) -> Option<&SharedLink> {
|
||||
let links = self.links.as_ref()?;
|
||||
if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) }
|
||||
self.links.as_ref()?.of(self.access)
|
||||
}
|
||||
|
||||
/// The code to show, grouped (`K7M-2QX-9TD`).
|
||||
|
||||
@@ -161,6 +161,7 @@ impl OnlineFile {
|
||||
let usable = |l: &SharedLink| link(&l.link) && l.id.len() <= 64 && !l.id.contains(char::is_control);
|
||||
self.shared = self.shared.take().filter(|s| usable(&s.edit)).map(|mut s| {
|
||||
s.view = s.view.take().filter(usable);
|
||||
s.comment = s.comment.take().filter(usable);
|
||||
clean_shared(s)
|
||||
});
|
||||
Some(self)
|
||||
@@ -384,9 +385,7 @@ impl Session {
|
||||
let Some(f) = self.prefs.online_files.get_mut(i) else { return };
|
||||
let before = (f.name.clone(), f.shared.is_some());
|
||||
// Links revoked from elsewhere no longer let anyone in.
|
||||
let live = |f: &OnlineFile, o: &OwnedFile| {
|
||||
f.shared.as_ref().is_some_and(|s| o.links.contains(&s.edit.id) && s.view.as_ref().is_none_or(|v| o.links.contains(&v.id)))
|
||||
};
|
||||
let live = |f: &OnlineFile, o: &OwnedFile| f.shared.as_ref().is_some_and(|s| s.all().all(|l| o.links.contains(&l.id)));
|
||||
let shared = match answer {
|
||||
Some(o) => {
|
||||
f.state = FileState::Online;
|
||||
@@ -429,6 +428,7 @@ impl Session {
|
||||
"shared": f.shared.is_some(),
|
||||
"code": code(f.shared.as_ref().map(|s| &s.edit)),
|
||||
"viewCode": code(f.shared.as_ref().and_then(|s| s.view.as_ref())),
|
||||
"commentCode": code(f.shared.as_ref().and_then(|s| s.comment.as_ref())),
|
||||
"state": f.state,
|
||||
"expires": f.expires,
|
||||
"session": session.map(|l| l.id),
|
||||
@@ -590,14 +590,16 @@ pub(crate) fn removed(s: &mut Session, server: &str, file: &str) {
|
||||
pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, on: bool) -> Result<Value> {
|
||||
let shared = s.online_index(server, file).and_then(|i| s.prefs.online_files.get(i)).and_then(|f| f.shared.clone());
|
||||
let result = |links: Option<&Shared>| {
|
||||
let (edit, view) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()));
|
||||
let (edit, view, comment) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()), links.and_then(|l| l.comment.as_ref()));
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"shared": links.is_some(),
|
||||
"code": code(edit),
|
||||
"viewCode": code(view),
|
||||
"commentCode": code(comment),
|
||||
"editLink": edit.map(|l| l.link.as_str()),
|
||||
"viewLink": view.map(|l| l.link.as_str()),
|
||||
"commentLink": comment.map(|l| l.link.as_str()),
|
||||
})
|
||||
};
|
||||
// As asked already: nothing to tell the server.
|
||||
@@ -607,7 +609,7 @@ pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, o
|
||||
if !on && shared.is_none() {
|
||||
return Err(bad(cmd, "the links others got aren't recorded on this computer: revoke them by id (action: links, then revoke)"));
|
||||
}
|
||||
let revoke: Vec<String> = shared.iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let revoke: Vec<String> = shared.iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
|
||||
let req = NetRequest::Share { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), revoke, create: on };
|
||||
let links = crate::live::share_answer(s, req)?.map(clean_shared);
|
||||
if on && links.is_none() {
|
||||
@@ -622,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.files",
|
||||
"Your online files",
|
||||
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode (the join codes, while shared), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
|
||||
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode, commentCode (the join codes, while shared; commentCode null from a server older than comment links), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
|
||||
false,
|
||||
files
|
||||
),
|
||||
|
||||
@@ -127,6 +127,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "يتيح رابط التحرير تغيير الملف، ويتيح رابط العرض الاطلاع عليه فقط. تتضمن الروابط مفاتيحها السرية؛ شاركها ككلمات المرور. يُنشئ `figcraft-server links` روابط أخرى ويلغيها."
|
||||
"Anyone with this code can edit the file." = "يمكن لأي شخص يحمل هذا الرمز تحرير الملف."
|
||||
"Anyone with this code can look at the file, not change it." = "يمكن لأي شخص يحمل هذا الرمز عرض الملف دون تغييره."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "يمكن لأي شخص يحمل هذا الرمز عرض الملف والتعليق عليه دون تغييره."
|
||||
"Appearance" = "المظهر"
|
||||
"Apply a component property" = "تطبيق خاصية مكوّن"
|
||||
"Apply variable" = "تطبيق متغير"
|
||||
@@ -220,6 +221,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C أو ⇧C"
|
||||
"CMYK colour" = "لون CMYK"
|
||||
"Can view" = "يمكنه العرض"
|
||||
"Can comment" = "يمكنه التعليق"
|
||||
"Check" = "تحقق"
|
||||
"Check for Updates" = "التحقق من التحديثات"
|
||||
"Check for updates…" = "التحقق من التحديثات…"
|
||||
@@ -1601,6 +1603,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "نُسخ رابط العرض"
|
||||
"View only" = "للعرض فقط"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "للعرض فقط: ترى كل تغيير فور حدوثه دون إمكانية التحرير. غادر للاحتفاظ بنسخة يمكنك تحريرها."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "يمكنك التعليق: ترى كل تغيير فور حدوثه ويمكنك التعليق دون إمكانية التحرير. غادر للاحتفاظ بنسخة يمكنك تحريرها."
|
||||
"Viewing “{name}”" = "جارٍ عرض «{name}»"
|
||||
"Violet" = "بنفسجي"
|
||||
"Visibility" = "الظهور"
|
||||
|
||||
@@ -121,6 +121,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "এডিট লিংক থাকা যেকোনো ব্যক্তি ফাইলটি পরিবর্তন করতে পারবেন; ভিউ লিংক শুধু দেখতে দেয়। লিংকগুলো গোপন তথ্য বহন করে: পাসওয়ার্ডের মতো এগুলো শেয়ার করুন। `figcraft-server links` কমান্ড দিয়ে আরও তৈরি ও বাতিল করা যায়।"
|
||||
"Anyone with this code can edit the file." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।"
|
||||
"Anyone with this code can look at the file, not change it." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি দেখতে পারবেন, পরিবর্তন করতে পারবেন না।"
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি দেখতে ও তাতে মন্তব্য করতে পারবেন, পরিবর্তন করতে পারবেন না।"
|
||||
"Appearance" = "চেহারা"
|
||||
"Apply a component property" = "একটি কম্পোনেন্ট প্রপার্টি প্রয়োগ করুন"
|
||||
"Apply variable" = "ভেরিয়েবল প্রয়োগ করুন"
|
||||
@@ -212,6 +213,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C বা ⇧C"
|
||||
"CMYK colour" = "CMYK রঙ"
|
||||
"Can view" = "দেখতে পারেন"
|
||||
"Can comment" = "মন্তব্য করতে পারেন"
|
||||
"Check" = "টিক"
|
||||
"Checking the fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্টগুলো চেক করা হচ্ছে..."
|
||||
"Checking the server…" = "সার্ভার চেক করা হচ্ছে..."
|
||||
@@ -1547,6 +1549,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "ভিউ লিংক কপি করা হয়েছে"
|
||||
"View only" = "শুধুমাত্র দেখার উপযোগী"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "শুধুমাত্র দেখার উপযোগী: আপনি প্রতিটি পরিবর্তন ঘটার সাথে সাথে দেখতে পান কিন্তু এডিট করতে পারেন না। আপনি এডিট করতে পারেন এমন একটি কপি রাখতে ত্যাগ করুন।"
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "মন্তব্য করতে পারেন: আপনি প্রতিটি পরিবর্তন ঘটার সাথে সাথে দেখতে ও মন্তব্য করতে পারেন, কিন্তু এডিট করতে পারেন না। আপনি এডিট করতে পারেন এমন একটি কপি রাখতে ত্যাগ করুন।"
|
||||
"Viewing “{name}”" = "“{name}” দেখা হচ্ছে"
|
||||
"Violet" = "বেগুনি"
|
||||
"Visibility" = "দৃশ্যমানতা"
|
||||
|
||||
@@ -115,6 +115,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kdokoli s odkazem pro úpravy může soubor měnit; odkaz pro prohlížení umožňuje pouze prohlížení. Odkazy obsahují tajné údaje: sdílejte je jako hesla. Příkaz `figcraft-server links` vytváří další odkazy a odvolává je."
|
||||
"Anyone with this code can edit the file." = "Kdokoli s tímto kódem může soubor upravovat."
|
||||
"Anyone with this code can look at the file, not change it." = "Kdokoli s tímto kódem může soubor prohlížet, ale nemůže ho měnit."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Kdokoli s tímto kódem může soubor prohlížet a komentovat, ale nemůže ho měnit."
|
||||
"Appearance" = "Vzhled"
|
||||
"Apply a component property" = "Použít vlastnost komponenty"
|
||||
"Apply variable" = "Použít proměnnou"
|
||||
@@ -206,6 +207,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C nebo ⇧C"
|
||||
"CMYK colour" = "Barva CMYK"
|
||||
"Can view" = "Může prohlížet"
|
||||
"Can comment" = "Může komentovat"
|
||||
"Check" = "Fajfka"
|
||||
"Checking the server…" = "Kontrola serveru…"
|
||||
"Chevron" = "Lomená šipka"
|
||||
@@ -1514,6 +1516,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Odkaz pro prohlížení zkopírován"
|
||||
"View only" = "Pouze prohlížení"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Pouze prohlížení: vidíte každou změnu průběžně, ale nemůžete upravovat. Opuštěním relace si ponecháte kopii, kterou můžete upravovat."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Může komentovat: vidíte každou změnu průběžně a můžete komentovat, ale nemůžete upravovat. Opuštěním relace si ponecháte kopii, kterou můžete upravovat."
|
||||
"Viewing “{name}”" = "Prohlížení „{name}“"
|
||||
"Violet" = "Fialová"
|
||||
"Visibility" = "Viditelnost"
|
||||
|
||||
@@ -115,6 +115,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Jede Person mit dem Bearbeitungslink kann die Datei ändern; der Ansichtslink erlaubt nur das Ansehen. Die Links enthalten ihre Geheimnisse: Teilen Sie sie wie Passwörter. `figcraft-server links` erzeugt weitere und widerruft sie."
|
||||
"Anyone with this code can edit the file." = "Jede Person mit diesem Code kann die Datei bearbeiten."
|
||||
"Anyone with this code can look at the file, not change it." = "Jede Person mit diesem Code kann die Datei ansehen, aber nicht ändern."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Jede Person mit diesem Code kann die Datei ansehen und kommentieren, aber nicht ändern."
|
||||
"Appearance" = "Darstellung"
|
||||
"Apply a component property" = "Komponenteneigenschaft anwenden"
|
||||
"Apply variable" = "Variable anwenden"
|
||||
@@ -202,6 +203,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C oder ⇧C"
|
||||
"CMYK colour" = "CMYK-Farbe"
|
||||
"Can view" = "Kann ansehen"
|
||||
"Can comment" = "Kann kommentieren"
|
||||
"Check" = "Häkchen"
|
||||
"Checking the server…" = "Server wird geprüft…"
|
||||
"Chevron" = "Chevron"
|
||||
@@ -1476,6 +1478,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Ansichtslink kopiert"
|
||||
"View only" = "Nur ansehen"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Nur ansehen: Sie sehen jede Änderung live, können aber nicht bearbeiten. Verlassen Sie die Sitzung, um eine bearbeitbare Kopie zu behalten."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Kann kommentieren: Sie sehen jede Änderung live und können kommentieren, aber nicht bearbeiten. Verlassen Sie die Sitzung, um eine bearbeitbare Kopie zu behalten."
|
||||
"Viewing “{name}”" = "Ansicht von „{name}“"
|
||||
"Violet" = "Violett"
|
||||
"Visibility" = "Sichtbarkeit"
|
||||
|
||||
@@ -123,6 +123,7 @@ name = "English"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them."
|
||||
"Anyone with this code can edit the file." = "Anyone with this code can edit the file."
|
||||
"Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Anyone with this code can look at the file and comment on it, not change it."
|
||||
"Appearance" = "Appearance"
|
||||
"Apply a component property" = "Apply a component property"
|
||||
"Apply variable" = "Apply variable"
|
||||
@@ -217,6 +218,7 @@ name = "English"
|
||||
"C or ⇧C" = "C or ⇧C"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Can view" = "Can view"
|
||||
"Can comment" = "Can comment"
|
||||
"Check" = "Check"
|
||||
"Check for Updates" = "Check for Updates"
|
||||
"Check for updates…" = "Check for updates…"
|
||||
@@ -1616,6 +1618,7 @@ name = "English"
|
||||
"View link copied" = "View link copied"
|
||||
"View only" = "View only"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit."
|
||||
"Viewing “{name}”" = "Viewing “{name}”"
|
||||
"Violet" = "Violet"
|
||||
"Visibility" = "Visibility"
|
||||
|
||||
@@ -139,6 +139,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Cualquiera con el enlace de edición puede cambiar el archivo; el enlace de lectura solo permite verlo. Los enlaces llevan sus claves secretas: compártelos como contraseñas. `figcraft-server links` crea más y los revoca."
|
||||
"Anyone with this code can edit the file." = "Cualquiera con este código puede editar el archivo."
|
||||
"Anyone with this code can look at the file, not change it." = "Cualquiera con este código puede ver el archivo, no cambiarlo."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Cualquiera con este código puede ver el archivo y comentarlo, no cambiarlo."
|
||||
"Appearance" = "Apariencia"
|
||||
"Apply a component property" = "Aplicar una propiedad de componente"
|
||||
"Apply variable" = "Aplicar variable"
|
||||
@@ -228,6 +229,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C o ⇧C"
|
||||
"CMYK colour" = "Color CMYK"
|
||||
"Can view" = "Puede ver"
|
||||
"Can comment" = "Puede comentar"
|
||||
"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…"
|
||||
"Checking the server…" = "Comprobando el servidor…"
|
||||
"Clear from this list" = "Borrar de esta lista"
|
||||
@@ -1590,6 +1592,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Enlace de lectura copiado"
|
||||
"View only" = "Solo lectura"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Solo lectura: ves cada cambio al momento, pero no puedes editar. Sal de la sesión para quedarte con una copia editable."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Puede comentar: ves cada cambio al momento y puedes comentar, pero no editar. Sal de la sesión para quedarte con una copia editable."
|
||||
"Viewing “{name}”" = "Viendo “{name}”"
|
||||
"Violet" = "Violeta"
|
||||
"Visibility" = "Visibilidad"
|
||||
|
||||
@@ -122,6 +122,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kuka tahansa muokkauslinkin saanut voi muuttaa tiedostoa. Katselulinkillä voi vain katsella. Linkit sisältävät salaiset tunnisteet: jaa niitä kuin salasanoja. `figcraft-server links` luo lisää linkkejä ja peruu niitä."
|
||||
"Anyone with this code can edit the file." = "Kuka tahansa tämän koodin saanut voi muokata tiedostoa."
|
||||
"Anyone with this code can look at the file, not change it." = "Kuka tahansa tämän koodin saanut voi katsella tiedostoa, mutta ei muuttaa sitä."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Kuka tahansa tämän koodin saanut voi katsella ja kommentoida tiedostoa, mutta ei muuttaa sitä."
|
||||
"Appearance" = "Ulkoasu"
|
||||
"Apply a component property" = "Käytä komponentin ominaisuutta"
|
||||
"Apply variable" = "Käytä muuttujaa"
|
||||
@@ -215,6 +216,7 @@ status = "draft — needs native review"
|
||||
"CSS custom properties…" = "CSS:n mukautetut ominaisuudet…"
|
||||
"Can edit" = "Voi muokata"
|
||||
"Can view" = "Voi katsella"
|
||||
"Can comment" = "Voi kommentoida"
|
||||
"Can't read this library: {e}" = "Kirjastoa ei voi lukea: {e}"
|
||||
"Cancel" = "Peruuta"
|
||||
"Canvas" = "Piirtoalue"
|
||||
@@ -1552,6 +1554,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Katselulinkki kopioitu"
|
||||
"View only" = "Vain katselu"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Vain katselu: näet kaikki muutokset reaaliajassa, mutta et voi muokata. Poistu säilyttääksesi muokattavan kopion."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Voi kommentoida: näet kaikki muutokset reaaliajassa ja voit kommentoida, mutta et muokata. Poistu säilyttääksesi muokattavan kopion."
|
||||
"Viewing “{name}”" = "Katsellaan tiedostoa ”{name}”"
|
||||
"Violet" = "Violetti"
|
||||
"Visibility" = "Näkyvyys"
|
||||
|
||||
@@ -120,6 +120,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Toute personne disposant du lien de modification peut modifier le fichier ; le lien de consultation permet seulement de le regarder. Les liens contiennent leurs secrets : partagez-les comme des mots de passe. `figcraft-server links` en crée d’autres et les révoque."
|
||||
"Anyone with this code can edit the file." = "Toute personne disposant de ce code peut modifier le fichier."
|
||||
"Anyone with this code can look at the file, not change it." = "Toute personne disposant de ce code peut consulter le fichier, pas le modifier."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Toute personne disposant de ce code peut consulter le fichier et le commenter, pas le modifier."
|
||||
"Appearance" = "Apparence"
|
||||
"Apply a component property" = "Appliquer une propriété de composant"
|
||||
"Apply variable" = "Appliquer une variable"
|
||||
@@ -211,6 +212,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C ou ⇧C"
|
||||
"CMYK colour" = "Couleur CMJN"
|
||||
"Can view" = "Peut consulter"
|
||||
"Can comment" = "Peut commenter"
|
||||
"Check" = "Coche"
|
||||
"Checking the server…" = "Vérification auprès du serveur…"
|
||||
"Chevron" = "Chevron"
|
||||
@@ -1553,6 +1555,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Lien de consultation copié"
|
||||
"View only" = "Consultation seule"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Consultation seule : vous voyez chaque modification en direct sans pouvoir modifier. Quittez la session pour garder une copie modifiable."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Peut commenter : vous voyez chaque modification en direct et pouvez commenter, sans pouvoir modifier. Quittez la session pour garder une copie modifiable."
|
||||
"Viewing “{name}”" = "Consultation de « {name} »"
|
||||
"Violet" = "Violet"
|
||||
"Visibility" = "Visibilité"
|
||||
|
||||
@@ -123,6 +123,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Siapa pun dengan link edit dapat mengubah file; link lihat hanya memungkinkan orang melihat. Link ini berisi kunci rahasia: bagikan seperti kata sandi. `figcraft-server links` membuat link tambahan dan mencabutnya."
|
||||
"Anyone with this code can edit the file." = "Siapa pun dengan kode ini dapat mengedit file."
|
||||
"Anyone with this code can look at the file, not change it." = "Siapa pun dengan kode ini dapat melihat file, tetapi tidak mengubahnya."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Siapa pun dengan kode ini dapat melihat file dan mengomentarinya, tetapi tidak mengubahnya."
|
||||
"Appearance" = "Tampilan"
|
||||
"Apply a component property" = "Terapkan properti component"
|
||||
"Apply variable" = "Terapkan variabel"
|
||||
@@ -214,6 +215,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C atau ⇧C"
|
||||
"CMYK colour" = "Warna CMYK"
|
||||
"Can view" = "Dapat melihat"
|
||||
"Can comment" = "Dapat berkomentar"
|
||||
"Check" = "Centang"
|
||||
"Checking the server…" = "Memeriksa server…"
|
||||
"Chevron" = "Chevron"
|
||||
@@ -1519,6 +1521,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Link lihat disalin"
|
||||
"View only" = "Hanya lihat"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Hanya lihat: Anda melihat setiap perubahan saat terjadi tetapi tidak dapat mengedit. Keluar untuk menyimpan salinan yang dapat diedit."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Dapat berkomentar: Anda melihat setiap perubahan saat terjadi dan dapat berkomentar, tetapi tidak dapat mengedit. Keluar untuk menyimpan salinan yang dapat diedit."
|
||||
"Viewing “{name}”" = "Melihat “{name}”"
|
||||
"Violet" = "Ungu"
|
||||
"Visibility" = "Visibilitas"
|
||||
|
||||
@@ -111,6 +111,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Chiunque abbia il link di modifica può cambiare il file; il link di visualizzazione permette solo di guardare. I link contengono i loro segreti: condividili come password. `figcraft-server links` ne crea altri e li revoca."
|
||||
"Anyone with this code can edit the file." = "Chiunque abbia questo codice può modificare il file."
|
||||
"Anyone with this code can look at the file, not change it." = "Chiunque abbia questo codice può vedere il file, ma non modificarlo."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Chiunque abbia questo codice può vedere il file e commentarlo, ma non modificarlo."
|
||||
"Appearance" = "Aspetto"
|
||||
"Apply a component property" = "Applica una proprietà del componente"
|
||||
"Apply variable" = "Applica variabile"
|
||||
@@ -200,6 +201,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C o ⇧C"
|
||||
"CMYK colour" = "Colore CMYK"
|
||||
"Can view" = "Può visualizzare"
|
||||
"Can comment" = "Può commentare"
|
||||
"Checking the fonts installed on this computer…" = "Controllo dei font installati su questo computer…"
|
||||
"Checking the server…" = "Controllo del server…"
|
||||
"Clear from this list" = "Rimuovi da questo elenco"
|
||||
@@ -1495,6 +1497,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Link di visualizzazione copiato"
|
||||
"View only" = "Sola visualizzazione"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Sola visualizzazione: vedi ogni modifica mentre avviene ma non puoi modificare. Esci per conservare una copia modificabile."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Può commentare: vedi ogni modifica mentre avviene e puoi commentare, ma non modificare. Esci per conservare una copia modificabile."
|
||||
"Viewing “{name}”" = "Visualizzazione di “{name}”"
|
||||
"Violet" = "Viola"
|
||||
"Visibility" = "Visibilità"
|
||||
|
||||
@@ -121,6 +121,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "編集リンクを持つ人はファイルを変更でき、閲覧リンクでは表示のみ可能。リンクには秘密情報が含まれるため、パスワードと同様に扱うこと。リンクの追加と取り消しは `figcraft-server links` で行う。"
|
||||
"Anyone with this code can edit the file." = "このコードを持つ人は誰でもファイルを編集可能。"
|
||||
"Anyone with this code can look at the file, not change it." = "このコードを持つ人は誰でもファイルを閲覧可能(変更は不可)。"
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "このコードを持つ人は誰でもファイルを閲覧・コメント可能(変更は不可)。"
|
||||
"Appearance" = "外観"
|
||||
"Apply a component property" = "コンポーネントプロパティを適用"
|
||||
"Apply variable" = "変数を適用"
|
||||
@@ -212,6 +213,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C または ⇧C"
|
||||
"CMYK colour" = "CMYK カラー"
|
||||
"Can view" = "閲覧可"
|
||||
"Can comment" = "コメント可"
|
||||
"Check" = "チェック"
|
||||
"Checking the server…" = "サーバーを確認中…"
|
||||
"Chevron" = "シェブロン"
|
||||
@@ -1535,6 +1537,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "閲覧リンクをコピー済み"
|
||||
"View only" = "閲覧のみ"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "閲覧のみ:すべての変更をリアルタイムで見られるが、編集はできない。退出すると編集可能なコピーが残る。"
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "コメント可:すべての変更をリアルタイムで見てコメントできるが、編集はできない。退出すると編集可能なコピーが残る。"
|
||||
"Viewing “{name}”" = "「{name}」を表示中"
|
||||
"Violet" = "紫"
|
||||
"Visibility" = "表示"
|
||||
|
||||
@@ -123,6 +123,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "რედაქტირების ბმულის მქონე ნებისმიერს შეუძლია ფაილის შეცვლა; ნახვის ბმული მხოლოდ დათვალიერების საშუალებას იძლევა. ბმულები საიდუმლო გასაღებებს შეიცავს: გააზიარეთ ისინი პაროლების მსგავსად. `figcraft-server links` ქმნის ახალ ბმულებს და აუქმებს მათ."
|
||||
"Anyone with this code can edit the file." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის რედაქტირება."
|
||||
"Anyone with this code can look at the file, not change it." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის ნახვა, მაგრამ არა შეცვლა."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის ნახვა და კომენტარის დატოვება, მაგრამ არა შეცვლა."
|
||||
"Appearance" = "იერსახე"
|
||||
"Apply a component property" = "კომპონენტის თვისების გამოყენება"
|
||||
"Apply variable" = "ცვლადის გამოყენება"
|
||||
@@ -215,6 +216,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"C or ⇧C" = "C ან ⇧C"
|
||||
"CMYK colour" = "CMYK ფერი"
|
||||
"Can view" = "შეუძლია ნახვა"
|
||||
"Can comment" = "შეუძლია კომენტარი"
|
||||
"Check" = "ნიშნული"
|
||||
"Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…"
|
||||
"Checking the server…" = "სერვერის შემოწმება…"
|
||||
@@ -1571,6 +1573,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"View link copied" = "ნახვის ბმული დაკოპირდა"
|
||||
"View only" = "მხოლოდ ნახვა"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "მხოლოდ ნახვა: ყოველ ცვლილებას ხედავთ მისი მოხდენისთანავე, მაგრამ რედაქტირება არ შეგიძლიათ. რედაქტირებადი ასლის შესანარჩუნებლად გადით სესიიდან."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "შეუძლია კომენტარი: ყოველ ცვლილებას ხედავთ მისი მოხდენისთანავე და შეგიძლიათ კომენტარის დატოვება, მაგრამ რედაქტირება არ შეგიძლიათ. რედაქტირებადი ასლის შესანარჩუნებლად გადით სესიიდან."
|
||||
"Viewing “{name}”" = "დათვალიერება: „{name}“"
|
||||
"Violet" = "იისფერი"
|
||||
"Visibility" = "ხილვადობა"
|
||||
|
||||
@@ -115,6 +115,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "편집 링크가 있으면 파일을 변경할 수 있고, 보기 링크로는 보기만 가능. 링크에는 비밀 정보가 담겨 있으므로 비밀번호처럼 공유하세요. 링크 추가와 취소는 `figcraft-server links`로 가능."
|
||||
"Anyone with this code can edit the file." = "이 코드를 가진 사람은 누구나 파일을 편집할 수 있음."
|
||||
"Anyone with this code can look at the file, not change it." = "이 코드를 가진 사람은 누구나 파일을 볼 수 있지만 변경할 수는 없음."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "이 코드를 가진 사람은 누구나 파일을 보고 댓글을 달 수 있지만 변경할 수는 없음."
|
||||
"Appearance" = "모양"
|
||||
"Apply a component property" = "컴포넌트 속성 적용"
|
||||
"Apply variable" = "변수 적용"
|
||||
@@ -202,6 +203,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C 또는 ⇧C"
|
||||
"CMYK colour" = "CMYK 색상"
|
||||
"Can view" = "보기 가능"
|
||||
"Can comment" = "댓글 가능"
|
||||
"Check" = "체크"
|
||||
"Checking the server…" = "서버 확인 중…"
|
||||
"Chevron" = "셰브런"
|
||||
@@ -1467,6 +1469,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "보기 링크 복사됨"
|
||||
"View only" = "보기 전용"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "보기 전용: 모든 변경을 실시간으로 볼 수 있지만 편집은 불가. 나가면 편집 가능한 사본이 남음."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "댓글 가능: 모든 변경을 실시간으로 보고 댓글을 달 수 있지만 편집은 불가. 나가면 편집 가능한 사본이 남음."
|
||||
"Viewing “{name}”" = "\"{name}\" 보는 중"
|
||||
"Violet" = "보라"
|
||||
"Visibility" = "표시 여부"
|
||||
|
||||
@@ -122,6 +122,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Iedereen met de bewerklink kan het bestand wijzigen; de weergavelink laat mensen alleen kijken. De links bevatten geheimen: deel ze alsof het wachtwoorden zijn. Met `figcraft-server links` maak je er meer en trek je ze in."
|
||||
"Anyone with this code can edit the file." = "Iedereen met deze code kan het bestand bewerken."
|
||||
"Anyone with this code can look at the file, not change it." = "Iedereen met deze code kan het bestand bekijken, maar niet wijzigen."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Iedereen met deze code kan het bestand bekijken en erop reageren, maar niet wijzigen."
|
||||
"Appearance" = "Weergave"
|
||||
"Apply a component property" = "Een componenteigenschap toepassen"
|
||||
"Apply variable" = "Variabele toepassen"
|
||||
@@ -215,6 +216,7 @@ status = "draft — needs native review"
|
||||
"CSS custom properties…" = "CSS-variabelen…"
|
||||
"Can edit" = "Kan bewerken"
|
||||
"Can view" = "Kan bekijken"
|
||||
"Can comment" = "Kan reageren"
|
||||
"Can't read this library: {e}" = "Kan deze bibliotheek niet lezen: {e}"
|
||||
"Cancel" = "Annuleren"
|
||||
"Canvas" = "Canvas"
|
||||
@@ -1552,6 +1554,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Weergavelink gekopieerd"
|
||||
"View only" = "Alleen weergeven"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Alleen weergeven: je ziet elke wijziging live terwijl die plaatsvindt, maar je kunt niet bewerken. Verlaat de sessie om een kopie te bewaren die je kunt bewerken."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Kan reageren: je ziet elke wijziging live terwijl die plaatsvindt en kunt reageren, maar niet bewerken. Verlaat de sessie om een kopie te bewaren die je kunt bewerken."
|
||||
"Viewing “{name}”" = "“{name}” bekijken"
|
||||
"Violet" = "Violet"
|
||||
"Visibility" = "Zichtbaarheid"
|
||||
|
||||
@@ -120,6 +120,7 @@ name = "Polski"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Każdy z linkiem do edycji może modyfikować plik; link do podglądu pozwala tylko oglądać. Linki zawierają tokeny: traktuj je jak hasła. Polecenie `figcraft-server links` tworzy i unieważnia linki."
|
||||
"Anyone with this code can edit the file." = "Każdy z tym kodem może edytować plik."
|
||||
"Anyone with this code can look at the file, not change it." = "Każdy z tym kodem może przeglądać plik bez możliwości zmian."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Każdy z tym kodem może przeglądać i komentować plik bez możliwości zmian."
|
||||
"Appearance" = "Wygląd"
|
||||
"Apply a component property" = "Zastosuj właściwość komponentu"
|
||||
"Apply variable" = "Zastosuj zmienną"
|
||||
@@ -212,6 +213,7 @@ name = "Polski"
|
||||
"C or ⇧C" = "C lub ⇧C"
|
||||
"CMYK colour" = "Kolor CMYK"
|
||||
"Can view" = "Może przeglądać"
|
||||
"Can comment" = "Może komentować"
|
||||
"Check" = "Ptaszek"
|
||||
"Checking the fonts installed on this computer…" = "Sprawdzanie czcionek zainstalowanych na tym komputerze…"
|
||||
"Checking the server…" = "Sprawdzanie serwera…"
|
||||
@@ -1562,6 +1564,7 @@ name = "Polski"
|
||||
"View link copied" = "Skopiowano link do podglądu"
|
||||
"View only" = "Tylko podgląd"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Tylko podgląd: widzisz każdą zmianę w czasie rzeczywistym, ale nie możesz edytować. Opuść sesję, aby zachować kopię do edycji."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Może komentować: widzisz każdą zmianę w czasie rzeczywistym i możesz komentować, ale nie możesz edytować. Opuść sesję, aby zachować kopię do edycji."
|
||||
"Viewing “{name}”" = "Podglądanie „{name}”"
|
||||
"Violet" = "Fiolet"
|
||||
"Visibility" = "Widoczność"
|
||||
|
||||
@@ -113,6 +113,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Qualquer pessoa com o link de edição pode alterar o arquivo; o link de visualização só permite ver. Os links carregam seus segredos: compartilhe-os como senhas. `figcraft-server links` cria outros e os revoga."
|
||||
"Anyone with this code can edit the file." = "Qualquer pessoa com este código pode editar o arquivo."
|
||||
"Anyone with this code can look at the file, not change it." = "Qualquer pessoa com este código pode ver o arquivo, não alterá-lo."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Qualquer pessoa com este código pode ver o arquivo e comentar nele, não alterá-lo."
|
||||
"Appearance" = "Aparência"
|
||||
"Apply a component property" = "Aplicar uma propriedade de componente"
|
||||
"Apply variable" = "Aplicar variável"
|
||||
@@ -200,6 +201,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C ou ⇧C"
|
||||
"CMYK colour" = "Cor CMYK"
|
||||
"Can view" = "Pode visualizar"
|
||||
"Can comment" = "Pode comentar"
|
||||
"Check" = "Visto"
|
||||
"Checking the server…" = "Verificando o servidor…"
|
||||
"Chevron" = "Divisa"
|
||||
@@ -1465,6 +1467,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Link de visualização copiado"
|
||||
"View only" = "Somente visualização"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Somente visualização: você vê cada alteração em tempo real, mas não pode editar. Saia da sessão para ficar com uma cópia editável."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Pode comentar: você vê cada alteração em tempo real e pode comentar, mas não pode editar. Saia da sessão para ficar com uma cópia editável."
|
||||
"Viewing “{name}”" = "Visualizando “{name}”"
|
||||
"Violet" = "Violeta"
|
||||
"Visibility" = "Visibilidade"
|
||||
|
||||
@@ -120,6 +120,7 @@ name = "Русский"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "По ссылке для редактирования можно изменять файл, по ссылке для просмотра — только смотреть. Ссылки содержат секреты: передавайте их как пароли. `figcraft-server links` создаёт новые ссылки и отзывает старые."
|
||||
"Anyone with this code can edit the file." = "Любой с этим кодом может редактировать файл."
|
||||
"Anyone with this code can look at the file, not change it." = "Любой с этим кодом может просматривать файл без возможности редактирования."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Любой с этим кодом может просматривать и комментировать файл без возможности редактирования."
|
||||
"Appearance" = "Внешний вид"
|
||||
"Apply a component property" = "Применить свойство компонента"
|
||||
"Apply variable" = "Применить переменную"
|
||||
@@ -212,6 +213,7 @@ name = "Русский"
|
||||
"C or ⇧C" = "C или ⇧C"
|
||||
"CMYK colour" = "Цвет CMYK"
|
||||
"Can view" = "Может просматривать"
|
||||
"Can comment" = "Может комментировать"
|
||||
"Check" = "Галочка"
|
||||
"Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…"
|
||||
"Checking the server…" = "Проверка сервера…"
|
||||
@@ -1562,6 +1564,7 @@ name = "Русский"
|
||||
"View link copied" = "Ссылка для просмотра скопирована"
|
||||
"View only" = "Только просмотр"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Только просмотр: вы видите все изменения сразу, но не можете редактировать. Выйдите из сессии, чтобы сохранить редактируемую копию."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Может комментировать: вы видите все изменения сразу и можете комментировать, но не можете редактировать. Выйдите из сессии, чтобы сохранить редактируемую копию."
|
||||
"Viewing “{name}”" = "Просмотр «{name}»"
|
||||
"Violet" = "Фиолетовый"
|
||||
"Visibility" = "Видимость"
|
||||
|
||||
@@ -121,6 +121,7 @@ name = "Svenska"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Alla med redigeringslänken kan ändra filen; visningslänken låter bara andra titta. Länkarna innehåller sina hemligheter: dela dem som lösenord. `figcraft-server links` skapar fler och återkallar dem."
|
||||
"Anyone with this code can edit the file." = "Alla med den här koden kan redigera filen."
|
||||
"Anyone with this code can look at the file, not change it." = "Alla med den här koden kan titta på filen, men inte ändra den."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Alla med den här koden kan titta på filen och kommentera den, men inte ändra den."
|
||||
"Appearance" = "Utseende"
|
||||
"Apply a component property" = "Använd en komponentegenskap"
|
||||
"Apply variable" = "Använd variabel"
|
||||
@@ -214,6 +215,7 @@ name = "Svenska"
|
||||
"C or ⇧C" = "C eller ⇧C"
|
||||
"CMYK colour" = "CMYK-färg"
|
||||
"Can view" = "Kan visa"
|
||||
"Can comment" = "Kan kommentera"
|
||||
"Check" = "Bock"
|
||||
"Check for Updates" = "Sök efter uppdateringar"
|
||||
"Check for updates…" = "Sök efter uppdateringar…"
|
||||
@@ -1596,6 +1598,7 @@ name = "Svenska"
|
||||
"View link copied" = "Visningslänken har kopierats"
|
||||
"View only" = "Endast visning"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Endast visning: du ser alla ändringar direkt men kan inte redigera. Lämna för att behålla en kopia som du kan redigera."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Kan kommentera: du ser alla ändringar direkt och kan kommentera, men inte redigera. Lämna för att behålla en kopia som du kan redigera."
|
||||
"Viewing “{name}”" = "Visar ”{name}”"
|
||||
"Violet" = "Violett"
|
||||
"Visibility" = "Synlighet"
|
||||
|
||||
@@ -121,6 +121,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "ทุกคนที่มีลิงก์แก้ไขเปลี่ยนไฟล์ได้ ส่วนลิงก์ดูเปิดดูได้อย่างเดียว ลิงก์มีรหัสลับในตัว: แชร์เหมือนรหัสผ่าน `figcraft-server links` สร้างลิงก์เพิ่มและเพิกถอนได้"
|
||||
"Anyone with this code can edit the file." = "ทุกคนที่มีรหัสนี้แก้ไขไฟล์ได้"
|
||||
"Anyone with this code can look at the file, not change it." = "ทุกคนที่มีรหัสนี้เปิดดูไฟล์ได้ แต่แก้ไขไม่ได้"
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "ทุกคนที่มีรหัสนี้เปิดดูไฟล์และแสดงความคิดเห็นได้ แต่แก้ไขไม่ได้"
|
||||
"Appearance" = "รูปลักษณ์"
|
||||
"Apply a component property" = "ใช้พร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
"Apply variable" = "ใช้ตัวแปร"
|
||||
@@ -212,6 +213,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C หรือ ⇧C"
|
||||
"CMYK colour" = "สี CMYK"
|
||||
"Can view" = "ดูได้"
|
||||
"Can comment" = "แสดงความคิดเห็นได้"
|
||||
"Check" = "ตรวจสอบ"
|
||||
"Checking the fonts installed on this computer…" = "กำลังตรวจสอบฟอนต์ที่ติดตั้งในคอมพิวเตอร์เครื่องนี้…"
|
||||
"Checking the server…" = "กำลังตรวจสอบเซิร์ฟเวอร์…"
|
||||
@@ -1544,6 +1546,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "คัดลอกลิงก์ดูแล้ว"
|
||||
"View only" = "ดูอย่างเดียว"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "ดูอย่างเดียว: คุณเห็นทุกการเปลี่ยนแปลงทันทีแต่แก้ไขไม่ได้ ออกจากเซสชันเพื่อเก็บสำเนาที่แก้ไขได้"
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "แสดงความคิดเห็นได้: คุณเห็นทุกการเปลี่ยนแปลงทันทีและแสดงความคิดเห็นได้ แต่แก้ไขไม่ได้ ออกจากเซสชันเพื่อเก็บสำเนาที่แก้ไขได้"
|
||||
"Viewing “{name}”" = "กำลังดู “{name}”"
|
||||
"Violet" = "ม่วง"
|
||||
"Visibility" = "การแสดงผล"
|
||||
|
||||
@@ -119,6 +119,7 @@ name = "Türkçe"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Düzenleme bağlantısına sahip herkes dosyayı değiştirebilir; görüntüleme bağlantısı yalnızca bakmaya izin verir. Bağlantılar gizli anahtarlarını taşır: onları parola gibi paylaşın. `figcraft-server links` yenilerini oluşturur ve iptal eder."
|
||||
"Anyone with this code can edit the file." = "Bu koda sahip herkes dosyayı düzenleyebilir."
|
||||
"Anyone with this code can look at the file, not change it." = "Bu koda sahip herkes dosyaya bakabilir, ancak değiştiremez."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Bu koda sahip herkes dosyaya bakabilir ve yorum yapabilir, ancak değiştiremez."
|
||||
"Appearance" = "Görünüm"
|
||||
"Apply a component property" = "Bir bileşen özelliği uygula"
|
||||
"Apply variable" = "Değişken uygula"
|
||||
@@ -210,6 +211,7 @@ name = "Türkçe"
|
||||
"C or ⇧C" = "C veya ⇧C"
|
||||
"CMYK colour" = "CMYK rengi"
|
||||
"Can view" = "Görüntüleyebilir"
|
||||
"Can comment" = "Yorum yapabilir"
|
||||
"Check" = "Onay"
|
||||
"Checking the server…" = "Sunucu denetleniyor…"
|
||||
"Chevron" = "Şerit ok"
|
||||
@@ -1518,6 +1520,7 @@ name = "Türkçe"
|
||||
"View link copied" = "Görüntüleme bağlantısı kopyalandı"
|
||||
"View only" = "Yalnızca görüntüleme"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Yalnızca görüntüleme: her değişikliği olduğu anda görürsünüz ama düzenleyemezsiniz. Düzenleyebileceğiniz bir kopya saklamak için ayrılın."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Yorum yapabilir: her değişikliği olduğu anda görürsünüz ve yorum yapabilirsiniz, ama düzenleyemezsiniz. Düzenleyebileceğiniz bir kopya saklamak için ayrılın."
|
||||
"Viewing “{name}”" = "“{name}” görüntüleniyor"
|
||||
"Violet" = "Mor"
|
||||
"Visibility" = "Görünürlük"
|
||||
|
||||
@@ -112,6 +112,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Bất kỳ ai có liên kết chỉnh sửa đều có thể thay đổi tệp; liên kết xem chỉ cho phép xem. Các liên kết chứa mã bí mật: hãy chia sẻ cẩn thận như mật khẩu. `figcraft-server links` dùng để tạo thêm và thu hồi liên kết."
|
||||
"Anyone with this code can edit the file." = "Bất kỳ ai có mã này đều có thể chỉnh sửa tệp."
|
||||
"Anyone with this code can look at the file, not change it." = "Bất kỳ ai có mã này đều có thể xem tệp nhưng không thể chỉnh sửa."
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "Bất kỳ ai có mã này đều có thể xem tệp và bình luận nhưng không thể chỉnh sửa."
|
||||
"Appearance" = "Giao diện"
|
||||
"Apply a component property" = "Áp dụng thuộc tính thành phần"
|
||||
"Apply variable" = "Áp dụng biến"
|
||||
@@ -203,6 +204,7 @@ status = "draft — needs native review"
|
||||
"CSS custom properties…" = "Thuộc tính tùy chỉnh CSS…"
|
||||
"Can edit" = "Có thể chỉnh sửa"
|
||||
"Can view" = "Có thể xem"
|
||||
"Can comment" = "Có thể bình luận"
|
||||
"Can't read this library: {e}" = "Không thể đọc thư viện này: {e}"
|
||||
"Cancel" = "Hủy"
|
||||
"Canvas" = "Vùng vẽ"
|
||||
@@ -1496,6 +1498,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "Đã sao chép liên kết xem"
|
||||
"View only" = "Chỉ xem"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Chỉ xem: bạn thấy mọi thay đổi theo thời gian thực nhưng không thể chỉnh sửa. Hãy rời phiên để giữ lại một bản sao có thể chỉnh sửa."
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "Có thể bình luận: bạn thấy mọi thay đổi theo thời gian thực và có thể bình luận nhưng không thể chỉnh sửa. Hãy rời phiên để giữ lại một bản sao có thể chỉnh sửa."
|
||||
"Viewing “{name}”" = "Đang xem “{name}”"
|
||||
"Violet" = "Tím"
|
||||
"Visibility" = "Hiển thị"
|
||||
|
||||
@@ -116,6 +116,7 @@ status = "draft — needs native review"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "持有编辑链接的任何人都可以修改文件;查看链接只能查看。链接包含密钥,请像密码一样分享。`figcraft-server links` 可生成更多链接或将其撤销。"
|
||||
"Anyone with this code can edit the file." = "持有此代码的任何人都可以编辑文件。"
|
||||
"Anyone with this code can look at the file, not change it." = "持有此代码的任何人都可以查看文件,但不能修改。"
|
||||
"Anyone with this code can look at the file and comment on it, not change it." = "持有此代码的任何人都可以查看文件并发表评论,但不能修改。"
|
||||
"Appearance" = "外观"
|
||||
"Apply a component property" = "应用组件属性"
|
||||
"Apply variable" = "应用变量"
|
||||
@@ -203,6 +204,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C 或 ⇧C"
|
||||
"CMYK colour" = "CMYK 颜色"
|
||||
"Can view" = "可查看"
|
||||
"Can comment" = "可评论"
|
||||
"Check" = "对勾"
|
||||
"Checking the server…" = "正在检查服务器…"
|
||||
"Chevron" = "箭头形"
|
||||
@@ -1475,6 +1477,7 @@ status = "draft — needs native review"
|
||||
"View link copied" = "已复制查看链接"
|
||||
"View only" = "仅查看"
|
||||
"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "仅查看:你能实时看到每处更改,但无法编辑。离开会话即可保留一份可编辑的副本。"
|
||||
"Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit." = "可评论:你能实时看到每处更改并发表评论,但无法编辑。离开会话即可保留一份可编辑的副本。"
|
||||
"Viewing “{name}”" = "正在查看“{name}”"
|
||||
"Violet" = "紫"
|
||||
"Visibility" = "可见性"
|
||||
|
||||
+34
-14
@@ -41,6 +41,14 @@ const UNDO_NOTE: &str = tr_noop("Undo takes back only your own changes; what oth
|
||||
/// What a view link means for you.
|
||||
const VIEW_NOTE: &str = tr_noop("View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit.");
|
||||
|
||||
/// What a comment link means for you.
|
||||
const COMMENT_NOTE: &str =
|
||||
tr_noop("Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit.");
|
||||
|
||||
/// What a share code can let people do, most first, as the Share popover offers it.
|
||||
const LEVELS: [(Access, &str); 3] =
|
||||
[(Access::Edit, tr_noop("Can edit")), (Access::Comment, tr_noop("Can comment")), (Access::View, tr_noop("Can view"))];
|
||||
|
||||
/// What a published file's links are.
|
||||
const PUBLISHED_NOTE: &str = tr_noop(
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.",
|
||||
@@ -1028,7 +1036,6 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
if live.status != Status::Live {
|
||||
return stop(app, ui);
|
||||
}
|
||||
let view = share.access == Access::View;
|
||||
let link = share.link().unwrap_or_default().to_string();
|
||||
let me = live.info.as_ref().map(|i| i.name.clone()).unwrap_or_default();
|
||||
let names: Vec<String> = std::iter::once(trf("{name} (you)", &[("name", &me)])).chain(live.peers.values().map(|p| p.name.clone())).collect();
|
||||
@@ -1056,16 +1063,11 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
link_rows(app, ui, &[(tr("Invite link"), &link, tr("Invite link copied"))]);
|
||||
}
|
||||
}
|
||||
// A server has a link (and a code) for looking only; a session this app hosts doesn't.
|
||||
if share.links.as_ref().is_some_and(|l| l.view.is_some())
|
||||
&& let Some(i) = widgets::segmented_text(ui, &[tr("Can edit"), tr("Can view")], usize::from(view))
|
||||
&& (i == 1) != view
|
||||
{
|
||||
app.run("collab.share", json!({"access": if i == 1 { "view" } else { "edit" }}));
|
||||
}
|
||||
let who = match (online, view) {
|
||||
(true, true) => tr("Anyone with this code can look at the file, not change it."),
|
||||
(true, false) => tr("Anyone with this code can edit the file."),
|
||||
access_choice(app, ui, share);
|
||||
let who = match (online, share.access) {
|
||||
(true, Access::View) => tr("Anyone with this code can look at the file, not change it."),
|
||||
(true, Access::Comment) => tr("Anyone with this code can look at the file and comment on it, not change it."),
|
||||
(true, Access::Edit) => tr("Anyone with this code can edit the file."),
|
||||
(false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),
|
||||
};
|
||||
widgets::label(ui, who, t.text_secondary);
|
||||
@@ -1078,6 +1080,20 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
stop(app, ui);
|
||||
}
|
||||
|
||||
/// What the code shown lets people do, to pick from the levels the share has links for: a server
|
||||
/// has links (and codes) for looking only and for commenting; a session this app hosts doesn't.
|
||||
fn access_choice(app: &mut FigApp, ui: &mut Ui, share: &Share) {
|
||||
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| share.links.as_ref().is_some_and(|l| l.of(*a).is_some())).collect();
|
||||
if levels.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let labels: Vec<&str> = levels.iter().map(|(_, l)| tr(l)).collect();
|
||||
let at = levels.iter().position(|(a, _)| *a == share.access).unwrap_or_default();
|
||||
if let Some((a, _)) = widgets::segmented_text(ui, &labels, at).filter(|i| *i != at).and_then(|i| levels.get(i)) {
|
||||
app.run("collab.share", json!({"access": a.id()}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosting: the invite link and URL to copy, and stop.
|
||||
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
||||
let Some(info) = app.session.live().info.clone() else { return };
|
||||
@@ -1094,9 +1110,13 @@ fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
||||
/// and leave (the file stays open as your copy).
|
||||
fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let live = app.session.live();
|
||||
let view = live.info.as_ref().is_some_and(|i| i.access == Access::View);
|
||||
let (what, note) = match live.info.as_ref().map_or(Access::Edit, |i| i.access) {
|
||||
Access::Edit => (tr_noop("Can edit"), UNDO_NOTE),
|
||||
Access::Comment => (tr_noop("Can comment"), COMMENT_NOTE),
|
||||
Access::View => (tr_noop("View only"), VIEW_NOTE),
|
||||
};
|
||||
if let Some(i) = live.info.as_ref() {
|
||||
widgets::label(ui, &format!("{} · {}", i.address, if view { tr("View only") } else { tr("Can edit") }), t.text_secondary);
|
||||
widgets::label(ui, &format!("{} · {}", i.address, tr(what)), t.text_secondary);
|
||||
if !i.pages.is_empty() {
|
||||
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
|
||||
widgets::label(ui, &trf("Pages: {pages}", &[("pages", &pages.join(", "))]), t.text_secondary);
|
||||
@@ -1111,7 +1131,7 @@ fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
host_line(ui, t, &h.operator, &h.terms);
|
||||
expiry_line(ui, t, h.expires);
|
||||
}
|
||||
widgets::label(ui, tr(if view { VIEW_NOTE } else { UNDO_NOTE }), t.text_secondary);
|
||||
widgets::label(ui, tr(note), t.text_secondary);
|
||||
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy of the file stays open")).clicked() {
|
||||
app.run("collab.leave", json!({}));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use egui::{Event, Key};
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event as LiveEvent, HostInfo, Published, Role, SessionInfo, Status, VIEW_ONLY};
|
||||
use figcraft_engine::live::{Access, COMMENT_ONLY, Event as LiveEvent, HostInfo, Published, Role, SessionInfo, Status, VIEW_ONLY};
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -206,3 +206,27 @@ fn a_view_link_shows_the_badge_and_only_the_moving_tools() {
|
||||
assert!(!has_text(&shapes, "View only"));
|
||||
assert!(h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_link_shows_its_badge_and_leaves_the_comment_tool() {
|
||||
let (mut h, _) = Harness::new();
|
||||
h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
joined(&mut h, Access::Comment);
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Can comment"), "the badge");
|
||||
assert_eq!(h.app.session.tool.tool, ToolId::Move);
|
||||
assert_eq!(crate::live::inspect(&h.app)["readOnly"], COMMENT_ONLY);
|
||||
// The comment tool is in the pill and its shortcut works; an editing tool's says why not.
|
||||
h.app.session.execute("tool.set", &json!({"tool": "comment"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.session.tool.tool, ToolId::Comment, "commenting stays");
|
||||
key(&mut h, Key::R);
|
||||
assert_eq!(h.app.session.tool.tool, ToolId::Comment);
|
||||
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("Can comment")));
|
||||
// The Share popover says what the link allows.
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "127.0.0.1:47900 · Can comment"));
|
||||
assert!(has_text(&shapes, "Can comment: you see every change as it happens and can comment, but can't edit. Leave to keep a copy you can edit."));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ fn links(n: usize, codes: Option<(&str, &str)>) -> SharedLinks {
|
||||
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
|
||||
code: code.map(str::to_string),
|
||||
};
|
||||
SharedLinks { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
|
||||
SharedLinks { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))), comment: None }
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
@@ -176,6 +176,11 @@ fn type_text(h: &mut Harness, text: &str) {
|
||||
|
||||
/// The file reached the server and the session is live, with these codes (if the server has any).
|
||||
fn on_the_server(h: &mut Harness, script: &Shared, codes: Option<(&str, &str)>) -> Vec<Shape> {
|
||||
shared_on_the_server(h, script, links(0, codes))
|
||||
}
|
||||
|
||||
/// [`on_the_server`] with the server's links for others as given.
|
||||
fn shared_on_the_server(h: &mut Harness, script: &Shared, shared: SharedLinks) -> Vec<Shape> {
|
||||
let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}");
|
||||
let published = Published {
|
||||
server: "wss://share.example:443/".into(),
|
||||
@@ -187,7 +192,7 @@ fn on_the_server(h: &mut Harness, script: &Shared, codes: Option<(&str, &str)>)
|
||||
expires: None,
|
||||
};
|
||||
let welcome = LiveEvent::Welcome { info: info(Role::Guest, &published.edit), peers: vec![] };
|
||||
script.lock().unwrap().events.extend([LiveEvent::Published(published), LiveEvent::Shared(Ok(links(0, codes))), welcome]);
|
||||
script.lock().unwrap().events.extend([LiveEvent::Published(published), LiveEvent::Shared(Ok(shared)), welcome]);
|
||||
h.frames(3)
|
||||
}
|
||||
|
||||
@@ -265,6 +270,7 @@ fn sharing_online_shows_the_code_copies_it_and_stops() {
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "Anyone with this code can edit the file.") && has_text(&shapes, "Here: Ada (you)"));
|
||||
assert!(!has_text(&shapes, "Can comment"), "this server made no comment link");
|
||||
// The code is the largest text in the popover.
|
||||
let size = |s: &str| texts(&shapes).into_iter().find(|(t, _, _)| t == s).map(|(_, _, size)| size.y).unwrap();
|
||||
assert!(size("K7M-2QX-9TD") > 1.5 * size("Copy code"));
|
||||
@@ -314,6 +320,38 @@ fn sharing_online_shows_the_code_copies_it_and_stops() {
|
||||
assert_eq!((s.published.len(), s.shares), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_with_comment_links_offers_a_comment_code() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
click(&mut h, &shapes, "Start sharing");
|
||||
let mut shared = links(0, Some(("K7M2QX9TD", "4FHJ8R2WN")));
|
||||
shared.comment = Some(SharedLink {
|
||||
id: "comment0".into(),
|
||||
link: "figcraft://join?server=wss://share.example:443/&file=f1&token=ShareCommentShareComm00".into(),
|
||||
code: Some("MNP4RT7WX".into()),
|
||||
});
|
||||
let shapes = shared_on_the_server(&mut h, &script, shared);
|
||||
for t in ["Can edit", "Can comment", "Can view", "K7M-2QX-9TD"] {
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
// What the code lets people do: commenting, between editing and viewing.
|
||||
let x = |t: &str| text_center(&shapes, t).x;
|
||||
assert!(x("Can edit") < x("Can comment") && x("Can comment") < x("Can view"));
|
||||
let shapes = click(&mut h, &shapes, "Can comment");
|
||||
assert!(has_text(&shapes, "MNP-4RT-7WX") && !has_text(&shapes, "K7M-2QX-9TD"));
|
||||
assert!(has_text(&shapes, "Anyone with this code can look at the file and comment on it, not change it."));
|
||||
assert_eq!(crate::live::inspect(&h.app)["share"]["access"], "comment");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert!(script.lock().unwrap().copied.last().unwrap().ends_with("token=ShareCommentShareComm00"));
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
assert!(has_text(&shapes, "4FH-J8R-2WN") && has_text(&shapes, "Anyone with this code can look at the file, not change it."));
|
||||
// Stopping takes the comment link away with the others.
|
||||
click(&mut h, &shapes, "Stop sharing");
|
||||
h.frames(3);
|
||||
assert_eq!(script.lock().unwrap().revoked, ["edit0", "view0", "comment0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() {
|
||||
let (mut h, script) = harness();
|
||||
|
||||
@@ -69,7 +69,7 @@ fn links(file: &str, n: usize) -> SharedLinks {
|
||||
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
|
||||
code: Some(code),
|
||||
};
|
||||
SharedLinks { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
|
||||
SharedLinks { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))), comment: None }
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
|
||||
@@ -353,22 +353,30 @@ fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, pill: Rect, t: &Toke
|
||||
});
|
||||
}
|
||||
|
||||
/// The pill shown for a file you can't edit now: Move and Hand, and a badge saying why ("View
|
||||
/// only", or "Publishing…" until the file is on the server).
|
||||
/// The pill shown for a file you can't edit now: Move and Hand (and Comment with a comment link),
|
||||
/// and a badge saying why ("View only", "Can comment", or "Publishing…" until the file is on the
|
||||
/// server).
|
||||
fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The editing tools are off: the tool falls back to moving around.
|
||||
if !matches!(app.session.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
if !figcraft_engine::live::tool_left(why, app.session.tool.tool) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };
|
||||
let comment = why == figcraft_engine::live::COMMENT_ONLY;
|
||||
let moving = [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Comment, Icon::Comment)];
|
||||
let tools = if comment { &moving[..] } else { &moving[..2] };
|
||||
let (label, badge_icon) = match why {
|
||||
figcraft_engine::live::PUBLISHING => (tr("Publishing…"), Icon::Eye),
|
||||
_ if comment => (tr("Can comment"), Icon::Comment),
|
||||
_ => (tr("View only"), Icon::Eye),
|
||||
};
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
|
||||
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
|
||||
let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w;
|
||||
let width = PAD * 2.0 + tools.len() as f32 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w;
|
||||
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + PAD;
|
||||
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
|
||||
for &(tool, icon) in tools {
|
||||
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
|
||||
let active = app.session.tool.tool == tool;
|
||||
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));
|
||||
@@ -385,7 +393,7 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why);
|
||||
a11y::status(&resp, label, why);
|
||||
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(badge.min.x + 12.0 + 7.0, cy), vec2(14.0, 14.0)), Icon::Eye, t.icon_secondary);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(badge.min.x + 12.0 + 7.0, cy), vec2(14.0, 14.0)), badge_icon, t.icon_secondary);
|
||||
ui.painter().galley(pos2(badge.min.x + 12.0 + 14.0 + 6.0, cy - text.size().y / 2.0), text, t.text);
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -203,6 +203,14 @@ a server that knows it keeps the new name and tells everyone with the client's `
|
||||
(apps take a `PeerJoined` for someone already there as their new name and colour); an older
|
||||
server drops it, so the session keeps showing the name the client joined with.
|
||||
|
||||
**Comment links** came later within version 2 the same way. A client that reads the `comment`
|
||||
access says so with the word `comments` in its `client_version` (hello, managing and library
|
||||
connections); a server welcomes a client that doesn't with a comment link as a viewer (and enforces
|
||||
it as one), and lists comment links to such a managing client as view links, so an older app never
|
||||
gets a level it can't read. This build reads a level it doesn't know (a later one) as `view`, in
|
||||
both encodings. An older server can't read `Admin createLink` with the `comment` access: the app
|
||||
asks for the comment link on a connection of its own and shares without one when it fails.
|
||||
|
||||
**Open hosting and reports** came later within version 2 the same way. A connection that opens
|
||||
with `OpenHosting` asks a server about its hosting, publishes without the key when the server is
|
||||
open to the public, or manages one file with its owner token; a server older than that can't read
|
||||
@@ -264,7 +272,7 @@ Server → client:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit` or `view` (a view link: every batch is refused). |
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit`, `view` (a view link: every batch is refused) or `comment` (a comment link: a batch is refused unless every change in it is to comment threads: a thread's `put` or `remove`, their order or the whole list). |
|
||||
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
|
||||
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
|
||||
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
|
||||
@@ -492,7 +500,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
|
||||
- "Share" in the right panel opens a popover: your name, a choice between **Online** and **Local
|
||||
network**, and **Start sharing**; while sharing, the join code (large), **Copy code** and **Copy
|
||||
link**, what the code lets people do (online: can edit or can view), who is here, and **Stop
|
||||
link**, what the code lets people do (online: can edit, can comment or can view), who is here, and **Stop
|
||||
sharing**; while joined, **Leave session**. **Join with a code** is one field in the popover, on
|
||||
the home screen and in "File ▸ Join live session…": it takes a code of either kind or an invite
|
||||
link. Another server, a server key, the port and "this computer only" are under **Advanced**;
|
||||
@@ -550,10 +558,11 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
A joiner's app sends `Resolve {code}` to its own server in Preferences, gets the file and the
|
||||
link's token, and joins with them as with the link.
|
||||
- **The file is its owner's.** Publishing gives the file its first edit and view link and its
|
||||
owner token; those stay with the owner, whose app joins with that edit link. Sharing makes two
|
||||
more links for others (`Admin createLink`, labelled "Shared (edit)" and "Shared (view)") and
|
||||
owner token; those stay with the owner, whose app joins with that edit link. Sharing makes three
|
||||
more links for others (`Admin createLink`, labelled "Shared (edit)", "Shared (view)" and
|
||||
"Shared (comment)") and
|
||||
asks for their codes; those are what the popover shows and copies. **Stop sharing**
|
||||
(`collab.stop`) revokes these two links (`Admin revoke`, as the owner): their codes stop
|
||||
(`collab.stop`) revokes these links (`Admin revoke`, as the owner): their codes stop
|
||||
resolving ("That code has expired or its sharing was stopped"), the links stop joining, and
|
||||
everyone who joined with them is disconnected at once with close code 1001 and "Sharing was
|
||||
stopped: the link you joined with no longer works. Your copy stays open", which apps take as
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
@@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
|
||||
| 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name, combined with the other filters, so an agent tagged in comments reads just its threads. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged |
|
||||
|
||||
@@ -237,7 +237,7 @@ server dialogs are open (`joinDialog`, `publishDialog`; Escape closes them), the
|
||||
Share popover shows (`code`, with `share: {via, access}`; null when the file isn't shared with
|
||||
one), what is typed in the popover's and the home screen's join field (`joinField`), whether the
|
||||
popover has Local network chosen and Advanced open (`local`, `advanced`), and why the active
|
||||
document can't be edited now (`readOnly`: the "View only" or "Publishing" reason, else null); the
|
||||
document can't be edited now (`readOnly`: the "View only", "Can comment" or "Publishing" reason, else null); the
|
||||
session itself is `engine.execute {command: "collab.peers"}` (sharing with a join code, hosting,
|
||||
joining and publishing are `collab.share`, `collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
|
||||
see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
|
||||
|
||||
+11
-6
@@ -1355,10 +1355,12 @@ only: a host stops hosting it, a guest leaves it.
|
||||
FigCraft server unless changed; `{url: ""}` turns online sharing off, `{}` goes back to the
|
||||
built-in one; `server` names another for this call, with `?key=…` when it needs its key) and
|
||||
returns `status: "connecting"`; once `collab.peers` says `"live"`, `code` is the code to give
|
||||
others (`K7M-2QX-9TD`) and `share` is `{via, access, shared, editCode, viewCode, editLink,
|
||||
viewLink}`: links and codes made for others (`published.edit` / `published.view` and `link` are
|
||||
others (`K7M-2QX-9TD`) and `share` is `{via, access, shared, editCode, viewCode, commentCode, editLink,
|
||||
viewLink, commentLink}`: links and codes made for others (`published.edit` / `published.view` and `link` are
|
||||
your own way in; keep them). `access: "view"` (or calling again with it while shared) makes
|
||||
`code` the view-only one. A server older than join codes leaves `code` null: give
|
||||
`code` the view-only one, and `access: "comment"` the one for viewing and commenting (whoever
|
||||
joins with it adds, replies to, reacts to and resolves comments but can't change the design;
|
||||
`commentCode` is null from a server older than comment links, and asking for it then fails). A server older than join codes leaves `code` null: give
|
||||
`share.editLink` / `share.viewLink` instead. `via: "local"`
|
||||
hosts the document for people on the same network and returns at once with a longer `code`
|
||||
(20 symbols, carrying this machine's IPv4 address, the port and the session's secret; `bind:
|
||||
@@ -1403,6 +1405,8 @@ only: a host stops hosting it, a guest leaves it.
|
||||
file, name, edit, view}` with the file's first edit link and view link (they carry their
|
||||
tokens: share them like passwords). Someone joining with the view link gets `access: "view"`:
|
||||
every edit on the synced document is refused with "View only: …" (the server refuses them too).
|
||||
With a comment link (`access: "comment"`) the comment commands work and sync, and every other edit
|
||||
is refused with "Can comment: …".
|
||||
On a server open to the public ([`server.md`](server.md#running-a-public-server)) `server` needs no
|
||||
key (`wss://figcraft.example.com/`): `published` then also has `owner` (the owner token, shown this
|
||||
once: keep it) and `expires` (when the server deletes the file unless someone opens it, seconds
|
||||
@@ -1463,10 +1467,11 @@ only: a host stops hosting it, a guest leaves it.
|
||||
and the rest is null or empty), status
|
||||
(off|connecting|live|reconnecting|failed), reason?,
|
||||
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
|
||||
access (edit|view), readOnly (the active document can't be edited now: view only, or being
|
||||
published), published (a file you published: {server, file, name, edit, view, owner, expires}),
|
||||
access (edit|view|comment), readOnly (the active document can't be edited now: view only,
|
||||
comment only, or being published), published (a file you published: {server, file, name, edit, view, owner, expires}),
|
||||
code (the join code to give others when the document was shared with `collab.share`, else
|
||||
null), share ({via: online|local, access, shared, editCode, viewCode, editLink, viewLink}, or
|
||||
null), share ({via: online|local, access, shared, editCode, viewCode, commentCode, editLink, viewLink,
|
||||
commentLink}, or
|
||||
null; `shared: false`: your file on a server, not shared now),
|
||||
hosting (what the server of the file you're in says: {open, operator, terms, idleDays, maxFileMb,
|
||||
expires}, or null),
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
+12
-5
@@ -55,7 +55,8 @@ prints the address to publish to, `ws://<host>:47900/?key=<server key>`. In the
|
||||
**File ▸ Publish to server…** (or **Share ▸ Advanced ▸ Publish to another server…**) asks for that address and the
|
||||
file's name. The open file is uploaded as a new file and the tab keeps syncing through the
|
||||
server; until the upload is done the tab is read-only ("Publishing…" in the toolbar). The Share
|
||||
popover then shows an **edit link** and a **view link** to copy. Agents do the same with
|
||||
popover then shows an **edit link** and a **view link** to copy (sharing with a join code also makes a
|
||||
**comment link**, see below). Agents do the same with
|
||||
`collab.publish {server, name?}` ([`mcp.md`](mcp.md#live-sessions)).
|
||||
|
||||
Whoever has the server key can publish files and manage every file's links: give it only to
|
||||
@@ -181,7 +182,12 @@ app's invite links and join the same way (the same **Join with a code** field ta
|
||||
The file opens as a new tab. With a **view link** you see everyone's changes and presence, and
|
||||
others see your cursor and selection, but you can't change anything: the toolbar shows a **View
|
||||
only** badge with only the Move and Hand tools, edits are refused with a message (and the server
|
||||
refuses them too). **Leave session** keeps the file open as your own copy, editable again.
|
||||
refuses them too). A **comment link** is a view link that also lets you comment: the badge reads
|
||||
**Can comment**, the Comment tool is there beside Move and Hand, and you add, reply to, react to
|
||||
and resolve comment threads; any other edit is refused, by the app and by the server, which takes a
|
||||
comment link's batch only when every change in it touches comment threads. An app from before
|
||||
comment links joins with one as a viewer. **Leave session** keeps the file open as your own copy,
|
||||
editable again.
|
||||
|
||||
## Team libraries
|
||||
|
||||
@@ -257,7 +263,7 @@ port as everything else; no other service is involved.
|
||||
| `figcraft-server files [list] [--sort size\|age\|idle]` | The files: id, name, people in it now, size, created, days unused, expiry, reports, and who published it (`key`, or the address of a file published without it); biggest, oldest or longest unused first |
|
||||
| `figcraft-server files remove FILE` | Remove a file and its links for good (whoever is in it is disconnected) |
|
||||
| `figcraft-server links list FILE` | A file's links: id, access, created, label |
|
||||
| `figcraft-server links create FILE --access edit\|view [--label TEXT]` | A new link, printed once |
|
||||
| `figcraft-server links create FILE --access edit\|comment\|view [--label TEXT]` | A new link, printed once |
|
||||
| `figcraft-server links revoke FILE LINK` | Revoke a link: it no longer joins, and whoever joined with it is disconnected |
|
||||
| `figcraft-server plugins [list]` | The plugin directory: every plugin, its versions, author, access, size and SHA-256 |
|
||||
| `figcraft-server plugins remove ID [--version V]` | Unpublish a version of a plugin, or every version |
|
||||
@@ -459,8 +465,9 @@ What someone can do, by what they hold (the server is reached through TLS at the
|
||||
| Who | Can | Cannot |
|
||||
|---|---|---|
|
||||
| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz`; try join codes, within the limits on wrong ones | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back, and all are compared in constant time against hashes); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block |
|
||||
| Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong |
|
||||
| Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; a comment code: the comment link's; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong |
|
||||
| Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links |
|
||||
| Someone with a file's **comment link** | What a view link gives, and add, reply to, react to, resolve, move and remove that file's comment threads (anyone's: comments are signed with the name in Preferences, there are no accounts) | Change anything else in it (the server refuses a batch that touches more than comment threads); reach any other file; manage links |
|
||||
| Someone with a file's **edit link** | Change that file, within its size cap; publish its team library (on a public server only for files published with the key); report it | Reach any other file; grow it past `max_file_mb` or its publisher's share; list, make or revoke links; rename or remove it |
|
||||
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one, which is how the app stops sharing), ask for join codes for links whose token they hold, rename it on the server, remove it; ask whether it is still there, its name, link ids and expiry (asking doesn't count as opening it) | Touch any other file, even with its id; tell a file that doesn't exist from one they don't own; list files; read reports; block; anything of the plugin directory |
|
||||
| The **operator** (server key, or the machine) | Everything above for every file, reports, blocks, the plugin directory; read every file on disk | Recover a lost link or owner token (only hashes are kept) |
|
||||
@@ -738,7 +745,7 @@ sudo systemctl start figcraft-server
|
||||
(128-bit) per file published without the key, or the server key (128-bit) for managing files.
|
||||
Tokens are stored as SHA-256 hashes and every comparison runs in constant time; revoking a link
|
||||
disconnects whoever joined with it. A view link's batches are refused by the server whatever the
|
||||
client does.
|
||||
client does, and so are a comment link's that change more than comment threads.
|
||||
- **Join codes** are 45 random bits naming one link: far easier to guess than a link, so every
|
||||
server limits wrong codes per address and for everyone
|
||||
([Sharing with a join code](#sharing-with-a-join-code)); with those limits, finding any one of
|
||||
|
||||
Reference in New Issue
Block a user