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>
This commit is contained in:
Phil
2026-11-01 09:31:03 -07:00
co-authored by Claude Opus 5.5
parent c78ade3900
commit c3ad8d05e0
8 changed files with 1125 additions and 36 deletions
Generated
+18
View File
@@ -1817,6 +1817,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -2678,6 +2688,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3640,6 +3651,7 @@ dependencies = [
"directories",
"httpmock",
"keyring",
"mime_guess",
"open",
"rand 0.10.3",
"ratatui",
@@ -3678,6 +3690,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.27"
+2 -1
View File
@@ -9,11 +9,12 @@ base64 = "0.23.1"
clap = { version = "4.6.7", features = ["derive"] }
directories = "6.0.0"
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
mime_guess = "2"
open = "5.4.5"
rand = "0.10.3"
ratatui = "0.30.2"
ratatui-textarea = "0.9.4"
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query"] }
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query", "multipart"] }
scraper = "0.29.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
+12 -1
View File
@@ -25,7 +25,7 @@ Running `tuipub` with no account starts the login flow first.
| Key | Action |
| --- | --- |
| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Tags, Crosspost) |
| Tab / Shift-Tab | Next / previous field (Title, In reply to, Note, Media, Tags, Crosspost) |
| Ctrl-T | Post type: Note → Like → Repost (only types the server lists in `q=config`) |
| Ctrl-S | Publish or save. The confirm dialog offers **d** (draft) / **p** (publish) |
| Ctrl-P | Your posts |
@@ -35,6 +35,15 @@ Running `tuipub` with no account starts the login flow first.
| Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) |
| ← → / ↑ ↓, Space, a | Crosspost list: move, toggle, all/none (one row when they fit, stacked when the window is narrow) |
**Media**: Tab to the Media field and press **a**, then type a path (Tab completes it) or an http(s) URL. You can also paste or drag files onto the field (quoted paths, `file://` URIs and `\ ` escapes all work), one per line. Files upload to the server's media endpoint in the background; a URL is attached as is. The file extension decides whether it goes in `photo`, `video` or `audio`. In the field: **Enter** alt text · **d** remove · **J/K** move down/up · **↑↓** select. Publishing waits for uploads to finish. A note with media needs no text, as Micropub doesn't require it. If the server lists post types in `q=config` and video or audio isn't among them, tuipub warns that it may drop the file.
| Key (Media field) | Action |
| --- | --- |
| a | Attach a file or URL (Enter on an empty list does the same) |
| Enter / e | Edit alt text |
| d / Delete | Remove |
| J / K | Move down / up (the first one leads) |
**Likes and reposts** (StarPunk 2.5.0+): press Ctrl-T, paste the URL into "Like of" / "Repost of", and optionally add a comment. StarPunk turns likes and reposts of fediverse or Bluesky posts into native favourites/boosts when it crossposts; likes of ordinary web pages aren't crossposted. What a like or repost points at can't be changed afterwards, but its comment can.
When editing, saving sends only the fields you changed. Crosspost targets can't be changed after posting, but StarPunk updates existing crossposts when you edit.
@@ -63,6 +72,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
- **Login**: authorization code flow with PKCE (S256). tuipub runs a one-shot callback server on `127.0.0.1:<random port>` and uses `http://127.0.0.1:<port>/` as its `client_id`. The client ID and redirect URI share an origin, so the server needs no client registration. It checks `state` and `iss`. Scopes requested: `create update delete media`. Tokens from older tuipub versions only had `create media`; run `tuipub login` again to edit and delete.
- **Token storage**: the system keyring (Secret Service / gnome-keyring), keyed by your profile URL. Account settings go in `~/.config/tuipub/config.toml`.
- **Posts list**: `q=source` without `url` (`limit`/`offset`, drafts included). Edits are Micropub `update` with `replace`/`delete`; deletes are `delete`/`undelete`; drafts are `post-status: draft`.
- **Media**: uploads go to `media-endpoint` from `q=config` as `multipart/form-data` with one `file` part. The `Location` of the 201 response is the file's URL, sent as `photo` (`{"value", "alt"}` when there's alt text), `video` or `audio`. Edits `replace` a media property that changed and `delete` one that's now empty. Without a media endpoint you can still attach by URL.
- **Posting**: always sends a JSON body. `mp-syndicate-to` lists exactly the checked targets, and an empty list means "crosspost nowhere". If the target list can't be loaded, `mp-syndicate-to` is left out and the server's default applies (the confirm dialog says so).
### Environment
@@ -73,6 +83,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
## StarPunk notes
- StarPunk 2.3.3+ stores the Title as the note's `# Heading` first line. Older versions ignore it, so on those, start the note with `# Heading` instead.
- Media (2.6.4): only JPEG, PNG, GIF and WebP upload. It keeps the first 4 photos, and `video`/`audio` are ignored (its `q=config` doesn't list them, so tuipub warns). A note needs text even with photos.
- Listing, editing, deleting, drafts and replies need StarPunk 2.4.0+; likes and reposts need 2.5.0+. Ctrl-P shows a message instead of the list when a server says it can't list posts (`q` in `q=config`).
## Develop
+623 -12
View File
@@ -1,10 +1,14 @@
//! 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, Note, Post, ServerConfig, SyndicationTarget, Update};
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)]
@@ -12,15 +16,17 @@ pub enum Focus {
Title,
Link,
Content,
Media,
Tags,
Syndication,
}
impl Focus {
const ORDER: [Focus; 5] = [
const ORDER: [Focus; 6] = [
Focus::Title,
Focus::Link,
Focus::Content,
Focus::Media,
Focus::Tags,
Focus::Syndication,
];
@@ -43,6 +49,27 @@ pub enum Discard {
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,
@@ -52,6 +79,7 @@ pub enum Mode {
ConfirmQuit,
ConfirmDiscard(Discard),
ConfirmDelete(String),
Prompt(Prompt),
}
/// Where the syndication target list came from.
@@ -85,6 +113,17 @@ pub struct App {
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,
@@ -157,6 +196,99 @@ 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 {
@@ -166,6 +298,13 @@ impl App {
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,
@@ -239,19 +378,192 @@ impl App {
/// Unsaved work: any text in a new note, or any change to the post being edited.
pub fn is_dirty(&self) -> bool {
match &self.editing {
self.uploading()
|| match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => [&self.title, &self.link, &self.content, &self.tags]
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())),
.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;
@@ -289,6 +601,16 @@ impl App {
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();
@@ -317,6 +639,17 @@ impl App {
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,
}
}
@@ -456,6 +789,28 @@ impl App {
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
}
@@ -469,6 +824,11 @@ impl App {
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);
@@ -492,6 +852,7 @@ impl App {
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(),
@@ -559,6 +920,10 @@ impl App {
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()
@@ -661,8 +1026,14 @@ impl App {
}
(true, KeyCode::Char('s')) => {
// Likes and reposts don't need a comment.
if self.kind == Kind::Note && self.note(false).content.trim().is_empty() {
// 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;
@@ -716,6 +1087,7 @@ impl App {
area.input(input);
}
}
Focus::Media => return self.handle_media_key(key),
Focus::Syndication => self.handle_target_key(key),
}
None
@@ -814,12 +1186,20 @@ impl App {
}
}
/// Bracketed paste: insert text into the focused field.
pub fn handle_paste(&mut self, text: &str) {
if self.mode != Mode::Normal || self.screen != Screen::Compose {
return;
}
/// 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);
@@ -833,8 +1213,9 @@ impl App {
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
}
Focus::Syndication => {}
Focus::Media | Focus::Syndication => {}
}
Vec::new()
}
}
@@ -904,6 +1285,7 @@ mod tests {
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
@@ -1333,4 +1715,233 @@ mod tests {
};
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();
}
}
+5 -1
View File
@@ -101,7 +101,11 @@ fn run_tui() -> Result<()> {
tx.send(req).context("background worker stopped")?;
}
}
Event::Paste(text) => app.handle_paste(&text),
Event::Paste(text) => {
for req in app.handle_paste(&text) {
tx.send(req).context("background worker stopped")?;
}
}
_ => {}
}
}
+292 -15
View File
@@ -1,4 +1,7 @@
//! Micropub client: config, create, list (`q=source`), update, delete and undelete.
//! Micropub client: config, create, list (`q=source`), update, delete, undelete
//! and media uploads.
use std::path::Path;
use reqwest::{StatusCode, blocking::Client as Http};
use serde::Deserialize;
@@ -21,6 +24,94 @@ pub struct ServerConfig {
/// Post types the server says it supports. Empty if it doesn't say.
#[serde(rename = "post-types", default)]
pub post_types: Vec<PostType>,
/// Where to upload files. None if the server has no media endpoint.
#[serde(rename = "media-endpoint", default)]
pub media_endpoint: Option<String>,
}
/// Which Micropub property a media file goes in.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MediaKind {
#[default]
Photo,
Video,
Audio,
}
impl MediaKind {
pub const ALL: [MediaKind; 3] = [MediaKind::Photo, MediaKind::Video, MediaKind::Audio];
pub fn property(self) -> &'static str {
match self {
MediaKind::Photo => "photo",
MediaKind::Video => "video",
MediaKind::Audio => "audio",
}
}
/// From a file name or URL's extension. Unknown types count as photos.
pub fn guess(name: &str) -> MediaKind {
let path = name.split(['?', '#']).next().unwrap_or(name);
match mime_guess::from_path(path).first() {
Some(m) if m.type_() == mime_guess::mime::VIDEO => MediaKind::Video,
Some(m) if m.type_() == mime_guess::mime::AUDIO => MediaKind::Audio,
_ => MediaKind::Photo,
}
}
}
/// A file attached to a post: its URL (on the media endpoint or elsewhere) and,
/// for photos, alt text.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Media {
pub kind: MediaKind,
pub url: String,
pub alt: String,
}
impl Media {
/// A plain URL, or `{value, alt}` when there is alt text.
fn to_json(&self) -> Value {
if self.alt.is_empty() {
json!(self.url)
} else {
json!({"value": self.url, "alt": self.alt})
}
}
fn from_mf2(kind: MediaKind, v: &Value) -> Option<Media> {
match v {
Value::String(url) => Some(Media {
kind,
url: url.clone(),
alt: String::new(),
}),
Value::Object(o) => Some(Media {
kind,
url: o
.get("value")
.or_else(|| o.get("url"))
.and_then(Value::as_str)?
.to_string(),
alt: o
.get("alt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}),
_ => None,
}
}
}
/// The media of one kind, as a property value. None if there is none.
fn media_json(media: &[Media], kind: MediaKind) -> Option<Value> {
let v: Vec<Value> = media
.iter()
.filter(|m| m.kind == kind)
.map(Media::to_json)
.collect();
(!v.is_empty()).then_some(Value::Array(v))
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
@@ -66,6 +157,13 @@ impl ServerConfig {
self.q.is_empty() || self.q.iter().any(|q| q == "source")
}
/// Whether the server says it takes this kind of media (`photo`, `video` and
/// `audio` post types). Servers that don't list post types get the benefit of
/// the doubt. Per the spec, servers ignore properties they don't support.
pub fn supports_media(&self, kind: MediaKind) -> bool {
self.post_types.is_empty() || self.post_types.iter().any(|t| t.kind == kind.property())
}
/// Whether the server takes this kind of post. Notes always work; servers that
/// don't list post types get the benefit of the doubt.
pub fn supports(&self, kind: Kind) -> bool {
@@ -85,6 +183,8 @@ pub struct Note {
pub in_reply_to: Option<String>,
pub like_of: Option<String>,
pub repost_of: Option<String>,
/// Photos, videos and audio, in order.
pub media: Vec<Media>,
/// Save unpublished (`post-status: draft`).
pub draft: bool,
}
@@ -94,8 +194,10 @@ impl Note {
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
pub fn to_json(&self) -> Value {
let mut props = serde_json::Map::new();
// Likes and reposts may have no comment.
if !self.content.is_empty() || (self.like_of.is_none() && self.repost_of.is_none()) {
// Likes, reposts and posts with media may have no text.
if !self.content.is_empty()
|| (self.like_of.is_none() && self.repost_of.is_none() && self.media.is_empty())
{
props.insert("content".into(), json!([self.content]));
}
if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) {
@@ -113,6 +215,11 @@ impl Note {
if let Some(u) = &self.repost_of {
props.insert("repost-of".into(), json!([u]));
}
for kind in MediaKind::ALL {
if let Some(v) = media_json(&self.media, kind) {
props.insert(kind.property().into(), v);
}
}
if self.draft {
props.insert("post-status".into(), json!(["draft"]));
}
@@ -137,6 +244,7 @@ pub struct Post {
pub in_reply_to: Option<String>,
pub like_of: Option<String>,
pub repost_of: Option<String>,
pub media: Vec<Media>,
/// Links to crossposted copies.
pub syndication: Vec<String>,
}
@@ -195,6 +303,16 @@ impl Post {
in_reply_to: first_str(p, "in-reply-to"),
like_of: first_str(p, "like-of"),
repost_of: first_str(p, "repost-of"),
media: MediaKind::ALL
.iter()
.flat_map(|&kind| {
p.get(kind.property())
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(move |v| Media::from_mf2(kind, v))
})
.collect(),
syndication: all_strs(p, "syndication"),
})
}
@@ -248,6 +366,16 @@ impl Update {
None => u.delete.push("in-reply-to".into()),
}
}
for kind in MediaKind::ALL {
let (old, new) = (media_json(&orig.media, kind), media_json(&new.media, kind));
match (old, new) {
(old, Some(v)) if old.as_ref() != Some(&v) => {
u.replace.insert(kind.property().into(), v);
}
(Some(_), None) => u.delete.push(kind.property().into()),
_ => {}
}
}
if new.draft != orig.draft {
let status = if new.draft { "draft" } else { "published" };
u.replace.insert("post-status".into(), json!([status]));
@@ -297,6 +425,8 @@ pub enum Error {
NoLocation(StatusCode),
#[error("network error: {0}")]
Http(#[from] reqwest::Error),
#[error("{0}")]
File(String),
}
#[derive(Deserialize)]
@@ -306,6 +436,7 @@ struct ErrorBody {
error_description: String,
}
#[derive(Clone)]
pub struct Client {
http: Http,
endpoint: Url,
@@ -420,6 +551,40 @@ impl Client {
self.action(&json!({"action": "undelete", "url": url}))
}
/// Upload a file to the media endpoint (resolved against the Micropub endpoint).
/// Returns the file's URL.
pub fn upload(&self, media_endpoint: &str, path: &Path) -> Result<String, Error> {
let endpoint = self
.endpoint
.join(media_endpoint)
.map_err(|e| Error::File(format!("bad media endpoint {media_endpoint}: {e}")))?;
let form = reqwest::blocking::multipart::Form::new()
.file("file", path)
.map_err(|e| Error::File(format!("can't read {}: {e}", path.display())))?;
let resp = self
.http
.post(endpoint.clone())
.bearer_auth(&self.token)
.header("Accept", "application/json")
.multipart(form)
.send()?;
Self::located(&endpoint, Self::check(resp)?)
}
/// The URL in a 201's Location header, made absolute.
fn located(base: &Url, resp: reqwest::blocking::Response) -> Result<String, Error> {
let status = resp.status();
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.ok_or(Error::NoLocation(status))?;
Ok(base
.join(location)
.map(|u| u.to_string())
.unwrap_or_else(|_| location.to_string()))
}
/// Publish a note. Returns the post's permalink.
pub fn create(&self, note: &Note) -> Result<String, Error> {
let resp = self
@@ -429,18 +594,7 @@ impl Client {
.header("Accept", "application/json")
.json(&note.to_json())
.send()?;
let resp = Self::check(resp)?;
let status = resp.status();
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.ok_or(Error::NoLocation(status))?;
Ok(self
.endpoint
.join(location)
.map(|u| u.to_string())
.unwrap_or_else(|_| location.to_string()))
Self::located(&self.endpoint, Self::check(resp)?)
}
}
@@ -819,5 +973,128 @@ mod tests {
assert!(cfg.supports(Kind::Note) && cfg.supports(Kind::Like));
assert!(!cfg.supports(Kind::Repost));
assert!(ServerConfig::default().supports(Kind::Repost));
assert!(!cfg.supports_media(MediaKind::Photo));
assert!(ServerConfig::default().supports_media(MediaKind::Video));
}
fn media(kind: MediaKind, url: &str, alt: &str) -> Media {
Media {
kind,
url: url.into(),
alt: alt.into(),
}
}
#[test]
fn media_json_and_parsing() {
let note = Note {
media: vec![
media(MediaKind::Photo, "https://e/a.jpg", "A cat"),
media(MediaKind::Video, "https://e/b.mp4", ""),
media(MediaKind::Photo, "https://e/c.jpg", ""),
],
..Default::default()
};
assert_eq!(
note.to_json()["properties"],
json!({
"photo": [{"value": "https://e/a.jpg", "alt": "A cat"}, "https://e/c.jpg"],
"video": ["https://e/b.mp4"]
}),
"a note with media needs no content"
);
let p = Post::from_mf2(&json!({"properties": {
"url": ["u"],
"photo": [{"value": "https://e/a.jpg", "alt": "A cat"}, "https://e/c.jpg"],
"audio": ["https://e/d.ogg"]
}}))
.unwrap();
assert_eq!(
p.media,
vec![
media(MediaKind::Photo, "https://e/a.jpg", "A cat"),
media(MediaKind::Photo, "https://e/c.jpg", ""),
media(MediaKind::Audio, "https://e/d.ogg", ""),
]
);
assert_eq!(MediaKind::guess("x.JPG"), MediaKind::Photo);
assert_eq!(MediaKind::guess("https://e/x.webm?t=1"), MediaKind::Video);
assert_eq!(MediaKind::guess("x.mp3"), MediaKind::Audio);
assert_eq!(MediaKind::guess("https://e/image"), MediaKind::Photo);
}
#[test]
fn update_diff_media() {
let mut orig = post();
orig.media = vec![
media(MediaKind::Photo, "https://e/a.jpg", ""),
media(MediaKind::Audio, "https://e/d.ogg", ""),
];
let mut n = same_note(&orig);
n.media = orig.media.clone();
assert!(Update::diff(&orig, &n).is_empty());
// New alt text replaces photos; dropping the audio deletes it; video is added.
n.media = vec![
media(MediaKind::Photo, "https://e/a.jpg", "Alt"),
media(MediaKind::Video, "https://e/v.mp4", ""),
];
let u = Update::diff(&orig, &n);
assert_eq!(
u.replace["photo"],
json!([{"value": "https://e/a.jpg", "alt": "Alt"}])
);
assert_eq!(u.replace["video"], json!(["https://e/v.mp4"]));
assert_eq!(u.delete, vec!["audio"]);
}
#[test]
fn upload_sends_multipart_file_and_resolves_location() {
let dir = std::env::temp_dir().join(format!("tuipub-upload-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("cat.jpg");
std::fs::write(&file, b"JPEGDATA").unwrap();
let server = MockServer::start();
let m = server.mock(|when, then| {
when.method(POST)
.path("/media")
.header("authorization", "Bearer tok")
.header_includes("content-type", "multipart/form-data")
.body_includes("name=\"file\"; filename=\"cat.jpg\"")
.body_includes("Content-Type: image/jpeg")
.body_includes("JPEGDATA");
then.status(201).header("Location", "/media/2026/cat.jpg");
});
// A relative media endpoint resolves against the Micropub endpoint.
let url = client(&server).upload("/media", &file).unwrap();
m.assert();
assert_eq!(url, server.url("/media/2026/cat.jpg"));
let err = client(&server)
.upload("/media", &dir.join("missing.jpg"))
.unwrap_err();
assert!(matches!(err, Error::File(_)), "{err}");
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/media");
then.status(400).json_body(
json!({"error": "invalid_request", "error_description": "Unsupported type"}),
);
});
let err = client(&server).upload("/media", &file).unwrap_err();
assert_eq!(err.to_string(), "invalid_request: Unsupported type");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn config_reads_media_endpoint() {
let cfg: ServerConfig =
serde_json::from_value(json!({"media-endpoint": "https://e/media"})).unwrap();
assert_eq!(cfg.media_endpoint.as_deref(), Some("https://e/media"));
assert!(ServerConfig::default().media_endpoint.is_none());
}
}
+146 -3
View File
@@ -8,8 +8,8 @@ use ratatui::{
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets};
use crate::micropub::{Kind, Post, SyndicationTarget};
use crate::app::{App, Discard, Focus, Mode, Posts, Prompt, Screen, Status, Targets};
use crate::micropub::{Kind, Media, MediaKind, Post, SyndicationTarget};
const ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
@@ -87,10 +87,12 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
}
_ => 3,
};
let [title, reply, content, tags, targets] = Layout::vertical([
let media_height = app.media.len().clamp(1, 4) as u16 + 2;
let [title, reply, content, media, tags, targets] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(media_height),
Constraint::Length(3),
Constraint::Length(targets_height),
])
@@ -122,6 +124,7 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
app.content
.set_block(field_block(&label, app.focus == Focus::Content));
f.render_widget(&app.content, content);
draw_media(f, app, media);
app.tags
.set_block(field_block("Tags", app.focus == Focus::Tags));
f.render_widget(&app.tags, tags);
@@ -129,6 +132,12 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
draw_targets(f, app, targets);
match (app.focus, editing) {
(Focus::Media, _) if app.media.is_empty() => {
" a / Enter attach a file or URL (or paste/drop a path) · Tab next · Ctrl-S publish · Esc quit"
}
(Focus::Media, _) => {
" a attach · Enter alt text · d remove · J/K reorder · ↑↓ move · Tab next · Ctrl-S publish"
}
(Focus::Syndication, _) => {
" ←→/↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit"
}
@@ -141,6 +150,88 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
}
}
fn media_icon(kind: MediaKind) -> &'static str {
match kind {
MediaKind::Photo => "📷",
MediaKind::Video => "🎬",
MediaKind::Audio => "🔊",
}
}
/// "2 photos, 1 video"
fn media_summary(media: &[Media]) -> String {
MediaKind::ALL
.iter()
.filter_map(|&kind| {
let n = media.iter().filter(|m| m.kind == kind).count();
let name = match kind {
MediaKind::Photo => "photo",
MediaKind::Video => "video",
MediaKind::Audio => "audio file",
};
(n > 0).then(|| format!("{n} {name}{}", if n == 1 { "" } else { "s" }))
})
.collect::<Vec<_>>()
.join(", ")
}
fn draw_media(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Media;
let title = match app.media.len() {
0 => "Media".to_string(),
n => format!("Media · {n}"),
};
let block = field_block(&title, focused);
if app.media.is_empty() {
f.render_widget(
Paragraph::new(if focused {
"None · press a to attach a photo, video or audio file"
} else {
"None · Tab here, then a to attach a photo, video or audio file"
})
.fg(DIM)
.block(block),
area,
);
return;
}
let items: Vec<ListItem> = app
.media
.iter()
.map(|m| {
let mut spans = vec![Span::raw(format!("{} ", media_icon(m.kind)))];
match &m.url {
None => spans.push(Span::styled(
format!("{} · uploading…", m.name),
Style::default().fg(Color::Yellow),
)),
Some(_) => spans.push(Span::raw(m.name.clone())),
}
if !m.alt.is_empty() {
spans.push(Span::styled(
format!(" alt: {}", m.alt),
Style::default().fg(DIM),
));
} else if m.kind == MediaKind::Photo && m.url.is_some() {
spans.push(Span::styled(" no alt text", Style::default().fg(DIM)));
}
if !app.media_kinds.contains(&m.kind) {
spans.push(Span::styled(
format!(" · server doesn't list {} posts", m.kind.property()),
Style::default().fg(Color::Yellow),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
}
let mut state = ListState::default().with_selected(Some(app.media_cursor));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Syndication;
let block = field_block("Crosspost to", focused);
@@ -269,6 +360,15 @@ fn post_item(p: &Post) -> ListItem<'static> {
} else {
Span::raw(head)
});
if let Some(m) = p.media.first() {
spans.push(Span::raw(format!(" {}", media_icon(m.kind))));
if p.media.len() > 1 {
spans.push(Span::styled(
p.media.len().to_string(),
Style::default().fg(DIM),
));
}
}
if !p.tags.is_empty() {
let tags: Vec<String> = p.tags.iter().map(|t| format!("#{t}")).collect();
spans.push(Span::styled(
@@ -349,6 +449,17 @@ fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str {
));
}
lines.extend(p.content.lines().map(|l| Line::raw(l.to_string())));
for m in &p.media {
let alt = if m.alt.is_empty() {
String::new()
} else {
format!(" · {}", m.alt)
};
lines.push(Line::styled(
format!("{} {}{alt}", media_icon(m.kind), m.url),
Style::default().fg(DIM),
));
}
lines.push(Line::raw(""));
lines.push(hint(&p.url));
for s in &p.syndication {
@@ -376,6 +487,7 @@ fn draw_popup(f: &mut Frame, app: &App) {
let editing = app.editing.is_some();
match &app.mode {
Mode::Normal => {}
Mode::Prompt(p) => draw_prompt(f, app, *p),
Mode::ConfirmPublish => {
let verb = match (editing, app.kind) {
(true, _) => "Save changes to ",
@@ -391,6 +503,13 @@ fn draw_popup(f: &mut Frame, app: &App) {
Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
]));
let media = app.note(false).media;
if !media.is_empty() {
lines.push(Line::from(vec![
Span::raw("Media: "),
Span::styled(media_summary(&media), Style::default().fg(ACCENT)),
]));
}
if app.kind == Kind::Like && !editing {
lines.push(hint("Likes of ordinary web pages aren't crossposted."));
}
@@ -471,6 +590,30 @@ fn draw_popup(f: &mut Frame, app: &App) {
}
}
/// A one-line text box in a popup.
fn draw_prompt(f: &mut Frame, app: &App, prompt: Prompt) {
let (title, keys) = match prompt {
Prompt::AddMedia => (
"Attach a file",
"Tab complete · Enter attach · Esc cancel · a URL is attached as is",
),
Prompt::Alt(_) => ("Alt text", "Enter save · Esc cancel"),
};
let area = centered(f.area(), f.area().width.saturating_sub(8).clamp(40, 100), 4);
f.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT))
.title(format!(" {title} "))
.title_style(Style::default().bold());
let inner = block.inner(area);
f.render_widget(block, area);
let [input, help] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
f.render_widget(&app.prompt, input);
f.render_widget(hint(keys), help);
}
fn popup(f: &mut Frame, title: &str, lines: Vec<Line<'static>>) {
let width = lines
.iter()
+25 -1
View File
@@ -1,5 +1,6 @@
//! Runs Micropub requests on a background thread so the UI never blocks.
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
@@ -11,10 +12,18 @@ pub const PAGE_SIZE: usize = 20;
pub enum Request {
LoadConfig,
Publish(Note),
List { offset: usize },
List {
offset: usize,
},
Update(Update),
Delete(String),
Undelete(String),
/// Upload a file. `id` matches the reply to the photo it's for.
Upload {
id: u64,
endpoint: String,
path: PathBuf,
},
}
pub enum Response {
@@ -37,6 +46,11 @@ pub enum Response {
url: String,
result: Result<(), Error>,
},
/// URL of the uploaded file.
Uploaded {
id: u64,
result: Result<String, Error>,
},
}
pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
@@ -63,6 +77,16 @@ pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
result: client.undelete(&url),
url,
},
// Uploads can be slow, so each gets its own thread and the
// rest of the queue keeps moving.
Request::Upload { id, endpoint, path } => {
let (client, tx) = (client.clone(), resp_tx.clone());
thread::spawn(move || {
let result = client.upload(&endpoint, &path);
tx.send(Response::Uploaded { id, result }).ok();
});
continue;
}
};
if resp_tx.send(resp).is_err() {
break;