diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 05ad44e..2b0cb51 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -116,7 +116,8 @@ pub use crate::reminders_config::RemindersConfig; pub use crate::session::Session; pub use crate::settings_store::{load, load_at, load_checked, load_checked_at, save, save_at}; pub use crate::share::{ - Accepted, FileMeta, Invite, InviteInfo, Member, Revision, ServerClient, ServerDevice, ServerVault, ShareError, ShareResult, bind_folder, connect_with_code, + Accepted, FileMeta, Invite, InviteInfo, Member, PublicationKind, Revision, ServerClient, ServerDevice, ServerVault, ShareError, ShareResult, bind_folder, + connect_with_code, }; pub use crate::slash::{SlashEntry, filter_entries, trigger_ok}; pub use crate::spec::{CommandResult, CommandSpec}; diff --git a/crates/i18n/locales/en.toml b/crates/i18n/locales/en.toml index 4cb6fe3..4c794fb 100644 --- a/crates/i18n/locales/en.toml +++ b/crates/i18n/locales/en.toml @@ -1468,6 +1468,25 @@ folder-too-long = "The text is longer than {n} characters." pairing-hint = "0000" device-hint = "This laptop" invite-hint = "xxxx-xxxx-xxxx" +publish-links = "Publish and links" +published-pages = "Published pages" +read-only-links = "Read-only links" +unpublish = "Unpublish" +create-link = "Create link" +link-once = "This link is shown only once. Copy it now; it is not shown again." +kind-note = "Note" +kind-folder = "Folder" +kind-page = "Page" +expiry-day = "1 day" +expiry-week = "7 days" +expiry-month = "30 days" +expiry-never = "Never" +link-never = "never" +link-revoked = "revoked" +publish-failed = "The server could not complete the request." +publish-note = "Publish this note" +publish-done = "This page is public. Copy the link; it stays up until you unpublish it." +click-again = "Click again" [ui.image-view] loading = "Loading…" diff --git a/crates/ui-egui/src/text/share.rs b/crates/ui-egui/src/text/share.rs index d5855e9..76b5ac5 100644 --- a/crates/ui-egui/src/text/share.rs +++ b/crates/ui-egui/src/text/share.rs @@ -54,4 +54,23 @@ table! { folder_not_empty = "ui.share.folder-not-empty"; folder_failed = "ui.share.folder-failed"; folder_too_long = "ui.share.folder-too-long"; + publish_links = "ui.share.publish-links"; + published_pages = "ui.share.published-pages"; + read_only_links = "ui.share.read-only-links"; + unpublish = "ui.share.unpublish"; + create_link = "ui.share.create-link"; + link_once = "ui.share.link-once"; + kind_note = "ui.share.kind-note"; + kind_folder = "ui.share.kind-folder"; + kind_page = "ui.share.kind-page"; + expiry_day = "ui.share.expiry-day"; + expiry_week = "ui.share.expiry-week"; + expiry_month = "ui.share.expiry-month"; + expiry_never = "ui.share.expiry-never"; + link_never = "ui.share.link-never"; + link_revoked = "ui.share.link-revoked"; + publish_failed = "ui.share.publish-failed"; + publish_note = "ui.share.publish-note"; + publish_done = "ui.share.publish-done"; + click_again = "ui.share.click-again"; } diff --git a/crates/ui-egui/src/views/share_dialog/mod.rs b/crates/ui-egui/src/views/share_dialog/mod.rs index 3d3012a..c47d3ed 100644 --- a/crates/ui-egui/src/views/share_dialog/mod.rs +++ b/crates/ui-egui/src/views/share_dialog/mod.rs @@ -11,6 +11,7 @@ mod invite; mod members; +mod publish; use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -115,6 +116,8 @@ pub struct ShareDialog { pub(crate) menu: Option, /// What the last failed ask said. pub(crate) error: Option, + /// The published pages and the read-only links of the section. + publish: publish::State, /// The running job, when there is one. job: Option>>>, } @@ -126,6 +129,16 @@ impl ShareDialog { /// explanation and the button into Settings › Sync. #[must_use] pub fn open(name: &str, session: &Session) -> Self { + Self::open_for(name, session, None) + } + + /// Builds the dialog over the vault of `session`, about the note `note`. + /// + /// The note is the one the dialog publishes and shares. A dialog without + /// one lists the publications and the links of the vault and offers no + /// note of its own. + #[must_use] + pub fn open_for(name: &str, session: &Session, note: Option<&str>) -> Self { let mut dialog = Self { vault_name: name.to_owned(), client: None, @@ -138,6 +151,7 @@ impl ShareDialog { armed: None, menu: None, error: None, + publish: publish::State::new(note), job: None, }; let binding = load_binding(session.vault()).ok().flatten(); @@ -176,6 +190,7 @@ impl ShareDialog { if self.client.is_some() { members::section(self, ui, tokens); invite::section(self, ui, tokens); + publish::section(self, ui, tokens); self.note_line(ui, tokens); Self::footer(ui, tokens, &mut action); } else { diff --git a/crates/ui-egui/src/views/share_dialog/publish.rs b/crates/ui-egui/src/views/share_dialog/publish.rs new file mode 100644 index 0000000..0e4988a --- /dev/null +++ b/crates/ui-egui/src/views/share_dialog/publish.rs @@ -0,0 +1,353 @@ +//! The "Publish and links" section of the Share dialog: the pages the vault +//! publishes and the read-only links that open one note. +//! +//! Every ask runs as a job on a worker thread that holds the one client of +//! the account, so the token never reaches the screen; the answer walks a +//! channel the section drains each frame. The secret of a minted link is shown +//! once, in a box of its own: the server keeps only its hash, so the link can +//! never be listed again. +//! +//! A viewer sees the lists and no buttons; the destructive ones (Unpublish, +//! Revoke) wait for a second click before they do anything. + +mod publish_rows; + +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::Ui; +use notescraft_engine::{PublicationKind, ServerClient, ShareError}; + +use super::{BODY_SIZE, HEADING_SIZE, SECTION_GAP, ShareDialog}; +use crate::text::share as strings; +use crate::theme::{Tokens, color}; + +/// Seconds of one hour. +const HOUR_SECS: u64 = 3600; +/// Hours of one day. +const HOURS_PER_DAY: u64 = 24; +/// Days of a week. +const WEEK_DAYS: u64 = 7; +/// Days of a month. +const MONTH_DAYS: u64 = 30; + +/// One published page, as the row shows it. +#[derive(Clone)] +struct Published { + /// The publication id, for the unpublish. + id: String, + /// The public URL of the page. + url: String, + /// `note` or `folder`, as the server spells it. + kind: String, +} + +/// One share link, as the row shows it. +#[derive(Clone)] +struct Link { + /// The link id, for the revoke. + id: String, + /// The note the link opens. + path: String, + /// When it stops working, in milliseconds. + expires_ms: u64, + /// Whether it was revoked already. + revoked: bool, +} + +/// The lifetime of a link to create. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Expiry { + /// One day. + Day, + /// A week. + Week, + /// A month. + Month, + /// No end. + Never, +} + +impl Expiry { + /// The choices, in the order they are offered. + const CHOICES: [Self; 4] = [Self::Day, Self::Week, Self::Month, Self::Never]; + + /// The hours the link lives, `None` when it never ends. + fn hours(self) -> Option { + match self { + Self::Day => Some(HOURS_PER_DAY), + Self::Week => Some(HOURS_PER_DAY * WEEK_DAYS), + Self::Month => Some(HOURS_PER_DAY * MONTH_DAYS), + Self::Never => None, + } + } + + /// The label of the choice. + fn label(self) -> &'static str { + match self { + Self::Day => strings::expiry_day(), + Self::Week => strings::expiry_week(), + Self::Month => strings::expiry_month(), + Self::Never => strings::expiry_never(), + } + } +} + +/// One round of work the section asks a worker for. +enum Work { + /// The published pages and the share links. + Load, + /// Publishes the note of the dialog as a web page. + Publish { path: String }, + /// Removes a published page. + Unpublish(String), + /// Mints a link to the note of the dialog. + CreateLink { path: String, hours: Option }, + /// Revokes a link. + Revoke(String), +} + +/// The answer of a worker of the section. +enum Outcome { + /// The published pages and the share links. + Lists { + /// The pages the vault publishes. + publications: Vec, + /// The links of the vault. + links: Vec, + }, + /// A page that was just published: its URL and the refreshed lists. + Published { + /// The public URL of the new page. + url: String, + /// The pages the vault publishes. + publications: Vec, + /// The links of the vault. + links: Vec, + }, + /// A minted link: its secret URL and the refreshed lists. + Minted { + /// The URL of the link, with its secret in it. + url: String, + /// The pages the vault publishes. + publications: Vec, + /// The links of the vault. + links: Vec, + }, + /// What the server said. + Failed, +} + +/// What the section knows between frames. +pub(super) struct State { + /// The published pages of the vault. + publications: Vec, + /// The share links of the vault. + links: Vec, + /// The secret of the last minted link, shown once and never stored. + minted: Option, + /// The URL of the page that was published last, shown once. + fresh: Option, + /// The note the dialog is about. + note: Option, + /// The lifetime the next link gets. + expiry: Expiry, + /// The destructive action that waits for its second click. + armed: Option, + /// What the last failed ask said. + error: Option, + /// The running job, when there is one. + job: Option>>>, + /// Whether the lists were asked for already. + asked: bool, +} + +impl State { + /// The state of the section, about the note `note`. + pub(super) fn new(note: Option<&str>) -> Self { + Self { + publications: Vec::new(), + links: Vec::new(), + minted: None, + fresh: None, + note: note.map(str::to_owned), + expiry: Expiry::Week, + armed: None, + error: None, + job: None, + asked: false, + } + } + + /// Starts a round of work on a worker thread; the client moves in, so the + /// token never reaches the screen. + fn ask(&mut self, client: &Arc>, vault_id: &str, work: Work) { + if self.job.is_some() { + return; + } + let client = Arc::clone(client); + let vault_id = vault_id.to_owned(); + let (sender, receiver) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("notescraft-publish".to_owned()).spawn(move || { + let _ = sender.send(run(&client, &vault_id, work)); + }); + match spawned { + Ok(_worker) => { + self.job = Some(Arc::new(Mutex::new(receiver))); + self.error = None; + self.armed = None; + self.asked = true; + } + Err(error) => self.error = Some(error.to_string()), + } + } + + /// Takes the answer of the running job, when it is there. + fn poll(&mut self) { + let Some(job) = self.job.clone() else { return }; + let outcome = { + let guard = job.lock().unwrap_or_else(PoisonError::into_inner); + match guard.try_recv() { + Ok(outcome) => Some(outcome), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Outcome::Failed), + } + }; + let Some(outcome) = outcome else { return }; + self.job = None; + match outcome { + Outcome::Lists { publications, links } => { + self.publications = publications; + self.links = links; + self.error = None; + } + Outcome::Published { url, publications, links } => { + self.fresh = Some(url); + self.publications = publications; + self.links = links; + self.error = None; + } + Outcome::Minted { url, publications, links } => { + self.minted = Some(url); + self.publications = publications; + self.links = links; + self.error = None; + } + Outcome::Failed => self.error = Some(strings::publish_failed().to_owned()), + } + } + + /// Whether a job is running. + fn busy(&self) -> bool { + self.job.is_some() + } +} + +/// Runs one round of work of the section. +fn run(client: &Arc>, vault_id: &str, work: Work) -> Outcome { + let guard = client.lock().unwrap_or_else(PoisonError::into_inner); + match work { + Work::Load => lists(&guard, vault_id), + Work::Publish { path } => { + let Ok(published) = guard.publish(vault_id, &path, PublicationKind::Note) else { + return Outcome::Failed; + }; + let Outcome::Lists { publications, links } = lists(&guard, vault_id) else { + return Outcome::Failed; + }; + Outcome::Published { url: published.url, publications, links } + } + Work::Unpublish(id) => { + if guard.unpublish(vault_id, &id).is_err() { + return Outcome::Failed; + } + lists(&guard, vault_id) + } + Work::Revoke(id) => { + if guard.revoke_share_link(vault_id, &id).is_err() { + return Outcome::Failed; + } + lists(&guard, vault_id) + } + Work::CreateLink { path, hours } => { + let seconds = hours.map(|hours| hours.saturating_mul(HOUR_SECS)); + let Ok(minted) = guard.create_share_link(vault_id, &path, seconds) else { + return Outcome::Failed; + }; + let Outcome::Lists { publications, links } = lists(&guard, vault_id) else { + return Outcome::Failed; + }; + Outcome::Minted { url: minted.url, publications, links } + } + } +} + +/// The published pages and the share links of the vault. +/// +/// A server without the routes answers 404; that is an empty list, not a +/// failure, so the section stays quiet on a server that cannot publish. +fn lists(client: &ServerClient, vault_id: &str) -> Outcome { + match (client.publications(vault_id), client.share_links(vault_id)) { + (Ok(publications), Ok(links)) => Outcome::Lists { + publications: publications.iter().map(|row| Published { id: row.id.clone(), url: row.url.clone(), kind: row.kind.clone() }).collect(), + links: links.iter().map(|row| Link { id: row.id.clone(), path: row.path.clone(), expires_ms: row.expires_ms, revoked: row.revoked }).collect(), + }, + (Err(error), _) | (_, Err(error)) if is_missing(&error) => Outcome::Lists { publications: Vec::new(), links: Vec::new() }, + _ => Outcome::Failed, + } +} + +/// Whether the server answered 404: it does not know the route. +fn is_missing(error: &ShareError) -> bool { + matches!(error, ShareError::Status { status: 404, .. }) +} + +/// The client and the vault id of the dialog, when the vault is bound. +fn account(dialog: &ShareDialog) -> Option<(Arc>, String)> { + let client = dialog.client.clone()?; + Some((client, dialog.vault_id.clone())) +} + +/// Whether the user may publish and share: an owner or an editor. +/// +/// A viewer sees the lists but no buttons. An unknown identity (the devices +/// of before the accounts) reads as the owner. +pub(super) fn can_write(dialog: &ShareDialog) -> bool { + let Some(me) = dialog.my_user.as_deref() else { return true }; + !dialog.members.iter().any(|member| member.user_id == me && member.role == "viewer") +} + +/// The key of an armed action: the publication or the link it waits for. +pub(super) fn arm_key(action: &str, id: &str) -> String { + format!("{action}:{id}") +} + +/// Draws the publish part of the dialog. +pub(super) fn section(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let colors = &tokens.theme.colors; + ui.add_space(SECTION_GAP); + ui.label(egui::RichText::new(strings::publish_links().to_uppercase()).size(HEADING_SIZE).strong().color(color(colors.text_muted))); + ui.add_space(8.0); + dialog.publish.poll(); + first_load(dialog); + if dialog.publish.busy() { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0).color(color(colors.accent))); + ui.add_space(8.0); + ui.label(egui::RichText::new(strings::loading()).size(BODY_SIZE).color(color(colors.text_muted))); + }); + } + publish_rows::published(dialog, ui, tokens); + ui.add_space(12.0); + publish_rows::links(dialog, ui, tokens); + publish_rows::note(dialog, ui, tokens); +} + +/// Asks for the lists the first time the section is drawn. +fn first_load(dialog: &mut ShareDialog) { + if dialog.publish.asked { + return; + } + let Some((client, vault_id)) = account(dialog) else { return }; + dialog.publish.ask(&client, &vault_id, Work::Load); +} diff --git a/crates/ui-egui/src/views/share_dialog/publish/publish_rows.rs b/crates/ui-egui/src/views/share_dialog/publish/publish_rows.rs new file mode 100644 index 0000000..1030d82 --- /dev/null +++ b/crates/ui-egui/src/views/share_dialog/publish/publish_rows.rs @@ -0,0 +1,306 @@ +//! The rows of the "Publish and links" section: the published pages, the +//! read-only links, the lifetime of the next link and the secret of the last +//! minted link. +//! +//! A viewer sees the lists and no buttons; the destructive ones (Unpublish, +//! Revoke) wait for a second click before they do anything. + +use egui::text::{Galley, LayoutJob, TextFormat}; +use egui::{Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Sense, Ui, Vec2}; + +use super::super::{BODY_SIZE, DIALOG_ID, ROW_HEIGHT, ShareDialog}; +use super::{Expiry, Link, Published, Work, account, arm_key, can_write}; +use crate::text::share as strings; +use crate::theme::{Tokens, color}; + +/// Height of the line that shows the URL of a published page, points. +const URL_HEIGHT: f32 = 32.0; +/// Height of the small pill of a row, points. +const PILL_HEIGHT: f32 = 32.0; +/// Horizontal padding of a pill, points. +const PILL_PAD: f32 = 12.0; +/// Gap between the parts of a row, points. +const GAP: f32 = 8.0; +/// The least width of a text in a row, points. +const MIN_TEXT_WIDTH: f32 = 40.0; +/// Longest text painted in a row, in characters. +const MAX_ROW_CHARS: usize = 160; +/// Milliseconds of one hour. +const HOUR_MS: u64 = 3_600_000; +/// Milliseconds of one day. +const DAY_MS: u64 = 24 * HOUR_MS; + +/// Draws the published pages of the vault. +pub(super) fn published(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let colors = &tokens.theme.colors; + ui.label(egui::RichText::new(strings::published_pages()).size(BODY_SIZE).strong().color(color(colors.text))); + ui.add_space(4.0); + if let Some(url) = dialog.publish.fresh.clone() { + url_box(ui, tokens, (DIALOG_ID, "copy-published"), &url, strings::publish_done()); + ui.add_space(6.0); + } + let publications = dialog.publish.publications.clone(); + for publication in &publications { + publication_row(dialog, ui, tokens, publication); + ui.add_space(6.0); + } + publish_note(dialog, ui, tokens); +} + +/// One published page: its URL with a copy button, its kind and the Unpublish. +fn publication_row(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens, publication: &Published) { + let colors = &tokens.theme.colors; + let (line, _space) = ui.allocate_exact_size(Vec2::new(ui.available_width(), URL_HEIGHT), Sense::hover()); + let writable = can_write(dialog); + let copy_width = pill_width(ui, strings::copy()); + let copy = writable + && pill(ui, tokens, (DIALOG_ID, "publish-copy", publication.id.as_str()), strings::copy(), Pos2::new(line.right() - copy_width / 2.0, line.center().y)); + let url_width = (line.width() - copy_width - GAP).max(MIN_TEXT_WIDTH); + let url = short(&publication.url, MAX_ROW_CHARS); + let galley = cut(ui, &url, url_width, FontId::proportional(BODY_SIZE), color(colors.link)); + ui.painter().galley(Pos2::new(line.left(), line.center().y - galley.size().y / 2.0), galley, color(colors.link)); + if copy { + ui.ctx().copy_text(publication.url.clone()); + } + let (row, _space) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::hover()); + kind_chip(ui, tokens, &publication.kind, row.left(), row.center().y); + if !writable { + return; + } + let key = arm_key("unpublish", &publication.id); + let armed = dialog.publish.armed.as_deref() == Some(key.as_str()); + let label = if armed { strings::click_again() } else { strings::unpublish() }; + let unpublish = text_button(ui, (DIALOG_ID, "unpublish", publication.id.as_str()), label, color(colors.danger), row.right(), row.center().y); + if !unpublish { + return; + } + if armed { + dialog.publish.armed = None; + if let Some((client, vault_id)) = account(dialog) { + dialog.publish.ask(&client, &vault_id, Work::Unpublish(publication.id.clone())); + } + } else { + dialog.publish.armed = Some(key); + } +} + +/// The button that publishes the note of the dialog as a web page. +fn publish_note(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + if !can_write(dialog) { + return; + } + let Some(note) = dialog.publish.note.clone() else { return }; + ui.add_space(4.0); + let label = strings::publish_note(); + let width = pill_width(ui, label); + let (rect, _space) = ui.allocate_exact_size(Vec2::new(width, pill_height(ui)), Sense::hover()); + let publish = pill(ui, tokens, (DIALOG_ID, "publish-note"), label, rect.center()); + if publish && let Some((client, vault_id)) = account(dialog) { + dialog.publish.ask(&client, &vault_id, Work::Publish { path: note }); + } +} + +/// The chip of the kind of a publication. +fn kind_chip(ui: &Ui, tokens: &Tokens, kind: &str, left: f32, center_y: f32) { + let colors = &tokens.theme.colors; + let label = match kind { + "note" => strings::kind_note(), + "folder" => strings::kind_folder(), + _ => strings::kind_page(), + }; + let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), color(colors.text)); + let width = galley.size().x + PILL_PAD * 2.0; + let rect = Rect::from_center_size(Pos2::new(left + width / 2.0, center_y), Vec2::new(width, PILL_HEIGHT)); + ui.painter().rect_filled(rect, CornerRadius::same(u8::MAX), color(colors.surface_2)); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color(colors.text)); +} + +/// Draws the read-only links of the vault. +pub(super) fn links(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let colors = &tokens.theme.colors; + ui.label(egui::RichText::new(strings::read_only_links()).size(BODY_SIZE).strong().color(color(colors.text))); + ui.add_space(4.0); + let links = dialog.publish.links.clone(); + for link in &links { + link_row(dialog, ui, tokens, link); + ui.add_space(6.0); + } + if !can_write(dialog) { + return; + } + if dialog.publish.note.is_some() { + create(dialog, ui, tokens); + minted(dialog, ui, tokens); + } +} + +/// One share link: the note it opens, when it ends and the Revoke. +fn link_row(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens, link: &Link) { + let colors = &tokens.theme.colors; + let (row, _space) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::hover()); + let writable = can_write(dialog); + let key = arm_key("revoke", &link.id); + let armed = dialog.publish.armed.as_deref() == Some(key.as_str()); + let label = if armed { strings::click_again() } else { strings::revoke() }; + let revoke_width = text_width(ui, label); + let revoke = writable && text_button(ui, (DIALOG_ID, "revoke-link", link.id.as_str()), label, color(colors.danger), row.right(), row.center().y); + let expiry = expiry_text(link, now_ms()); + let expiry_galley = ui.painter().layout_no_wrap(expiry, FontId::proportional(BODY_SIZE), color(colors.text_muted)); + let right = if writable { row.right() - revoke_width - GAP } else { row.right() }; + let expiry_x = (right - expiry_galley.size().x).max(row.left()); + ui.painter().galley(Pos2::new(expiry_x, row.center().y - expiry_galley.size().y / 2.0), expiry_galley, color(colors.text_muted)); + let path_width = (expiry_x - row.left() - GAP).max(MIN_TEXT_WIDTH); + let path = short(&link.path, MAX_ROW_CHARS); + let galley = cut(ui, &path, path_width, FontId::proportional(BODY_SIZE), color(colors.text)); + ui.painter().galley(Pos2::new(row.left(), row.center().y - galley.size().y / 2.0), galley, color(colors.text)); + if !revoke { + return; + } + if armed { + dialog.publish.armed = None; + if let Some((client, vault_id)) = account(dialog) { + dialog.publish.ask(&client, &vault_id, Work::Revoke(link.id.clone())); + } + } else { + dialog.publish.armed = Some(key); + } +} + +/// The choice of the lifetime of the next link, and the button that mints it. +fn create(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let colors = &tokens.theme.colors; + ui.add_space(8.0); + ui.horizontal(|ui| { + for choice in Expiry::CHOICES { + let label = choice.label(); + let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), color(colors.text)); + let width = galley.size().x + PILL_PAD * 2.0; + let (rect, _space) = ui.allocate_exact_size(Vec2::new(width, pill_height(ui)), Sense::hover()); + let hit = ui.interact(rect, ui.id().with((DIALOG_ID, "link-expiry", choice.label())), Sense::click()).on_hover_cursor(CursorIcon::PointingHand); + let selected = dialog.publish.expiry == choice; + let fill = if selected { color(colors.accent) } else { color(colors.surface_2) }; + let tint = if selected { color(colors.on_accent) } else { color(colors.text) }; + ui.painter().rect_filled(rect, CornerRadius::same(u8::MAX), fill); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, tint); + if hit.clicked() { + dialog.publish.expiry = choice; + } + } + }); + ui.add_space(8.0); + let label = strings::create_link(); + let width = pill_width(ui, label); + let (rect, _space) = ui.allocate_exact_size(Vec2::new(width, pill_height(ui)), Sense::hover()); + let create = pill(ui, tokens, (DIALOG_ID, "create-link"), label, rect.center()); + if create + && let Some(note) = dialog.publish.note.clone() + && let Some((client, vault_id)) = account(dialog) + { + let hours = dialog.publish.expiry.hours(); + dialog.publish.ask(&client, &vault_id, Work::CreateLink { path: note, hours }); + } +} + +/// The secret of the last minted link, shown once, with the note that says so. +fn minted(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let Some(url) = dialog.publish.minted.clone() else { return }; + url_box(ui, tokens, (DIALOG_ID, "copy-link"), &url, strings::link_once()); +} + +/// A URL in a box of its own: the text, its Copy button and the note under it. +fn url_box(ui: &mut Ui, tokens: &Tokens, salt: (&str, &str), url: &str, note: &str) { + let colors = &tokens.theme.colors; + let (line, _space) = ui.allocate_exact_size(Vec2::new(ui.available_width(), pill_height(ui)), Sense::hover()); + let copy_width = pill_width(ui, strings::copy()); + let copy = pill(ui, tokens, salt, strings::copy(), Pos2::new(line.right() - copy_width / 2.0, line.center().y)); + let url_width = (line.width() - copy_width - GAP).max(MIN_TEXT_WIDTH); + let galley = cut(ui, url, url_width, FontId::proportional(BODY_SIZE), color(colors.text)); + ui.painter().galley(Pos2::new(line.left(), line.center().y - galley.size().y / 2.0), galley, color(colors.text)); + if copy { + ui.ctx().copy_text(url.to_owned()); + } + ui.add_space(6.0); + ui.label(egui::RichText::new(note).size(BODY_SIZE).color(color(colors.text_muted))); +} + +/// Draws the line of the last failed ask, when there was one. +pub(super) fn note(dialog: &mut ShareDialog, ui: &mut Ui, tokens: &Tokens) { + let colors = &tokens.theme.colors; + let Some(error) = dialog.publish.error.clone() else { return }; + ui.add_space(6.0); + ui.label(egui::RichText::new(error).size(BODY_SIZE).color(color(colors.text_muted))); +} + +/// A pill button centred at `center`; whether it was clicked. +fn pill(ui: &Ui, tokens: &Tokens, salt: impl std::hash::Hash + std::fmt::Debug, label: &str, center: Pos2) -> bool { + let colors = &tokens.theme.colors; + let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), color(colors.text)); + let width = galley.size().x + PILL_PAD * 2.0; + let rect = Rect::from_center_size(center, Vec2::new(width, pill_height(ui))); + let hit = ui.interact(rect, ui.id().with(salt), Sense::click()).on_hover_cursor(CursorIcon::PointingHand); + ui.painter().rect_filled(rect, CornerRadius::same(u8::MAX), if hit.hovered() { color(colors.border) } else { color(colors.surface_2) }); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color(colors.text)); + hit.clicked() +} + +/// The height of a pill: the touch target of the compact layout. +fn pill_height(ui: &Ui) -> f32 { + crate::style::compact::target(ui.ctx(), PILL_HEIGHT) +} + +/// The width a pill with that label takes, points. +fn pill_width(ui: &Ui, label: &str) -> f32 { + ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), Color32::PLACEHOLDER).size().x + PILL_PAD * 2.0 +} + +/// A text button at `right`; whether it was clicked. +fn text_button(ui: &Ui, salt: impl std::hash::Hash + std::fmt::Debug, label: &str, tint: Color32, right: f32, center_y: f32) -> bool { + let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), tint); + let width = galley.size().x + PILL_PAD * 2.0; + let center = Pos2::new(right - width / 2.0, center_y); + let hit = + ui.interact(Rect::from_center_size(center, Vec2::new(width, ROW_HEIGHT)), ui.id().with(salt), Sense::click()).on_hover_cursor(CursorIcon::PointingHand); + ui.painter().galley(center - galley.size() / 2.0, galley, tint); + hit.clicked() +} + +/// The width a text button with that label takes, points. +fn text_width(ui: &Ui, label: &str) -> f32 { + ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(BODY_SIZE), Color32::PLACEHOLDER).size().x + PILL_PAD * 2.0 +} + +/// The text of `text`, cut to `width` and marked with an ellipsis. +fn cut(ui: &Ui, text: &str, width: f32, font: FontId, tint: Color32) -> std::sync::Arc { + let mut job = LayoutJob::default(); + job.wrap.max_width = width.max(MIN_TEXT_WIDTH); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.append(text, 0.0, TextFormat { font_id: font, color: tint, ..Default::default() }); + ui.ctx().fonts_mut(|fonts| fonts.layout_job(job)) +} + +/// The text of `text`, cut to `limit` characters. +fn short(text: &str, limit: usize) -> String { + text.chars().take(limit).collect() +} + +/// What a link row says about its end: the time left, never, or revoked. +fn expiry_text(link: &Link, now_ms: u64) -> String { + if link.revoked { + return strings::link_revoked().to_owned(); + } + if link.expires_ms == 0 { + return strings::link_never().to_owned(); + } + let left_ms = link.expires_ms.saturating_sub(now_ms); + if left_ms == 0 { + return strings::expired().to_owned(); + } + let (template, count) = if left_ms > DAY_MS { (strings::in_days(), left_ms / DAY_MS) } else { (strings::in_hours(), left_ms.div_ceil(HOUR_MS)) }; + template.replace("{n}", &count.to_string()) +} + +/// The current Unix time in milliseconds. +fn now_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) +} diff --git a/crates/ui-egui/tests/publish_ui.rs b/crates/ui-egui/tests/publish_ui.rs new file mode 100644 index 0000000..2b061f9 --- /dev/null +++ b/crates/ui-egui/tests/publish_ui.rs @@ -0,0 +1,511 @@ +//! The "Publish and links" section of the Share dialog, driven the way a user +//! does: real pointer events through whole frames, over a real session with a +//! bound vault and a stub server that speaks the publish routes. +//! +//! The stub keeps the pages and the links of the vault, so an unpublish takes +//! a page away and a minted link shows up in the list without its secret. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use egui::{Context, Event, PointerButton, Pos2, RawInput, Rect, vec2}; +use notescraft_engine::{Session, bind_folder}; +use notescraft_ui_egui::theme::Tokens; +use notescraft_ui_egui::views::share_dialog::ShareDialog; + +/// The token of the bound vault; it must never reach a painted text. +const TOKEN: &str = "secret-token"; +/// The secret of a minted link; it shows once and never in the list. +const SECRET: &str = "link-secret-9f2"; +/// The vault of the stub. +const VAULT: &str = "v-1"; +/// Milliseconds of one day. +const DAY_MS: u64 = 86_400_000; +/// Seconds of thirty days, the lifetime of a month. +const MONTH_SECS: &str = "2592000"; + +/// The members the stub answers with: an owner and a viewer. +const MEMBERS_OWNER: &str = concat!( + r##"[{"user_id":"u-1","name":"Marcos","color":"#cba6f7","role":"owner","last_seen_ms":1},"##, + r##"{"user_id":"u-2","name":"Ana","color":"#94e2d5","role":"viewer","last_seen_ms":2}]"## +); + +/// The members of a vault whose account is a viewer. +const MEMBERS_VIEWER: &str = r##"[{"user_id":"u-1","name":"Marcos","color":"#cba6f7","role":"viewer","last_seen_ms":1}]"##; + +/// One request the stub received. +#[derive(Clone)] +struct Request { + /// The method of the request line. + method: String, + /// The path of the request line. + path: String, + /// The body of the request. + body: String, +} + +/// What the stub keeps: the published pages and the share links. +struct Server { + /// The members of the vault, as the server lists them. + members: String, + /// The published pages, as `(id, url path)`. + publications: Vec<(String, String)>, + /// The share links, as `(id, path)`. + links: Vec<(String, String)>, + /// Every request, in order. + seen: Vec, +} + +impl Server { + /// The pages the vault publishes, as the server lists them. + fn publications_json(&self) -> String { + let rows: Vec = + self.publications.iter().map(|(id, path)| format!(r#"{{"id":"{id}","kind":"note","target":"a.md","url_path":"{path}","created_ms":1}}"#)).collect(); + format!("[{}]", rows.join(",")) + } + + /// The links of the vault, as the server lists them; never a secret. + fn links_json(&self) -> String { + let expires = now_ms() + 7 * DAY_MS; + let rows: Vec = + self.links.iter().map(|(id, path)| format!(r#"{{"id":"{id}","path":"{path}","created_ms":1,"expires_ms":{expires},"revoked":false}}"#)).collect(); + format!("[{}]", rows.join(",")) + } +} + +/// A stub server that speaks the publish routes of the sync server. +struct Stub { + /// The base URL of the stub. + base: String, + /// The state of the stub. + state: Arc>, + /// Whether the listener thread should stop. + stop: Arc, +} + +impl Stub { + /// Starts a stub on an ephemeral port. + fn spawn() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub"); + listener.set_nonblocking(true).expect("nonblocking"); + let port = listener.local_addr().expect("addr").port(); + let state = Arc::new(Mutex::new(Server { members: MEMBERS_OWNER.to_owned(), publications: Vec::new(), links: Vec::new(), seen: Vec::new() })); + let stop = Arc::new(AtomicBool::new(false)); + let thread = (Arc::clone(&state), Arc::clone(&stop)); + let handle = std::thread::spawn(move || { + while !thread.1.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let _ = stream.set_nonblocking(false); + let Ok(mut writer) = stream.try_clone() else { continue }; + let state = Arc::clone(&thread.0); + std::thread::spawn(move || { + let mut reader = BufReader::new(stream); + let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(20))); + while let Some(request) = read_request(&mut reader) { + let answer = answer(&state, &request); + write_answer(&mut writer, answer.0, &answer.1); + } + }); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + }); + let _ = handle; + Self { base: format!("http://127.0.0.1:{port}"), state, stop } + } + + /// The state of the stub. + fn state(&self) -> MutexGuard<'_, Server> { + self.state.lock().expect("state") + } + + /// Every request the stub received, as `(method, path, body)`. + fn requests(&self) -> Vec<(String, String, String)> { + self.state().seen.iter().map(|request| (request.method.clone(), request.path.clone(), request.body.clone())).collect() + } +} + +impl Drop for Stub { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +/// The answer of the stub for one request: the status and the body. +fn answer(state: &Mutex, request: &Request) -> (u16, String) { + let mut server = state.lock().expect("state"); + server.seen.push(request.clone()); + let path = request.path.as_str(); + if request.method == "GET" && path == format!("/v1/vaults/{VAULT}/members") { + return (200, server.members.clone()); + } + if request.method == "GET" && path == format!("/v1/vaults/{VAULT}/invites") { + return (200, "[]".to_owned()); + } + if request.method == "GET" && path == format!("/v1/vaults/{VAULT}/publications") { + return (200, server.publications_json()); + } + if request.method == "GET" && path == format!("/v1/vaults/{VAULT}/share-links") { + return (200, server.links_json()); + } + if request.method == "POST" && path == format!("/v1/vaults/{VAULT}/publications") { + let target = field(&request.body, "target"); + let id = format!("pub-{}", server.publications.len() + 1); + let url_path = format!("/p/slug/{target}"); + server.publications.push((id.clone(), url_path.clone())); + return (200, format!(r#"{{"id":"{id}","slug":"slug","url_path":"{url_path}"}}"#)); + } + if request.method == "POST" && path == format!("/v1/vaults/{VAULT}/share-links") { + let note = field(&request.body, "path"); + let id = format!("l-{}", server.links.len() + 1); + server.links.push((id.clone(), note)); + let expires = now_ms() + 7 * DAY_MS; + return (200, format!(r#"{{"id":"{id}","url_path":"/s/{SECRET}","expires_ms":{expires}}}"#)); + } + if request.method == "DELETE" && path.starts_with(&format!("/v1/vaults/{VAULT}/publications/")) { + let id = path.rsplit('/').next().unwrap_or_default().to_owned(); + server.publications.retain(|(page, _)| *page != id); + return (204, String::new()); + } + if request.method == "DELETE" && path.starts_with(&format!("/v1/vaults/{VAULT}/share-links/")) { + let id = path.rsplit('/').next().unwrap_or_default().to_owned(); + server.links.retain(|(link, _)| *link != id); + return (204, String::new()); + } + (404, r#"{"error":"no such route"}"#.to_owned()) +} + +/// The value of a field of a JSON body, when it is there. +fn field(body: &str, name: &str) -> String { + let key = format!("\"{name}\""); + let Some(start) = body.find(&key) else { return String::new() }; + let rest = body.get(start + key.len()..).unwrap_or_default(); + let Some(colon) = rest.find(':') else { return String::new() }; + let rest = rest.get(colon + 1..).unwrap_or_default().trim_start(); + let Some(value) = rest.strip_prefix('"') else { return String::new() }; + let end = value.find('"').unwrap_or_default(); + value.get(..end).unwrap_or_default().to_owned() +} + +/// Reads one request of a connection. +fn read_request(reader: &mut BufReader) -> Option { + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_owned(); + let path = parts.next()?.to_owned(); + let mut length = 0_usize; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).ok()? == 0 { + break; + } + if header.trim_end().is_empty() { + break; + } + if let Some((name, value)) = header.trim_end().split_once(':') + && name.trim().eq_ignore_ascii_case("content-length") + { + length = value.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0_u8; length]; + if length > 0 { + reader.read_exact(&mut body).ok()?; + } + Some(Request { method, path, body: String::from_utf8_lossy(&body).into_owned() }) +} + +/// Writes one answer of the stub. +fn write_answer(stream: &mut TcpStream, status: u16, body: &str) { + let head = format!("HTTP/1.1 {status} S\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +/// The current Unix time in milliseconds. +fn now_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) +} + +/// A vault bound to the stub, with one note in it. +fn bound_vault(name: &str, base: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("notescraft-publish-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("vault root"); + bind_folder(&root, base, VAULT, TOKEN, "d-1", Some("u-1")).expect("bind the vault"); + std::fs::write(root.join("a.md"), "# A\n").expect("seed"); + root +} + +/// A painted text and where it started. +#[derive(Debug)] +struct Painted { + /// The text of the shape. + text: String, + /// Where the text starts. + pos: Pos2, +} + +/// The Share dialog over a vault, driven with real events. +struct Driver { + /// The dialog under test. + dialog: ShareDialog, + /// The egui context that paints it. + ctx: Context, + /// The clock of the frames. + time: f64, + /// The commands of the frames since the last click. + commands: Vec, +} + +impl Driver { + /// The dialog over `vault`, about the note `note`. + fn over(vault: &std::path::Path, note: Option<&str>) -> Self { + let session = Session::open(vault).expect("session"); + let dialog = ShareDialog::open_for("Project Atlas", &session, note); + let ctx = Context::default(); + notescraft_ui_egui::fonts::install(&ctx); + notescraft_ui_egui::motion::set_enabled(&ctx, false); + ctx.all_styles_mut(|theme| theme.animation_time = 0.0); + Driver { dialog, ctx, time: 1.0, commands: Vec::new() } + } + + /// One frame with `events`; the texts it painted. + fn frame(&mut self, events: Vec) -> Vec { + self.time += 0.05; + let tokens = Tokens::new(notescraft_themes::builtin("catppuccin-mocha").expect("theme")); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let dialog = &mut self.dialog; + let mut out = self.ctx.run_ui(input, |ui| { + dialog.show(ui.ctx(), &tokens); + }); + out.textures_delta.clear(); + self.commands.extend(out.platform_output.commands); + out.shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if !text.galley.text().is_empty() => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos }), + _ => None, + }) + .collect() + } + + /// Frames until `wanted` is painted and its place stops moving; the texts. + /// + /// The card grows while the jobs answer, so a click has to wait for the + /// layout to settle. + fn wait_for(&mut self, wanted: &str) -> Vec { + for _ in 0..100 { + let texts = self.frame(Vec::new()); + if texts.iter().any(|painted| painted.text.contains(wanted)) { + return self.settled(wanted); + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("never painted {wanted:?}"); + } + + /// Frames until `wanted` is gone and its place stops moving; the texts. + fn wait_until_gone(&mut self, wanted: &str) -> Vec { + for _ in 0..100 { + let texts = self.frame(Vec::new()); + if !texts.iter().any(|painted| painted.text.contains(wanted)) { + return texts; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("{wanted:?} never went away"); + } + + /// Frames until the place of `wanted` stops moving; the last texts. + fn settled(&mut self, wanted: &str) -> Vec { + let mut texts = Vec::new(); + let mut last = None; + for _ in 0..20 { + texts = self.frame(Vec::new()); + let place = texts.iter().find(|painted| painted.text.contains(wanted)).map(|painted| painted.pos); + if place.is_some() && place == last { + return texts; + } + last = place; + std::thread::sleep(Duration::from_millis(10)); + } + texts + } + + /// Frames with real time for the workers; the last texts. + fn settle(&mut self) -> Vec { + let mut texts = Vec::new(); + for _ in 0..12 { + texts = self.frame(Vec::new()); + std::thread::sleep(Duration::from_millis(10)); + } + texts + } + + /// Clicks `at` and lets the frame land: move, press and release as their + /// own frames, like a real pointer. + fn click(&mut self, at: Pos2) -> Vec { + self.commands.clear(); + let _ = self.frame(vec![Event::PointerMoved(at)]); + let _ = self.frame(vec![Event::PointerMoved(at), button(at, true)]); + let _ = self.frame(vec![Event::PointerMoved(at), button(at, false)]); + for _ in 0..10 { + let _ = self.frame(Vec::new()); + std::thread::sleep(Duration::from_millis(5)); + } + self.frame(Vec::new()) + } +} + +/// One pointer button event at `at`. +fn button(at: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE } +} + +/// The painted `text`, or a panic. +fn painted<'a>(texts: &'a [Painted], text: &str) -> &'a Painted { + texts.iter().find(|painted| painted.text == text).unwrap_or_else(|| panic!("no {text:?} painted")) +} + +/// The click on a painted label, as raw button events. +fn click_at(text: &Painted) -> Pos2 { + text.pos + vec2(16.0, 10.0) +} + +/// The dialog lists the published pages of the vault with their URL. +#[test] +fn the_dialog_lists_the_published_pages() { + let stub = Stub::spawn(); + stub.state().publications.push(("pub-1".to_owned(), "/p/slug/a.md".to_owned())); + let mut app = Driver::over(&bound_vault("list", &stub.base), Some("a.md")); + let texts = app.wait_for("/p/slug/a.md"); + assert!(texts.iter().any(|painted| painted.text.contains("PUBLISH AND LINKS")), "the section is there"); + assert!(texts.iter().any(|painted| painted.text == "Note"), "the kind of the page"); + assert!(texts.iter().any(|painted| painted.text == "Unpublish"), "the unpublish button"); + assert!(!texts.iter().any(|painted| painted.text.contains(TOKEN)), "the token never paints"); +} + +/// The copy button of a published page puts its URL on the clipboard. +#[test] +fn the_copy_button_puts_the_url_on_the_clipboard() { + let stub = Stub::spawn(); + stub.state().publications.push(("pub-1".to_owned(), "/p/slug/a.md".to_owned())); + let mut app = Driver::over(&bound_vault("copy", &stub.base), Some("a.md")); + let texts = app.wait_for("/p/slug/a.md"); + app.click(click_at(painted(&texts, "Copy"))); + assert!( + app.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(url) if url.contains("/p/slug/a.md"))), + "copy put the url on the clipboard" + ); +} + +/// Publishing the note of the dialog shows its public URL. +#[test] +fn publishing_the_note_shows_its_url() { + let stub = Stub::spawn(); + let mut app = Driver::over(&bound_vault("publish", &stub.base), Some("a.md")); + let texts = app.wait_for("Publish this note"); + app.click(click_at(painted(&texts, "Publish this note"))); + let texts = app.wait_for("/p/slug/a.md"); + assert!(texts.iter().any(|painted| painted.text.contains("This page is public")), "the note that says so"); + let seen = stub.requests(); + assert!( + seen.iter().any(|(method, path, body)| method == "POST" && path == "/v1/vaults/v-1/publications" && body.contains("a.md")), + "the page went to the server: {seen:?}" + ); +} + +/// Unpublishing takes the page away and tells the server. +#[test] +fn unpublishing_takes_the_page_away() { + let stub = Stub::spawn(); + stub.state().publications.push(("pub-1".to_owned(), "/p/slug/a.md".to_owned())); + let mut app = Driver::over(&bound_vault("unpublish", &stub.base), Some("a.md")); + let texts = app.wait_for("/p/slug/a.md"); + app.click(click_at(painted(&texts, "Unpublish"))); + let texts = app.wait_for("Click again"); + app.click(click_at(painted(&texts, "Click again"))); + let texts = app.wait_until_gone("/p/slug/a.md"); + assert!(!texts.iter().any(|painted| painted.text == "Unpublish"), "the row is gone"); + let seen = stub.requests(); + assert!( + seen.iter().any(|(method, path, _body)| method == "DELETE" && path == "/v1/vaults/v-1/publications/pub-1"), + "the unpublish went to the server: {seen:?}" + ); +} + +/// A minted link shows its secret once; the list row never carries it. +#[test] +fn a_minted_link_shows_its_secret_once() { + let stub = Stub::spawn(); + let mut app = Driver::over(&bound_vault("mint", &stub.base), Some("a.md")); + let texts = app.wait_for("Create link"); + app.click(click_at(painted(&texts, "Create link"))); + let texts = app.wait_for(SECRET); + assert!(texts.iter().any(|painted| painted.text.contains("shown only once")), "the note that says so"); + let secrets: Vec<&Painted> = texts.iter().filter(|painted| painted.text.contains(SECRET)).collect(); + assert_eq!(secrets.len(), 1, "the secret shows once: {secrets:?}"); + let row = texts.iter().find(|painted| painted.text == "a.md").expect("the row of the link"); + assert!(!row.text.contains(SECRET), "the row carries no secret"); +} + +/// The expiry of a link is the one the user picked. +#[test] +fn the_expiry_of_a_link_is_the_one_picked() { + let stub = Stub::spawn(); + let mut app = Driver::over(&bound_vault("expiry", &stub.base), Some("a.md")); + let texts = app.wait_for("Create link"); + app.click(click_at(painted(&texts, "30 days"))); + let texts = app.wait_for("Create link"); + app.click(click_at(painted(&texts, "Create link"))); + app.wait_for(SECRET); + let seen = stub.requests(); + let minted = seen + .iter() + .find(|(method, path, _body)| method == "POST" && path == "/v1/vaults/v-1/share-links") + .unwrap_or_else(|| panic!("the link was minted: {seen:?}")); + assert!(minted.2.contains(MONTH_SECS), "thirty days in seconds: {}", minted.2); +} + +/// A viewer sees the lists but no buttons. +#[test] +fn a_viewer_sees_the_lists_without_the_buttons() { + let stub = Stub::spawn(); + stub.state().members = MEMBERS_VIEWER.to_owned(); + stub.state().publications.push(("pub-1".to_owned(), "/p/slug/a.md".to_owned())); + stub.state().links.push(("l-0".to_owned(), "notes/b.md".to_owned())); + let mut app = Driver::over(&bound_vault("viewer", &stub.base), Some("a.md")); + app.wait_for("Marcos"); + let texts = app.wait_for("/p/slug/a.md"); + assert!(texts.iter().any(|painted| painted.text.contains("PUBLISH AND LINKS")), "the section is there"); + assert!(texts.iter().any(|painted| painted.text.contains("notes/b.md")), "the list of the links"); + assert!(!texts.iter().any(|painted| painted.text == "Unpublish"), "no button for a viewer"); + assert!(!texts.iter().any(|painted| painted.text == "Revoke"), "no button for a viewer"); + assert!(!texts.iter().any(|painted| painted.text == "Create link"), "no button for a viewer"); + assert!(!texts.iter().any(|painted| painted.text == "Publish this note"), "no button for a viewer"); + assert!(!texts.iter().any(|painted| painted.text == "Copy"), "no button for a viewer"); +} + +/// A vault that is not on a server shows nothing of the section. +#[test] +fn an_unbound_vault_shows_nothing() { + let root = std::env::temp_dir().join(format!("notescraft-publish-plain-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("vault root"); + std::fs::write(root.join("a.md"), "# A\n").expect("seed"); + let mut app = Driver::over(&root, Some("a.md")); + let texts = app.settle(); + assert!(!texts.iter().any(|painted| painted.text.contains("PUBLISH AND LINKS")), "no section without a server"); + assert!(texts.iter().any(|painted| painted.text.contains("not on a server yet")), "the explanation"); +}