feat: Publish and links section in the Share dialog
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Published pages (copy, unpublish), publish this note, read-only links with expiry choices; the secret link is shown once and never in lists, logs or toasts; viewers see lists without buttons; destructive buttons click twice. Re-exports PublicationKind from the engine. Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: 908 lib tests, publish_ui 8 and share_ui 10 pass, clippy -D warnings on ui-egui and engine and xtask tree clean. Open: the shell does not pass the note into the dialog yet and the card needs to scroll on short screens.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-20 20:10:55 -03:00
parent 2ca3702db0
commit 1d06b71950
7 changed files with 1225 additions and 1 deletions
+2 -1
View File
@@ -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};
+19
View File
@@ -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…"
+19
View File
@@ -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";
}
@@ -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<String>,
/// What the last failed ask said.
pub(crate) error: Option<String>,
/// The published pages and the read-only links of the section.
publish: publish::State,
/// The running job, when there is one.
job: Option<Arc<Mutex<Receiver<Outcome>>>>,
}
@@ -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 {
@@ -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<u64> {
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<u64> },
/// 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<Published>,
/// The links of the vault.
links: Vec<Link>,
},
/// 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<Published>,
/// The links of the vault.
links: Vec<Link>,
},
/// 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<Published>,
/// The links of the vault.
links: Vec<Link>,
},
/// What the server said.
Failed,
}
/// What the section knows between frames.
pub(super) struct State {
/// The published pages of the vault.
publications: Vec<Published>,
/// The share links of the vault.
links: Vec<Link>,
/// The secret of the last minted link, shown once and never stored.
minted: Option<String>,
/// The URL of the page that was published last, shown once.
fresh: Option<String>,
/// The note the dialog is about.
note: Option<String>,
/// The lifetime the next link gets.
expiry: Expiry,
/// The destructive action that waits for its second click.
armed: Option<String>,
/// What the last failed ask said.
error: Option<String>,
/// The running job, when there is one.
job: Option<Arc<Mutex<Receiver<Outcome>>>>,
/// 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<Mutex<ServerClient>>, 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<Mutex<ServerClient>>, 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<Mutex<ServerClient>>, 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);
}
@@ -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<Galley> {
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))
}
+511
View File
@@ -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<Request>,
}
impl Server {
/// The pages the vault publishes, as the server lists them.
fn publications_json(&self) -> String {
let rows: Vec<String> =
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<String> =
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<Mutex<Server>>,
/// Whether the listener thread should stop.
stop: Arc<AtomicBool>,
}
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<Server>, 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<TcpStream>) -> Option<Request> {
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<egui::OutputCommand>,
}
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<Event>) -> Vec<Painted> {
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<Painted> {
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<Painted> {
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<Painted> {
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<Painted> {
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<Painted> {
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");
}