feat: Publish and links section in the Share dialog
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:
@@ -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};
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user