Post list, edit, delete/undo, drafts and replies

Uses StarPunk 2.4.0's Micropub update, delete/undelete, post-status and
q=source listing. Login asks for update and delete scopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-10-31 20:19:09 -06:00
co-authored by Claude Opus 5.5
parent 7fb72f4d99
commit 42b331256a
7 changed files with 1542 additions and 210 deletions
+325 -88
View File
@@ -8,9 +8,11 @@ use ratatui::{
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::app::{App, Focus, Mode, Status, Targets};
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets};
use crate::micropub::Post;
const ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
fn field_block(title: &str, focused: bool) -> Block<'static> {
let block = Block::bordered().title(format!(" {title} "));
@@ -20,26 +22,28 @@ fn field_block(title: &str, focused: bool) -> Block<'static> {
.border_style(Style::default().fg(ACCENT))
.title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
} else {
block.border_style(Style::default().fg(Color::DarkGray))
block.border_style(Style::default().fg(DIM))
}
}
fn hint(s: &str) -> Line<'static> {
Line::styled(s.to_string(), Style::default().fg(DIM))
}
pub fn draw(f: &mut Frame, app: &mut App) {
let targets_height = match app.targets_state {
Targets::Loaded if !app.targets.is_empty() => (app.targets.len() as u16 + 2).min(8),
_ => 3,
};
let [header, title, content, tags, targets, status, help] = Layout::vertical([
let [header, body, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(targets_height),
Constraint::Min(8),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(f.area());
let place = match (app.screen, &app.editing) {
(Screen::Posts, _) => "Posts".to_string(),
(Screen::Compose, Some(p)) => format!("Editing {}", p.url),
(Screen::Compose, None) => "New note".to_string(),
};
f.render_widget(
Line::from(vec![
Span::styled(
@@ -48,28 +52,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
),
Span::raw(" "),
Span::styled(app.me.clone(), Style::default().bold()),
Span::styled(
format!(" via {}", app.endpoint),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!(" · {place}"), Style::default().fg(DIM)),
]),
header,
);
app.title
.set_block(field_block("Title", app.focus == Focus::Title));
f.render_widget(&app.title, title);
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
app.content.set_block(field_block(
&format!("Note · {chars} chars"),
app.focus == Focus::Content,
));
f.render_widget(&app.content, content);
app.tags
.set_block(field_block("Tags", app.focus == Focus::Tags));
f.render_widget(&app.tags, tags);
draw_targets(f, app, targets);
let help_text = match app.screen {
Screen::Compose => draw_compose(f, app, body),
Screen::Posts => draw_posts(f, app, body),
};
let status_line = match &app.status {
Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)),
@@ -77,75 +68,76 @@ pub fn draw(f: &mut Frame, app: &mut App) {
None => Line::raw(""),
};
f.render_widget(status_line, status);
f.render_widget(hint(help_text), help);
let help_text = match app.focus {
Focus::Syndication => {
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit"
draw_popup(f, app);
}
fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
let editing = app.editing.is_some();
let targets_height = match (&app.targets_state, editing) {
(_, true) => 3,
(Targets::Loaded, false) if !app.targets.is_empty() => {
(app.targets.len() as u16 + 2).min(8)
}
_ => " Tab next field · Ctrl-S publish · Ctrl-R refresh · Ctrl-O open last post · Esc quit",
_ => 3,
};
f.render_widget(
Line::styled(help_text, Style::default().fg(Color::DarkGray)),
help,
);
let [title, reply, content, tags, targets] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(targets_height),
])
.areas(area);
match &app.mode {
Mode::Editing => {}
Mode::ConfirmPublish => popup(
f,
"Publish?",
vec![
Line::from(vec![
Span::raw("Post to "),
Span::styled(app.me.clone(), Style::default().bold()),
]),
Line::from(vec![
Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
]),
Line::raw(""),
Line::styled(
"y / Enter publish · any other key cancel",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]),
Mode::Published(url) => popup(
f,
"Published",
vec![
Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()),
Line::raw(""),
Line::styled(
"o open in browser · any other key new note",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::ConfirmQuit => popup(
f,
"Quit?",
vec![
Line::raw("This note hasn't been published and will be lost."),
Line::raw(""),
Line::styled(
"y quit · any other key keep editing",
Style::default().fg(Color::DarkGray),
),
],
),
app.title
.set_block(field_block("Title", app.focus == Focus::Title));
f.render_widget(&app.title, title);
app.reply
.set_block(field_block("In reply to", app.focus == Focus::Reply));
f.render_widget(&app.reply, reply);
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
let label = match &app.editing {
Some(p) if p.draft => format!("Note · draft · {chars} chars"),
_ => format!("Note · {chars} chars"),
};
app.content
.set_block(field_block(&label, app.focus == Focus::Content));
f.render_widget(&app.content, content);
app.tags
.set_block(field_block("Tags", app.focus == Focus::Tags));
f.render_widget(&app.tags, tags);
draw_targets(f, app, targets);
match (app.focus, editing) {
(Focus::Syndication, _) => {
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit"
}
(_, true) => {
" Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts"
}
(_, false) => {
" Tab next field · Ctrl-S publish · Ctrl-P posts · Ctrl-R refresh · Ctrl-O open last · Esc quit"
}
}
}
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Syndication;
let block = field_block("Crosspost to", focused);
if app.editing.is_some() {
let text = format!(
"{} · targets can't be changed after posting",
app.syndication_summary()
);
f.render_widget(Paragraph::new(text).fg(DIM).block(block), area);
return;
}
match &app.targets_state {
Targets::Loading => f.render_widget(
Paragraph::new("Loading targets…")
.fg(Color::DarkGray)
.block(block),
Paragraph::new("Loading targets…").fg(DIM).block(block),
area,
),
Targets::Failed(_) => f.render_widget(
@@ -156,7 +148,7 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
),
Targets::Loaded if app.targets.is_empty() => f.render_widget(
Paragraph::new("This server has no crosspost targets")
.fg(Color::DarkGray)
.fg(DIM)
.block(block),
area,
),
@@ -169,7 +161,7 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
ListItem::new(Line::from(vec![
Span::styled(
mark,
Style::default().fg(if *on { Color::Green } else { Color::DarkGray }),
Style::default().fg(if *on { Color::Green } else { DIM }),
),
Span::raw(t.name.clone()),
]))
@@ -185,6 +177,232 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
}
}
/// "2026-11-01T01:02:09.6" -> "2026-11-01 01:02"
fn short_date(iso: &str) -> String {
let date = iso.get(..10).unwrap_or(iso);
match iso.get(11..16) {
Some(time) => format!("{date} {time}"),
None => date.to_string(),
}
}
/// The post's title, or the start of its first line.
fn headline(p: &Post) -> String {
p.title.clone().unwrap_or_else(|| {
p.content
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("(empty)")
.trim()
.to_string()
})
}
fn post_item(p: &Post) -> ListItem<'static> {
let mut spans = vec![Span::styled(
format!("{} ", short_date(&p.published)),
Style::default().fg(DIM),
)];
if p.draft {
spans.push(Span::styled(
"draft ",
Style::default().fg(Color::Yellow).bold(),
));
}
if p.in_reply_to.is_some() {
spans.push(Span::styled("↩ ", Style::default().fg(ACCENT)));
}
let head = headline(p);
spans.push(if p.title.is_some() {
Span::styled(head, Style::default().bold())
} else {
Span::raw(head)
});
if !p.tags.is_empty() {
let tags: Vec<String> = p.tags.iter().map(|t| format!("#{t}")).collect();
spans.push(Span::styled(
format!(" {}", tags.join(" ")),
Style::default().fg(DIM),
));
}
if !p.syndication.is_empty() {
spans.push(Span::styled(
format!(" ↗{}", p.syndication.len()),
Style::default().fg(Color::Green),
));
}
ListItem::new(Line::from(spans))
}
fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str {
let [list_area, preview_area] =
Layout::vertical([Constraint::Min(5), Constraint::Percentage(40)]).areas(area);
let count = match (&app.posts_state, app.posts_more) {
(Posts::Loading, _) => "loading…".to_string(),
(_, true) => format!("{}+", app.posts.len()),
_ => app.posts.len().to_string(),
};
let block = field_block(&format!("Posts · {count}"), true);
match &app.posts_state {
Posts::Failed(e) if app.posts.is_empty() => f.render_widget(
Paragraph::new(format!("Couldn't load posts: {e} (r to retry)"))
.fg(Color::Red)
.wrap(Wrap { trim: true })
.block(block),
list_area,
),
_ if app.posts.is_empty() => {
let msg = if app.posts_state == Posts::Loading {
"Loading…"
} else {
"No posts yet."
};
f.render_widget(Paragraph::new(msg).fg(DIM).block(block), list_area)
}
_ => {
let mut items: Vec<ListItem> = app.posts.iter().map(post_item).collect();
if app.posts_more {
items.push(ListItem::new(hint(" ↓ more")));
}
let list = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default().with_selected(Some(app.posts_cursor));
f.render_stateful_widget(list, list_area, &mut state);
}
}
let preview = match app.selected_post() {
Some(p) => {
let mut lines = Vec::new();
if let Some(t) = &p.title {
lines.push(Line::styled(t.clone(), Style::default().bold()));
}
if let Some(r) = &p.in_reply_to {
lines.push(Line::styled(
format!("In reply to {r}"),
Style::default().fg(ACCENT),
));
}
lines.extend(p.content.lines().map(|l| Line::raw(l.to_string())));
lines.push(Line::raw(""));
lines.push(hint(&p.url));
for s in &p.syndication {
lines.push(Line::styled(
format!("↗ {s}"),
Style::default().fg(Color::Green),
));
}
Paragraph::new(lines).wrap(Wrap { trim: false })
}
None => Paragraph::new(""),
};
f.render_widget(preview.block(field_block("Preview", false)), preview_area);
if app.scope_hint().is_some() {
" ↑↓ move · Enter edit · n new · r reload · Esc back · (log in again to edit/delete)"
} else if app.selected_post().is_some_and(|p| p.draft) {
" ↑↓ move · Enter edit · p publish · d delete · u undo · n new · r reload · Esc back"
} else {
" ↑↓ move · Enter edit · d delete · u undo delete · o open · n new · r reload · Esc back"
}
}
fn draw_popup(f: &mut Frame, app: &App) {
let editing = app.editing.is_some();
match &app.mode {
Mode::Normal => {}
Mode::ConfirmPublish => {
let mut lines = vec![Line::from(vec![
Span::raw(if editing {
"Save changes to "
} else {
"Post to "
}),
Span::styled(app.me.clone(), Style::default().bold()),
])];
lines.push(Line::from(vec![
Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
]));
lines.push(Line::raw(""));
let keys = match (editing, app.default_draft()) {
(false, _) => "y / Enter publish · d save as draft · Esc cancel",
(true, true) => "y / Enter save as draft · p publish · Esc cancel",
(true, false) => "y / Enter save · d unpublish to draft · Esc cancel",
};
lines.push(hint(keys));
popup(f, if editing { "Save?" } else { "Publish?" }, lines);
}
Mode::Sending => popup(f, "Working", vec![Line::raw("Talking to your site…")]),
Mode::Published { url, draft } => {
let title = if *draft { "Draft saved" } else { "Published" };
let mut lines = vec![Line::styled(
url.clone(),
Style::default().fg(ACCENT).underlined(),
)];
lines.push(Line::raw(""));
lines.push(hint(if *draft {
"Not public yet; publish it from the post list (Ctrl-P) · any key to continue"
} else {
"o open in browser · any other key new note"
}));
popup(f, title, lines);
}
Mode::ConfirmQuit => popup(
f,
"Quit?",
vec![
Line::raw(if editing {
"Your changes haven't been saved and will be lost."
} else {
"This note hasn't been published and will be lost."
}),
Line::raw(""),
hint("y quit · any other key keep editing"),
],
),
Mode::ConfirmDiscard(action) => {
let what = match action {
Discard::New | Discard::BackToPosts if editing => {
"Discard your changes to this post?"
}
Discard::Edit(_) if editing => "Discard your changes to the post you're editing?",
_ => "Discard the note you're writing?",
};
popup(
f,
"Discard?",
vec![
Line::raw(what),
Line::raw(""),
hint("y discard · any other key keep it"),
],
);
}
Mode::ConfirmDelete(url) => {
let head = app
.posts
.iter()
.find(|p| &p.url == url)
.map(headline)
.unwrap_or_else(|| url.clone());
let head: String = head.chars().take(60).collect();
popup(
f,
"Delete?",
vec![
Line::styled(head, Style::default().bold()),
Line::raw("Its crossposts are removed too. You can undo with u."),
Line::raw(""),
hint("y delete · any other key cancel"),
],
);
}
}
}
fn popup(f: &mut Frame, title: &str, lines: Vec<Line<'static>>) {
let width = lines
.iter()
@@ -217,3 +435,22 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
.areas(row);
cell
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_headlines() {
assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02");
assert_eq!(short_date("2026-11-01"), "2026-11-01");
assert_eq!(short_date(""), "");
let mut p = Post {
content: "\nFirst line\nsecond".into(),
..Default::default()
};
assert_eq!(headline(&p), "First line");
p.title = Some("Title".into());
assert_eq!(headline(&p), "Title");
}
}