Files
tuipub/src/app.rs
T
PhilandClaude Opus 5.5 c3ad8d05e0 Media uploads: photos, video and audio
Attach files from the new Media field (path prompt with Tab completion,
or paste/drop paths) or by URL. Files go to the server's media endpoint
(q=config media-endpoint, multipart 'file', 201 Location) in the
background; publishing waits for them. The extension picks the photo,
video or audio property; photos can carry alt text ({value, alt}).
Edits replace or delete a media property only when it changed. Notes
with media need no text. Warns when q=config lists post types without
video or audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:31:03 -07:00

1948 lines
67 KiB
Rust

//! App state and key handling. Pure logic: no terminal or network here.
use std::path::PathBuf;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode};
use crate::micropub::{
self, Error, Kind, Media, MediaKind, Note, Post, ServerConfig, SyndicationTarget, Update,
};
use crate::worker::{PAGE_SIZE, Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Title,
Link,
Content,
Media,
Tags,
Syndication,
}
impl Focus {
const ORDER: [Focus; 6] = [
Focus::Title,
Focus::Link,
Focus::Content,
Focus::Media,
Focus::Tags,
Focus::Syndication,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Compose,
Posts,
}
/// What to do once the user agrees to throw away unsaved changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Discard {
/// Start a new, empty note.
New,
/// Load the post at this index of the list into the editor.
Edit(usize),
/// Stop editing and go back to the post list.
BackToPosts,
}
/// A photo, video or audio file in the form. `url` is None while it uploads.
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
/// Matches an upload's reply to its attachment.
pub id: u64,
pub kind: MediaKind,
/// File name, for display.
pub name: String,
pub url: Option<String>,
pub alt: String,
}
/// A one-line question shown in a popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Prompt {
/// A file path or URL to attach.
AddMedia,
/// Alt text for the attachment with this id.
Alt(u64),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Normal,
ConfirmPublish,
Sending,
Published { url: String, draft: bool },
ConfirmQuit,
ConfirmDiscard(Discard),
ConfirmDelete(String),
Prompt(Prompt),
}
/// Where the syndication target list came from.
#[derive(Debug, Clone, PartialEq)]
pub enum Targets {
Loading,
Loaded,
/// Couldn't load q=config. Publishing leaves syndication to the server default.
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Posts {
NotLoaded,
Loading,
Loaded,
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Info(String),
Error(String),
}
pub struct App {
pub me: String,
pub screen: Screen,
pub title: TextArea<'static>,
/// In reply to / like of / repost of, depending on `kind`.
pub link: TextArea<'static>,
pub content: TextArea<'static>,
pub tags: TextArea<'static>,
pub media: Vec<Attachment>,
pub media_cursor: usize,
next_media_id: u64,
/// From q=config. None if the server has none (or the config hasn't loaded).
pub media_endpoint: Option<String>,
/// Media kinds the server lists as post types (all, if it lists none).
pub media_kinds: Vec<MediaKind>,
/// Folder of the last file attached, to start the next path from.
last_dir: String,
/// The text box of the open prompt.
pub prompt: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets,
pub target_cursor: usize,
pub focus: Focus,
pub kind: Kind,
/// Kinds the server takes (from q=config).
pub kinds: Vec<Kind>,
pub mode: Mode,
pub status: Option<Status>,
pub last_permalink: Option<String>,
/// The post being edited (as loaded), or None for a new note.
pub editing: Option<Post>,
pub posts: Vec<Post>,
pub posts_state: Posts,
pub posts_cursor: usize,
/// The last page was full, so there may be more posts to load.
pub posts_more: bool,
pub can_list: bool,
/// Permalink of the last deleted post, for undo.
pub last_deleted: Option<String>,
/// Granted scopes, to explain missing permissions.
pub scope: String,
pub should_quit: bool,
/// Set when a request failed for auth reasons, so main can suggest logging in again.
pub needs_login: bool,
}
fn text_area(placeholder: &str) -> TextArea<'static> {
let mut t = TextArea::default();
t.set_placeholder_text(placeholder);
t.set_cursor_line_style(Style::default());
t
}
fn title_area() -> TextArea<'static> {
text_area("Optional title")
}
fn link_area() -> TextArea<'static> {
link_area_for(Kind::Note)
}
fn link_area_for(kind: Kind) -> TextArea<'static> {
text_area(match kind {
Kind::Note => "Optional: URL of the post you're replying to",
Kind::Like => "URL of the post you like",
Kind::Repost => "URL of the post you're reposting",
})
}
/// The note body: soft-wraps at word boundaries (long words such as URLs break mid-word).
fn content_area() -> TextArea<'static> {
let mut t = text_area("What's on your mind? (Markdown)");
t.set_wrap_mode(WrapMode::WordOrGlyph);
t
}
fn tags_area() -> TextArea<'static> {
text_area("Comma-separated, e.g. rust, indieweb")
}
fn filled(mut area: TextArea<'static>, text: &str) -> TextArea<'static> {
area.insert_str(text);
area.move_cursor(CursorMove::Top);
area.move_cursor(CursorMove::Head);
area
}
fn single_line(area: &TextArea) -> String {
area.lines().join(" ").trim().to_string()
}
const ADD_MEDIA_HINT: &str = "Path to a photo, video or audio file, or its URL";
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
/// The last part of a URL's path, to name an attachment.
fn name_from_url(url: &str) -> String {
let path = url.split(['?', '#']).next().unwrap_or(url);
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|n| !n.is_empty() && !n.contains(':'))
.unwrap_or(url)
.to_string()
}
fn expand_tilde(s: &str) -> PathBuf {
let home = std::env::var_os("HOME").map(PathBuf::from);
match (s.strip_prefix('~'), home) {
(Some(""), Some(h)) => h,
(Some(rest), Some(h)) if rest.starts_with('/') => h.join(&rest[1..]),
_ => PathBuf::from(s),
}
}
/// A path as typed, pasted or dropped onto the terminal: quoted, `file://` URIs,
/// backslash-escaped spaces and `~` all work.
pub fn clean_path(input: &str) -> PathBuf {
let s = input.trim();
for q in ['\'', '"'] {
if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
return expand_tilde(&s[1..s.len() - 1]);
}
}
if s.starts_with("file://")
&& let Some(path) = url::Url::parse(s).ok().and_then(|u| u.to_file_path().ok())
{
return path;
}
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.extend(chars.next()),
c => out.push(c),
}
}
expand_tilde(&out)
}
/// Tab completion for a path: completes as far as it's unambiguous.
/// Returns the new text and how many entries matched.
pub fn complete_path(input: &str) -> (String, usize) {
let (dir_part, prefix) = match input.rfind('/') {
Some(i) => input.split_at(i + 1),
None => ("", input),
};
let dir = if dir_part.is_empty() {
PathBuf::from(".")
} else {
clean_path(dir_part)
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return (input.to_string(), 0);
};
let matches: Vec<(String, bool)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let hidden = name.starts_with('.') && !prefix.starts_with('.');
(name.starts_with(prefix) && !hidden).then(|| (name, e.path().is_dir()))
})
.collect();
let done = match matches.as_slice() {
[] => return (input.to_string(), 0),
[(name, true)] => format!("{name}/"),
[(name, false)] => name.clone(),
[(first, _), rest @ ..] => {
let mut common = first.as_str();
for (name, _) in rest {
while !name.starts_with(common) {
let mut c = common.chars();
c.next_back();
common = c.as_str();
}
}
common.to_string()
}
};
(format!("{dir_part}{done}"), matches.len())
}
impl App {
pub fn new(me: String, scope: String) -> Self {
let mut app = App {
me,
screen: Screen::Compose,
title: title_area(),
link: link_area(),
content: content_area(),
tags: tags_area(),
media: Vec::new(),
media_cursor: 0,
next_media_id: 0,
media_endpoint: None,
media_kinds: MediaKind::ALL.to_vec(),
last_dir: String::new(),
prompt: TextArea::default(),
targets: Vec::new(),
targets_state: Targets::Loading,
target_cursor: 0,
focus: Focus::Content,
kind: Kind::Note,
kinds: Kind::ALL.to_vec(),
mode: Mode::Normal,
status: None,
last_permalink: None,
editing: None,
posts: Vec::new(),
posts_state: Posts::NotLoaded,
posts_cursor: 0,
posts_more: false,
can_list: true,
last_deleted: None,
scope,
should_quit: false,
needs_login: false,
};
app.update_cursors();
app
}
fn has_scope(&self, s: &str) -> bool {
self.scope.split_whitespace().any(|x| x == s)
}
/// A hint if the token can't edit or delete (tokens from tuipub 0.1 only had create).
pub fn scope_hint(&self) -> Option<&'static str> {
(!self.has_scope("update") || !self.has_scope("delete"))
.then_some("Run `tuipub login` again to allow editing and deleting.")
}
fn focus_order(&self) -> Vec<Focus> {
Focus::ORDER
.into_iter()
// Crosspost targets are chosen once, when posting, and what a like or
// repost points at can't be changed either.
.filter(|f| match (*f, &self.editing) {
(Focus::Syndication, Some(_)) => false,
(Focus::Link, Some(p)) => p.kind() == Kind::Note,
_ => true,
})
.collect()
}
fn step_focus(&mut self, by: isize) {
let order = self.focus_order();
let i = order.iter().position(|f| *f == self.focus).unwrap_or(0) as isize;
self.focus = order[(i + by).rem_euclid(order.len() as isize) as usize];
self.update_cursors();
}
fn update_cursors(&mut self) {
let focus = self.focus;
for (field, area) in [
(Focus::Title, &mut self.title),
(Focus::Link, &mut self.link),
(Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags),
] {
let style = if field == focus {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
area.set_cursor_style(style);
}
}
/// Unsaved work: any text in a new note, or any change to the post being edited.
pub fn is_dirty(&self) -> bool {
self.uploading()
|| match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => {
!self.media.is_empty()
|| [&self.title, &self.link, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty()))
}
}
}
/// Whether any attachment is still uploading.
pub fn uploading(&self) -> bool {
self.media.iter().any(|p| p.url.is_none())
}
fn push_media(
&mut self,
kind: MediaKind,
name: String,
url: Option<String>,
alt: String,
) -> u64 {
if !self.media_kinds.contains(&kind) {
self.error(format!(
"This server doesn't list {} posts, so it may drop {name}.",
kind.property()
));
}
let id = self.next_media_id;
self.next_media_id += 1;
self.media.push(Attachment {
id,
kind,
name,
url,
alt,
});
self.media_cursor = self.media.len() - 1;
id
}
/// Attach a file by URL, or start uploading one. The extension decides
/// whether it's a photo, video or audio.
fn add_media(&mut self, input: &str) -> Option<Request> {
let input = input.trim();
if input.is_empty() {
return None;
}
if is_url(input) {
let kind = MediaKind::guess(input);
self.push_media(
kind,
name_from_url(input),
Some(input.to_string()),
String::new(),
);
return None;
}
let Some(endpoint) = self.media_endpoint.clone() else {
self.error(match self.targets_state {
Targets::Loading => "Still loading the server config; try again in a moment.",
Targets::Failed(_) => "Couldn't load the server config (Ctrl-R to retry).",
Targets::Loaded => {
"This server has no media endpoint. You can still attach files by URL."
}
});
return None;
};
let path = clean_path(input);
if !path.is_file() {
self.error(format!("No such file: {}", path.display()));
return None;
}
if let Some(dir) = input.rfind('/').map(|i| &input[..=i])
&& !dir.starts_with(['\'', '"'])
{
self.last_dir = dir.to_string();
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| input.to_string());
self.info(format!("Uploading {name}…"));
let id = self.push_media(MediaKind::guess(&name), name, None, String::new());
Some(Request::Upload { id, endpoint, path })
}
fn open_prompt(&mut self, prompt: Prompt) {
let (placeholder, text) = match prompt {
Prompt::AddMedia => (ADD_MEDIA_HINT, self.last_dir.clone()),
Prompt::Alt(id) => (
"Describe it for people who can't see it",
self.media
.iter()
.find(|p| p.id == id)
.map(|p| p.alt.clone())
.unwrap_or_default(),
),
};
let mut area = filled(text_area(placeholder), &text);
area.move_cursor(CursorMove::End);
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.prompt = area;
self.mode = Mode::Prompt(prompt);
}
fn handle_prompt_key(&mut self, prompt: Prompt, key: KeyEvent) -> Option<Request> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Enter => {
self.mode = Mode::Normal;
let text = single_line(&self.prompt);
match prompt {
Prompt::AddMedia => return self.add_media(&text),
Prompt::Alt(id) => {
if let Some(p) = self.media.iter_mut().find(|p| p.id == id) {
p.alt = text;
}
}
}
}
KeyCode::Tab if prompt == Prompt::AddMedia => {
let (text, n) = complete_path(&single_line(&self.prompt));
if n == 0 {
self.error("No matching files.");
} else {
self.status = None;
}
let mut area = filled(text_area(ADD_MEDIA_HINT), &text);
area.move_cursor(CursorMove::End);
area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.prompt = area;
}
_ => {
let input = Input::from(key);
if !(input.ctrl && input.key == Key::Char('m')) {
self.prompt.input(input);
}
}
}
None
}
fn handle_media_key(&mut self, key: KeyEvent) -> Option<Request> {
let n = self.media.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.media_cursor = self.media_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.media_cursor = (self.media_cursor + 1).min(n.saturating_sub(1))
}
KeyCode::Char('a') => self.open_prompt(Prompt::AddMedia),
KeyCode::Enter if n == 0 => self.open_prompt(Prompt::AddMedia),
KeyCode::Enter | KeyCode::Char('e') => {
if let Some(p) = self.media.get(self.media_cursor) {
self.open_prompt(Prompt::Alt(p.id));
}
}
KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace if n > 0 => {
self.media.remove(self.media_cursor);
self.media_cursor = self.media_cursor.min(self.media.len().saturating_sub(1));
}
// Move the selected attachment up or down (the first one leads).
KeyCode::Char('K') if self.media_cursor > 0 => {
self.media.swap(self.media_cursor, self.media_cursor - 1);
self.media_cursor -= 1;
}
KeyCode::Char('J') if self.media_cursor + 1 < n => {
self.media.swap(self.media_cursor, self.media_cursor + 1);
self.media_cursor += 1;
}
_ => {}
}
None
}
fn clear_form(&mut self) {
self.title = title_area();
self.link = link_area();
self.content = content_area();
self.tags = tags_area();
self.media.clear();
self.media_cursor = 0;
self.editing = None;
self.kind = Kind::Note;
self.focus = Focus::Content;
self.update_cursors();
}
/// The link field is locked when editing a like or repost.
pub fn link_locked(&self) -> bool {
self.editing
.as_ref()
.is_some_and(|p| p.kind() != Kind::Note)
}
/// Ctrl-T: next post type the server supports. Keeps whatever is typed.
fn cycle_kind(&mut self) {
if self.editing.is_some() {
self.info("An existing post's type can't be changed.");
return;
}
let i = self.kinds.iter().position(|k| *k == self.kind).unwrap_or(0);
self.kind = self.kinds[(i + 1) % self.kinds.len()];
let url = single_line(&self.link);
self.link = filled(link_area_for(self.kind), &url);
self.link.move_cursor(CursorMove::End);
if self.kind != Kind::Note {
self.focus = Focus::Link;
}
self.update_cursors();
}
fn load_post(&mut self, post: Post) {
self.title = filled(title_area(), post.title.as_deref().unwrap_or(""));
self.kind = post.kind();
let link = post.target().or(post.in_reply_to.as_deref()).unwrap_or("");
self.link = filled(link_area_for(self.kind), link);
self.content = filled(content_area(), &post.content);
self.tags = filled(tags_area(), &post.tags.join(", "));
self.media.clear();
for p in &post.media {
self.push_media(
p.kind,
name_from_url(&p.url),
Some(p.url.clone()),
p.alt.clone(),
);
}
self.media_cursor = 0;
self.editing = Some(post);
self.focus = Focus::Content;
self.update_cursors();
self.screen = Screen::Compose;
self.status = None;
}
pub fn note(&self, draft: bool) -> Note {
let title = single_line(&self.title);
let link = single_line(&self.link);
let link = (!link.is_empty()).then_some(link);
Note {
title: (!title.is_empty()).then_some(title),
content: self.content.lines().join("\n").trim_end().to_string(),
tags: micropub::parse_tags(&self.tags.lines().join(",")),
syndicate_to: match self.targets_state {
Targets::Loaded => Some(
self.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.uid.clone())
.collect(),
),
_ => None,
},
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
like_of: link.clone().filter(|_| self.kind == Kind::Like),
repost_of: link.filter(|_| self.kind == Kind::Repost),
media: self
.media
.iter()
.filter_map(|a| {
a.url.clone().map(|url| Media {
kind: a.kind,
url,
alt: a.alt.clone(),
})
})
.collect(),
draft,
}
}
/// The status a save keeps unless the user picks another: drafts stay drafts.
pub fn default_draft(&self) -> bool {
self.editing.as_ref().is_some_and(|p| p.draft)
}
/// Human summary of where the post will be crossposted.
pub fn syndication_summary(&self) -> String {
if let Some(p) = &self.editing {
return match p.syndication.len() {
0 => "not crossposted".into(),
n => format!(
"{n} crossposted cop{} (edits follow)",
if n == 1 { "y" } else { "ies" }
),
};
}
match &self.targets_state {
Targets::Loaded => {
let names: Vec<_> = self
.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.name.as_str())
.collect();
if names.is_empty() {
"nowhere".into()
} else {
names.join(", ")
}
}
_ => "server default".into(),
}
}
pub fn selected_post(&self) -> Option<&Post> {
self.posts.get(self.posts_cursor)
}
fn info(&mut self, s: impl Into<String>) {
self.status = Some(Status::Info(s.into()));
}
fn error(&mut self, s: impl Into<String>) {
self.status = Some(Status::Error(s.into()));
}
fn reload_posts(&mut self) -> Option<Request> {
if !self.can_list {
self.error("This server can't list posts.");
return None;
}
self.posts_state = Posts::Loading;
Some(Request::List { offset: 0 })
}
/// Handle a reply from the worker. May ask for a follow-up request (e.g. reload the list).
pub fn handle_response(&mut self, resp: Response) -> Option<Request> {
match resp {
Response::Config(Ok(cfg)) => self.set_config(cfg),
Response::Config(Err(e)) => {
self.targets_state = Targets::Failed(e.to_string());
self.report(e);
}
Response::Published(Ok(url), draft) => {
self.clear_form();
self.info(if draft {
format!("Saved draft {url}")
} else {
format!("Published {url}")
});
self.last_permalink = Some(url.clone());
self.mode = Mode::Published { url, draft };
// The list is now out of date.
if self.posts_state != Posts::NotLoaded {
return self.reload_posts();
}
}
Response::Published(Err(e), _) | Response::Updated { result: Err(e), .. } => {
self.mode = Mode::Normal;
self.report(e);
}
Response::Updated {
url,
result: Ok(()),
} => {
self.mode = Mode::Normal;
if self.editing.as_ref().is_some_and(|p| p.url == url) {
self.clear_form();
}
self.last_permalink = Some(url);
self.screen = Screen::Posts;
self.info("Saved.");
return self.reload_posts();
}
Response::Listed {
offset,
result: Ok(posts),
} => {
self.posts_more = posts.len() == PAGE_SIZE;
if offset == 0 {
self.posts = posts;
} else {
self.posts.extend(posts);
}
self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1));
self.posts_state = Posts::Loaded;
}
Response::Listed { result: Err(e), .. } => {
self.posts_state = Posts::Failed(e.to_string());
self.report(e);
}
Response::Deleted {
url,
result: Ok(()),
} => {
self.mode = Mode::Normal;
self.posts.retain(|p| p.url != url);
self.posts_cursor = self.posts_cursor.min(self.posts.len().saturating_sub(1));
self.last_deleted = Some(url);
self.info("Deleted. Press u to undo.");
}
Response::Undeleted {
url,
result: Ok(()),
} => {
self.mode = Mode::Normal;
self.last_deleted = None;
self.info(format!("Restored {url}"));
return self.reload_posts();
}
Response::Deleted { result: Err(e), .. }
| Response::Undeleted { result: Err(e), .. } => {
self.mode = Mode::Normal;
self.report(e);
}
// Replies for attachments that were removed (or a form that was cleared) are dropped.
Response::Uploaded { id, result } => {
let i = self.media.iter().position(|p| p.id == id)?;
match result {
Ok(url) => {
self.media[i].url = Some(url);
if !self.uploading() {
let name = self.media[i].name.clone();
self.info(format!("Uploaded {name}."));
}
}
Err(e) => {
let gone = self.media.remove(i);
self.media_cursor =
self.media_cursor.min(self.media.len().saturating_sub(1));
if matches!(e, Error::Unauthorized(_)) {
self.needs_login = true;
}
self.error(format!("Couldn't upload {}: {e}", gone.name));
}
}
}
}
None
}
fn report(&mut self, e: Error) {
if matches!(e, Error::Unauthorized(_)) {
self.needs_login = true;
}
self.error(e.to_string());
}
fn set_config(&mut self, cfg: ServerConfig) {
self.can_list = cfg.can_list();
self.media_endpoint = cfg.media_endpoint.clone();
self.media_kinds = MediaKind::ALL
.into_iter()
.filter(|k| cfg.supports_media(*k))
.collect();
self.kinds = Kind::ALL.into_iter().filter(|k| cfg.supports(*k)).collect();
// Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets);
self.targets = cfg
.syndicate_to
.into_iter()
.map(|t| {
let on = previous
.iter()
.find(|(p, _)| p.uid == t.uid)
.is_none_or(|(_, on)| *on);
(t, on)
})
.collect();
self.target_cursor = self.target_cursor.min(self.targets.len().saturating_sub(1));
self.targets_state = Targets::Loaded;
}
/// Handle a key press. Returns a request for the worker, if any.
pub fn handle_key(&mut self, key: KeyEvent) -> Option<Request> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match self.mode.clone() {
Mode::Sending => None,
Mode::Prompt(p) => self.handle_prompt_key(p, key),
Mode::ConfirmPublish => {
let draft = match key.code {
KeyCode::Char('y') | KeyCode::Enter => self.default_draft(),
KeyCode::Char('p') => false,
KeyCode::Char('d') => true,
_ => {
self.mode = Mode::Normal;
return None;
}
};
self.send(draft)
}
Mode::ConfirmQuit => {
match key.code {
KeyCode::Char('y') => self.should_quit = true,
_ => self.mode = Mode::Normal,
}
None
}
Mode::ConfirmDiscard(action) => {
self.mode = Mode::Normal;
if key.code == KeyCode::Char('y') {
return self.discard(action);
}
None
}
Mode::ConfirmDelete(url) => {
if key.code == KeyCode::Char('y') {
self.mode = Mode::Sending;
self.info("Deleting…");
return Some(Request::Delete(url));
}
self.mode = Mode::Normal;
None
}
Mode::Published { url, draft } => {
self.mode = Mode::Normal;
if key.code == KeyCode::Char('o') {
self.open_post(&url, draft);
}
None
}
Mode::Normal => {
if ctrl && matches!(key.code, KeyCode::Char('q' | 'c')) {
self.quit();
return None;
}
match self.screen {
Screen::Compose => self.handle_compose_key(key, ctrl),
Screen::Posts => self.handle_posts_key(key, ctrl),
}
}
}
}
fn quit(&mut self) {
if self.is_dirty() {
self.mode = Mode::ConfirmQuit;
} else {
self.should_quit = true;
}
}
/// Validate the form, then publish a new note or save the edited post.
fn send(&mut self, draft: bool) -> Option<Request> {
let note = self.note(draft);
self.mode = Mode::Normal;
if self.uploading() {
self.error("Wait for the uploads to finish.");
return None;
}
let link = note
.in_reply_to
.as_ref()
.or(note.like_of.as_ref())
.or(note.repost_of.as_ref());
match (self.kind, link) {
(Kind::Like | Kind::Repost, None) if !self.link_locked() => {
let what = if self.kind == Kind::Like {
"you like"
} else {
"you're reposting"
};
self.error(format!("Add the URL of the post {what}."));
return None;
}
(_, Some(u)) if !(u.starts_with("http://") || u.starts_with("https://")) => {
self.error("The link must be an http(s) URL.");
return None;
}
_ => {}
}
if let Some(orig) = &self.editing {
let update = Update::diff(orig, &note);
if update.is_empty() {
self.info("No changes to save.");
return None;
}
self.mode = Mode::Sending;
self.info("Saving…");
return Some(Request::Update(update));
}
self.mode = Mode::Sending;
self.info(if draft {
"Saving draft…"
} else {
"Publishing…"
});
Some(Request::Publish(note))
}
fn discard(&mut self, action: Discard) -> Option<Request> {
match action {
Discard::New => {
self.clear_form();
self.screen = Screen::Compose;
}
Discard::BackToPosts => {
self.clear_form();
self.screen = Screen::Posts;
}
Discard::Edit(i) => {
if let Some(post) = self.posts.get(i).cloned() {
self.load_post(post);
}
}
}
None
}
/// Run `action` now if nothing would be lost, or ask first.
fn guard(&mut self, action: Discard) -> Option<Request> {
if self.is_dirty() {
self.mode = Mode::ConfirmDiscard(action);
None
} else {
self.discard(action)
}
}
fn open_post(&mut self, url: &str, draft: bool) {
if draft {
self.info("Drafts aren't public yet, so there's nothing to open.");
return;
}
if let Err(e) = open::that_detached(url) {
self.error(format!("Could not open browser: {e}"));
}
}
fn show_posts(&mut self) -> Option<Request> {
self.screen = Screen::Posts;
if matches!(self.posts_state, Posts::NotLoaded | Posts::Failed(_)) {
return self.reload_posts();
}
None
}
fn handle_compose_key(&mut self, key: KeyEvent, ctrl: bool) -> Option<Request> {
match (ctrl, key.code) {
(false, KeyCode::Esc) => {
if self.editing.is_some() {
return self.guard(Discard::BackToPosts);
}
self.quit();
return None;
}
(true, KeyCode::Char('t')) => {
self.cycle_kind();
return None;
}
(true, KeyCode::Char('s')) => {
// Likes and reposts don't need a comment.
// Micropub doesn't require text, so a note with media may have none.
if self.kind == Kind::Note
&& self.media.is_empty()
&& self.note(false).content.trim().is_empty()
{
self.error("Write something first.");
} else if self.uploading() {
self.error("Wait for the uploads to finish.");
} else {
self.status = None;
self.mode = Mode::ConfirmPublish;
}
return None;
}
(true, KeyCode::Char('p')) => return self.show_posts(),
(true, KeyCode::Char('n')) => {
if self.editing.is_some() || self.is_dirty() {
return self.guard(Discard::New);
}
return None;
}
(true, KeyCode::Char('r')) => {
self.targets_state = Targets::Loading;
self.info("Refreshing server config…");
return Some(Request::LoadConfig);
}
(true, KeyCode::Char('o')) => {
if let Some(url) = self.last_permalink.clone() {
self.open_post(&url, false);
}
return None;
}
(_, KeyCode::Tab) => {
self.step_focus(1);
return None;
}
(_, KeyCode::BackTab) => {
self.step_focus(-1);
return None;
}
_ => {}
}
match self.focus {
Focus::Content => {
self.content.input(Input::from(key));
}
Focus::Title | Focus::Link | Focus::Tags => {
let input = Input::from(key);
// Single-line fields: Enter moves on instead of adding a line.
if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) {
self.step_focus(1);
} else {
let area = match self.focus {
Focus::Title => &mut self.title,
Focus::Link => &mut self.link,
_ => &mut self.tags,
};
area.input(input);
}
}
Focus::Media => return self.handle_media_key(key),
Focus::Syndication => self.handle_target_key(key),
}
None
}
fn handle_posts_key(&mut self, key: KeyEvent, ctrl: bool) -> Option<Request> {
let n = self.posts.len();
match (ctrl, key.code) {
(false, KeyCode::Esc) | (true, KeyCode::Char('p')) => self.screen = Screen::Compose,
(true, KeyCode::Char('n')) | (false, KeyCode::Char('n')) => {
return self.guard(Discard::New);
}
(false, KeyCode::Char('q')) => self.quit(),
(false, KeyCode::Up | KeyCode::Char('k')) => {
self.posts_cursor = self.posts_cursor.saturating_sub(1)
}
(false, KeyCode::Down | KeyCode::Char('j')) => {
if self.posts_cursor + 1 < n {
self.posts_cursor += 1;
} else if self.posts_more && self.posts_state == Posts::Loaded {
// At the bottom: fetch the next page.
self.posts_state = Posts::Loading;
return Some(Request::List { offset: n });
}
}
(false, KeyCode::Home | KeyCode::Char('g')) => self.posts_cursor = 0,
(false, KeyCode::Char('r')) => {
self.posts_cursor = 0;
return self.reload_posts();
}
(false, KeyCode::Char('u')) => match self.last_deleted.clone() {
Some(url) => {
self.mode = Mode::Sending;
self.info("Restoring…");
return Some(Request::Undelete(url));
}
None => self.info("Nothing to undo."),
},
_ => {}
}
let post = self.selected_post().cloned()?;
match (ctrl, key.code) {
(false, KeyCode::Enter | KeyCode::Char('e')) => {
if self.editing.as_ref().is_some_and(|p| p.url == post.url) {
self.screen = Screen::Compose;
return None;
}
return self.guard(Discard::Edit(self.posts_cursor));
}
(false, KeyCode::Char('d') | KeyCode::Delete) => {
self.mode = Mode::ConfirmDelete(post.url);
}
(false, KeyCode::Char('o')) => self.open_post(&post.url, post.draft),
(false, KeyCode::Char('p')) if post.draft => {
let mut update = Update {
url: post.url,
..Default::default()
};
update
.replace
.insert("post-status".into(), serde_json::json!(["published"]));
self.mode = Mode::Sending;
self.info("Publishing draft…");
return Some(Request::Update(update));
}
_ => {}
}
None
}
fn handle_target_key(&mut self, key: KeyEvent) {
let n = self.targets.len();
if n == 0 {
return;
}
match key.code {
// Up/Down for the stacked layout, Left/Right for the single row.
KeyCode::Up | KeyCode::Left | KeyCode::Char('k' | 'h') => {
self.target_cursor = self.target_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Right | KeyCode::Char('j' | 'l') => {
self.target_cursor = (self.target_cursor + 1).min(n - 1)
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some((_, on)) = self.targets.get_mut(self.target_cursor) {
*on = !*on;
}
}
KeyCode::Char('a') => {
let all_on = self.targets.iter().all(|(_, on)| *on);
for (_, on) in &mut self.targets {
*on = !all_on;
}
}
_ => {}
}
}
/// Bracketed paste: insert text into the focused field. Pasting (or dropping)
/// files onto the Media field attaches them, one per line.
pub fn handle_paste(&mut self, text: &str) -> Vec<Request> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
if matches!(self.mode, Mode::Prompt(_)) {
self.prompt.insert_str(text.trim().replace('\n', " "));
return Vec::new();
}
if self.mode != Mode::Normal || self.screen != Screen::Compose {
return Vec::new();
}
if self.focus == Focus::Media {
return text.lines().filter_map(|l| self.add_media(l)).collect();
}
match self.focus {
Focus::Content => {
self.content.insert_str(text);
}
Focus::Title => {
self.title.insert_str(text.replace('\n', " "));
}
Focus::Link => {
self.link.insert_str(text.replace('\n', ""));
}
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
}
Focus::Media | Focus::Syndication => {}
}
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn typ(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key(key(KeyCode::Char(c)));
}
}
fn target(uid: &str) -> SyndicationTarget {
SyndicationTarget {
uid: uid.into(),
name: uid.to_uppercase(),
}
}
fn loaded(targets: &[&str]) -> App {
let mut app = App::new(
"https://example.com/".into(),
"create update delete media".into(),
);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(),
..Default::default()
})));
app
}
fn post(slug: &str, draft: bool) -> Post {
Post {
url: format!("https://example.com/note/{slug}"),
title: None,
content: format!("Post {slug}"),
tags: vec!["rust".into()],
published: "2026-10-31T12:00:00".into(),
draft,
in_reply_to: None,
syndication: vec![],
..Default::default()
}
}
/// App on the post list with these posts loaded.
fn with_posts(posts: Vec<Post>) -> App {
let mut app = loaded(&[]);
assert!(matches!(
app.handle_key(ctrl('p')),
Some(Request::List { offset: 0 })
));
app.handle_response(Response::Listed {
offset: 0,
result: Ok(posts),
});
assert_eq!(app.screen, Screen::Posts);
app
}
#[test]
fn compose_and_publish_flow() {
let mut app = loaded(&["gts", "bsky"]);
typ(&mut app, "Hello");
app.handle_key(key(KeyCode::Enter));
typ(&mut app, "world");
app.handle_key(key(KeyCode::Tab)); // -> media
app.handle_key(key(KeyCode::Tab)); // -> tags
typ(&mut app, "rust, #tui");
app.handle_key(key(KeyCode::Tab)); // -> syndication
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Char(' '))); // uncheck bsky
assert!(app.handle_key(ctrl('s')).is_none());
assert_eq!(app.mode, Mode::ConfirmPublish);
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected publish request");
};
assert_eq!(app.mode, Mode::Sending);
assert_eq!(note.content, "Hello\nworld");
assert_eq!(note.title, None);
assert_eq!(note.tags, vec!["rust", "tui"]);
assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()]));
assert!(!note.draft);
app.handle_response(Response::Published(
Ok("https://example.com/note/x".into()),
false,
));
assert!(matches!(app.mode, Mode::Published { .. }));
assert!(!app.is_dirty(), "form clears after publishing");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn save_new_note_as_draft_with_reply() {
let mut app = loaded(&[]);
app.handle_key(key(KeyCode::BackTab)); // -> reply
assert_eq!(app.focus, Focus::Link);
typ(&mut app, "https://other.example/post");
app.handle_key(key(KeyCode::Enter)); // -> content
typ(&mut app, "Nice post!");
app.handle_key(ctrl('s'));
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('d'))) else {
panic!("expected publish request");
};
assert!(note.draft);
assert_eq!(
note.in_reply_to.as_deref(),
Some("https://other.example/post")
);
app.handle_response(Response::Published(Ok("u".into()), true));
assert_eq!(
app.mode,
Mode::Published {
url: "u".into(),
draft: true
}
);
}
#[test]
fn reply_must_be_a_url() {
let mut app = loaded(&[]);
app.focus = Focus::Link;
typ(&mut app, "not a url");
app.focus = Focus::Content;
typ(&mut app, "x");
app.handle_key(ctrl('s'));
assert!(app.handle_key(key(KeyCode::Char('y'))).is_none());
assert!(matches!(app.status, Some(Status::Error(_))));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn title_enter_moves_on() {
let mut app = loaded(&[]);
app.focus = Focus::Title;
typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.focus, Focus::Link);
assert_eq!(app.title.lines(), ["My title"]);
}
#[test]
fn content_wraps_and_keeps_wrapping_after_publish() {
let mut app = loaded(&[]);
assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph);
assert_eq!(app.title.wrap_mode(), WrapMode::None);
app.handle_response(Response::Published(Ok("u".into()), false));
assert_eq!(app.content.wrap_mode(), WrapMode::WordOrGlyph);
}
#[test]
fn empty_note_is_not_published() {
let mut app = loaded(&[]);
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::Normal);
assert!(matches!(app.status, Some(Status::Error(_))));
}
#[test]
fn unchecking_all_sends_empty_list_but_unknown_config_sends_none() {
let mut app = loaded(&["gts"]);
app.focus = Focus::Syndication;
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.note(false).syndicate_to, Some(vec![]));
assert_eq!(app.syndication_summary(), "nowhere");
let mut app = App::new("m".into(), String::new());
app.handle_response(Response::Config(Err(Error::NoLocation(
reqwest::StatusCode::OK,
))));
assert_eq!(app.note(false).syndicate_to, None);
assert_eq!(app.syndication_summary(), "server default");
}
#[test]
fn refresh_keeps_choices() {
let mut app = loaded(&["gts", "bsky"]);
app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")],
..Default::default()
})));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]);
}
#[test]
fn quit_asks_when_dirty() {
let mut app = loaded(&[]);
typ(&mut app, "draft");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::ConfirmQuit);
app.handle_key(key(KeyCode::Char('n')));
assert!(!app.should_quit);
app.handle_key(ctrl('q'));
app.handle_key(key(KeyCode::Char('y')));
assert!(app.should_quit);
let mut clean = loaded(&[]);
clean.handle_key(key(KeyCode::Esc));
assert!(clean.should_quit);
}
#[test]
fn publish_error_returns_to_editing_and_flags_login() {
let mut app = loaded(&[]);
typ(&mut app, "x");
app.handle_key(ctrl('s'));
app.handle_key(key(KeyCode::Char('y')));
app.handle_response(Response::Published(
Err(Error::Unauthorized("expired".into())),
false,
));
assert_eq!(app.mode, Mode::Normal);
assert!(app.needs_login);
assert!(app.is_dirty(), "draft is kept after a failed publish");
}
#[test]
fn paste_into_single_line_fields() {
let mut app = loaded(&[]);
app.focus = Focus::Tags;
app.handle_paste("a\nb");
assert_eq!(app.note(false).tags, vec!["a", "b"]);
}
#[test]
fn list_pages_when_scrolling_past_the_end() {
let page: Vec<Post> = (0..PAGE_SIZE)
.map(|i| post(&i.to_string(), false))
.collect();
let mut app = with_posts(page);
assert!(app.posts_more);
for _ in 0..PAGE_SIZE - 1 {
assert!(app.handle_key(key(KeyCode::Down)).is_none());
}
let Some(Request::List { offset }) = app.handle_key(key(KeyCode::Down)) else {
panic!("expected next page");
};
assert_eq!(offset, PAGE_SIZE);
assert!(
app.handle_key(key(KeyCode::Down)).is_none(),
"one request at a time"
);
app.handle_response(Response::Listed {
offset,
result: Ok(vec![post("last", false)]),
});
assert_eq!(app.posts.len(), PAGE_SIZE + 1);
assert!(!app.posts_more);
app.handle_key(key(KeyCode::Down));
assert_eq!(app.selected_post().unwrap().content, "Post last");
}
#[test]
fn edit_sends_only_changes_and_returns_to_list() {
let mut app = with_posts(vec![post("a", false), post("b", true)]);
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.screen, Screen::Compose);
assert_eq!(app.content.lines(), ["Post b"]);
assert_eq!(app.tags.lines(), ["rust"]);
assert!(!app.is_dirty());
assert!(!app.focus_order().contains(&Focus::Syndication));
// Saving with no changes does nothing.
app.handle_key(ctrl('s'));
assert!(app.handle_key(key(KeyCode::Enter)).is_none());
app.handle_key(key(KeyCode::End));
typ(&mut app, " edited");
assert!(app.is_dirty());
app.handle_key(ctrl('s'));
// Enter keeps the draft a draft, so only content changes.
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected update");
};
assert_eq!(u.url, "https://example.com/note/b");
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
assert!(u.delete.is_empty());
let next = app.handle_response(Response::Updated {
url: u.url,
result: Ok(()),
});
assert!(matches!(next, Some(Request::List { offset: 0 })));
assert_eq!(app.screen, Screen::Posts);
assert!(app.editing.is_none());
}
#[test]
fn publish_a_draft_from_the_editor_and_the_list() {
let mut app = with_posts(vec![post("d", true)]);
app.handle_key(key(KeyCode::Enter));
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else {
panic!("expected update");
};
assert_eq!(u.replace["post-status"], serde_json::json!(["published"]));
let mut app = with_posts(vec![post("d", true), post("pub", false)]);
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('p'))) else {
panic!("expected update");
};
assert_eq!(u.url, "https://example.com/note/d");
app.handle_response(Response::Updated {
url: u.url,
result: Ok(()),
});
app.handle_key(key(KeyCode::Down));
assert!(
app.handle_key(key(KeyCode::Char('p'))).is_none(),
"already published"
);
}
#[test]
fn delete_then_undo() {
let mut app = with_posts(vec![post("a", false), post("b", false)]);
assert!(app.handle_key(key(KeyCode::Char('d'))).is_none());
assert!(matches!(app.mode, Mode::ConfirmDelete(_)));
let Some(Request::Delete(url)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected delete");
};
app.handle_response(Response::Deleted {
url: url.clone(),
result: Ok(()),
});
assert_eq!(app.posts.len(), 1);
assert_eq!(app.last_deleted.as_deref(), Some(url.as_str()));
let Some(Request::Undelete(u)) = app.handle_key(key(KeyCode::Char('u'))) else {
panic!("expected undelete");
};
assert_eq!(u, url);
let next = app.handle_response(Response::Undeleted {
url: u,
result: Ok(()),
});
assert!(matches!(next, Some(Request::List { offset: 0 })));
assert!(app.last_deleted.is_none());
}
#[test]
fn delete_can_be_cancelled() {
let mut app = with_posts(vec![post("a", false)]);
app.handle_key(key(KeyCode::Char('d')));
assert!(app.handle_key(key(KeyCode::Char('n'))).is_none());
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.posts.len(), 1);
}
#[test]
fn unsaved_text_is_protected_when_switching_posts() {
let mut app = with_posts(vec![post("a", false)]);
app.handle_key(key(KeyCode::Esc)); // back to compose
typ(&mut app, "half-written note");
app.handle_key(ctrl('p'));
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::Edit(0)));
app.handle_key(key(KeyCode::Char('n')));
assert_eq!(app.screen, Screen::Posts);
assert!(app.editing.is_none());
app.handle_key(key(KeyCode::Enter));
app.handle_key(key(KeyCode::Char('y')));
assert_eq!(app.screen, Screen::Compose);
assert_eq!(app.content.lines(), ["Post a"]);
// Esc while editing with changes asks before leaving.
typ(&mut app, "x");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::ConfirmDiscard(Discard::BackToPosts));
}
#[test]
fn edit_toggles_name_and_reply() {
let mut orig = post("a", false);
orig.title = Some("Old".into());
orig.in_reply_to = Some("https://r.example/".into());
let mut app = with_posts(vec![orig]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.title.lines(), ["Old"]);
assert_eq!(app.link.lines(), ["https://r.example/"]);
app.title = title_area();
app.link = link_area();
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected update");
};
assert_eq!(u.delete, ["name", "in-reply-to"]);
}
#[test]
fn scope_hint_for_old_tokens() {
let app = App::new("m".into(), "create media".into());
assert!(app.scope_hint().is_some());
assert!(loaded(&[]).scope_hint().is_none());
}
#[test]
fn server_without_listing() {
let mut app = loaded(&[]);
app.handle_response(Response::Config(Ok(ServerConfig {
q: vec!["config".into()],
..Default::default()
})));
assert!(app.handle_key(ctrl('p')).is_none());
assert!(matches!(app.status, Some(Status::Error(_))));
}
#[test]
fn drafts_are_not_opened() {
let mut app = with_posts(vec![post("d", true)]);
app.handle_key(key(KeyCode::Char('o')));
assert!(matches!(app.status, Some(Status::Info(ref s)) if s.contains("Drafts")));
}
#[test]
fn like_without_comment() {
let mut app = loaded(&["gts"]);
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Like);
assert_eq!(app.focus, Focus::Link);
// A like needs its URL.
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::ConfirmPublish, "no comment needed");
assert!(app.handle_key(key(KeyCode::Enter)).is_none());
assert!(matches!(app.status, Some(Status::Error(ref e)) if e.contains("URL")));
typ(&mut app, "https://social.example/@a/1");
app.handle_key(ctrl('s'));
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected publish");
};
assert_eq!(note.like_of.as_deref(), Some("https://social.example/@a/1"));
assert_eq!(note.in_reply_to, None);
assert_eq!(note.content, "");
assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()]));
}
#[test]
fn ctrl_t_cycles_supported_kinds_and_keeps_the_url() {
let mut app = loaded(&[]);
app.focus = Focus::Link;
typ(&mut app, "https://x.example/");
app.handle_key(ctrl('t'));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Repost);
assert_eq!(
app.note(false).repost_of.as_deref(),
Some("https://x.example/")
);
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Note);
assert_eq!(
app.note(false).in_reply_to.as_deref(),
Some("https://x.example/")
);
// A server without likes or reposts: Ctrl-T stays on Note.
app.handle_response(Response::Config(Ok(ServerConfig {
post_types: vec![micropub::PostType {
kind: "note".into(),
}],
..Default::default()
})));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Note);
}
#[test]
fn editing_a_like_locks_its_url_and_type() {
let mut like = post("l", false);
like.content = String::new();
like.like_of = Some("https://x.example/".into());
let mut app = with_posts(vec![like]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.kind, Kind::Like);
assert!(app.link_locked());
assert!(!app.focus_order().contains(&Focus::Link));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Like);
// Adding a comment is an ordinary content update.
typ(&mut app, "So good");
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected update");
};
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
}
/// Loaded with a media endpoint, focused on the Media field.
fn media_app() -> App {
let mut app = App::new("https://example.com/".into(), "create media".into());
app.handle_response(Response::Config(Ok(ServerConfig {
media_endpoint: Some("https://example.com/micropub/media".into()),
..Default::default()
})));
app.focus = Focus::Media;
app
}
fn scratch_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tuipub-test-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn attach(app: &mut App, path: &str) -> Option<Request> {
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.mode, Mode::Prompt(Prompt::AddMedia));
app.handle_paste(path);
app.handle_key(key(KeyCode::Enter))
}
#[test]
fn attach_upload_and_publish() {
let dir = scratch_dir("attach");
let file = dir.join("cat pic.jpg");
std::fs::write(&file, b"jpeg").unwrap();
let mut app = media_app();
let Some(Request::Upload { id, endpoint, path }) =
attach(&mut app, &file.display().to_string())
else {
panic!("expected an upload");
};
assert_eq!(endpoint, "https://example.com/micropub/media");
assert_eq!(path, file);
assert!(app.uploading() && app.is_dirty());
// Can't publish until the upload is done.
app.focus = Focus::Content;
typ(&mut app, "Look");
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::Normal);
assert!(matches!(app.status, Some(Status::Error(_))));
app.handle_response(Response::Uploaded {
id,
result: Ok("https://example.com/media/a.jpg".into()),
});
// Alt text.
app.focus = Focus::Media;
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Prompt(Prompt::Alt(id)));
typ(&mut app, "A cat");
app.handle_key(key(KeyCode::Enter));
app.handle_key(ctrl('s'));
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected publish");
};
assert_eq!(
note.media,
vec![Media {
kind: MediaKind::Photo,
url: "https://example.com/media/a.jpg".into(),
alt: "A cat".into()
}]
);
assert_eq!(app.last_dir, format!("{}/", dir.display()));
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn failed_upload_is_removed_and_late_replies_ignored() {
let dir = scratch_dir("fail");
let file = dir.join("x.png");
std::fs::write(&file, b"png").unwrap();
let mut app = media_app();
let Some(Request::Upload { id, .. }) = attach(&mut app, &file.display().to_string()) else {
panic!("expected an upload");
};
app.handle_response(Response::Uploaded {
id,
result: Err(Error::Unauthorized("nope".into())),
});
assert!(app.media.is_empty() && app.needs_login);
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("x.png")));
// A reply for something no longer in the form does nothing.
app.handle_response(Response::Uploaded {
id: 99,
result: Ok("u".into()),
});
assert!(app.media.is_empty());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn urls_attach_without_upload_and_kinds_follow_extension() {
let mut app = loaded(&[]); // no media endpoint
app.focus = Focus::Media;
assert!(attach(&mut app, "https://cdn.example/clip.mp4").is_none());
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("no media endpoint")));
assert_eq!(app.media.len(), 1);
assert_eq!(app.media[0].kind, MediaKind::Video);
assert_eq!(app.media[0].name, "clip.mp4");
// Servers that list post types without video get a warning (but keep the file).
let mut app = App::new("m".into(), "media".into());
app.handle_response(Response::Config(Ok(ServerConfig {
post_types: vec![micropub::PostType {
kind: "photo".into(),
}],
..Default::default()
})));
app.focus = Focus::Media;
attach(&mut app, "https://cdn.example/clip.mp4");
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("may drop")));
assert_eq!(app.media.len(), 1);
attach(&mut app, "https://cdn.example/a.jpg");
assert_eq!(app.media.len(), 2);
// Media alone is enough for a note: Micropub doesn't require text.
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::ConfirmPublish);
}
#[test]
fn missing_file_is_reported() {
let mut app = media_app();
assert!(attach(&mut app, "/nonexistent/file.jpg").is_none());
assert!(matches!(&app.status, Some(Status::Error(e)) if e.contains("No such file")));
assert!(app.media.is_empty());
}
#[test]
fn pasting_paths_onto_media_field_uploads_each() {
let dir = scratch_dir("paste");
let (a, b) = (dir.join("a.jpg"), dir.join("b c.ogg"));
std::fs::write(&a, b"").unwrap();
std::fs::write(&b, b"").unwrap();
let mut app = media_app();
let reqs = app.handle_paste(&format!("{}\n'{}'\n", a.display(), b.display()));
assert_eq!(reqs.len(), 2);
assert_eq!(app.media[1].kind, MediaKind::Audio);
// Elsewhere, a path is just text.
app.focus = Focus::Content;
assert!(app.handle_paste(&a.display().to_string()).is_empty());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn remove_and_reorder_media_on_an_edited_post() {
let photo = |u: &str| Media {
kind: MediaKind::Photo,
url: u.into(),
alt: String::new(),
};
let mut p = post("a", false);
p.media = vec![photo("https://e/1.jpg"), photo("https://e/2.jpg")];
let mut app = with_posts(vec![p]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.media.len(), 2);
assert!(!app.is_dirty());
app.focus = Focus::Media;
app.handle_key(key(KeyCode::Char('J')));
assert_eq!(app.media[1].name, "1.jpg");
assert_eq!(app.media_cursor, 1);
assert!(app.is_dirty(), "order counts as a change");
app.handle_key(key(KeyCode::Char('d')));
app.handle_key(key(KeyCode::Char('d')));
assert!(app.media.is_empty());
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected update");
};
assert_eq!(u.delete, vec!["photo"]);
}
#[test]
fn paths_are_cleaned() {
let home = std::env::var("HOME").unwrap();
assert_eq!(
clean_path(" ~/a b.jpg "),
PathBuf::from(format!("{home}/a b.jpg"))
);
assert_eq!(clean_path("/x/a\\ b.jpg"), PathBuf::from("/x/a b.jpg"));
assert_eq!(clean_path("'/x/it\\s.jpg'"), PathBuf::from("/x/it\\s.jpg"));
assert_eq!(clean_path("\"/x/a b.jpg\""), PathBuf::from("/x/a b.jpg"));
assert_eq!(
clean_path("file:///x/a%20b.jpg"),
PathBuf::from("/x/a b.jpg")
);
assert_eq!(name_from_url("https://e/media/2026/x.jpg?s=1"), "x.jpg");
assert_eq!(name_from_url("https://e/"), "e");
}
#[test]
fn tab_completes_paths() {
let dir = scratch_dir("complete");
std::fs::write(dir.join("photo-1.jpg"), b"").unwrap();
std::fs::write(dir.join("photo-2.jpg"), b"").unwrap();
std::fs::create_dir_all(dir.join("videos")).unwrap();
std::fs::write(dir.join(".hidden"), b"").unwrap();
let d = format!("{}/", dir.display());
assert_eq!(complete_path(&format!("{d}ph")), (format!("{d}photo-"), 2));
assert_eq!(complete_path(&format!("{d}v")), (format!("{d}videos/"), 1));
assert_eq!(
complete_path(&format!("{d}photo-2")),
(format!("{d}photo-2.jpg"), 1)
);
assert_eq!(complete_path(&format!("{d}zz")).1, 0);
assert_eq!(complete_path(&d).1, 3, "hidden files only when asked for");
let mut app = media_app();
app.handle_key(key(KeyCode::Char('a')));
app.handle_paste(&format!("{d}v"));
app.handle_key(key(KeyCode::Tab));
assert_eq!(single_line(&app.prompt), format!("{d}videos/"));
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Normal);
assert!(app.media.is_empty());
std::fs::remove_dir_all(dir).ok();
}
}