Likes and reposts

Ctrl-T switches the post type (note, like, repost) among the types the
server lists in q=config. Sends like-of / repost-of with an optional
comment; editing a like or repost keeps its target locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-10-31 20:26:47 -06:00
co-authored by Claude Opus 5.5
parent 8ec7b15028
commit dfc9b753ac
4 changed files with 369 additions and 56 deletions
+58 -19
View File
@@ -9,7 +9,7 @@ use ratatui::{
};
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets};
use crate::micropub::Post;
use crate::micropub::{Kind, Post};
const ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
@@ -42,7 +42,7 @@ pub fn draw(f: &mut Frame, app: &mut App) {
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(),
(Screen::Compose, None) => format!("New {}", app.kind.label().to_lowercase()),
};
f.render_widget(
Line::from(vec![
@@ -94,13 +94,25 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
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 link_label = match (app.kind, app.link_locked()) {
(Kind::Note, _) => "In reply to",
(Kind::Like, false) => "Like of",
(Kind::Repost, false) => "Repost of",
(Kind::Like, true) => "Like of · can't be changed",
(Kind::Repost, true) => "Repost of · can't be changed",
};
app.link
.set_block(field_block(link_label, app.focus == Focus::Link));
f.render_widget(&app.link, reply);
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
let name = if app.kind == Kind::Note {
"Note"
} else {
"Comment (optional)"
};
let label = match &app.editing {
Some(p) if p.draft => format!("Note · draft · {chars} chars"),
_ => format!("Note · {chars} chars"),
Some(p) if p.draft => format!("{name} · draft · {chars} chars"),
_ => format!("{name} · {chars} chars"),
};
app.content
.set_block(field_block(&label, app.focus == Focus::Content));
@@ -119,7 +131,7 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
" 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"
" Tab next · Ctrl-T note/like/repost · Ctrl-S publish · Ctrl-P posts · Ctrl-O open last · Esc quit"
}
}
}
@@ -186,15 +198,15 @@ fn short_date(iso: &str) -> String {
}
}
/// The post's title, or the start of its first line.
/// The post's title, or the start of its first line (for a bare like or repost, its URL).
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()
.map(|l| l.trim().to_string())
.or_else(|| p.target().map(str::to_string))
.unwrap_or_else(|| "(empty)".to_string())
})
}
@@ -209,8 +221,13 @@ fn post_item(p: &Post) -> ListItem<'static> {
Style::default().fg(Color::Yellow).bold(),
));
}
if p.in_reply_to.is_some() {
spans.push(Span::styled("↩ ", Style::default().fg(ACCENT)));
match p.kind() {
Kind::Like => spans.push(Span::styled("♥ ", Style::default().fg(Color::Magenta))),
Kind::Repost => spans.push(Span::styled("🔁 ", Style::default().fg(Color::Green))),
Kind::Note if p.in_reply_to.is_some() => {
spans.push(Span::styled("↩ ", Style::default().fg(ACCENT)))
}
Kind::Note => {}
}
let head = headline(p);
spans.push(if p.title.is_some() {
@@ -279,6 +296,18 @@ fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str {
if let Some(t) = &p.title {
lines.push(Line::styled(t.clone(), Style::default().bold()));
}
if let Some(u) = &p.like_of {
lines.push(Line::styled(
format!("♥ Like of {u}"),
Style::default().fg(Color::Magenta),
));
}
if let Some(u) = &p.repost_of {
lines.push(Line::styled(
format!("🔁 Repost of {u}"),
Style::default().fg(Color::Green),
));
}
if let Some(r) = &p.in_reply_to {
lines.push(Line::styled(
format!("In reply to {r}"),
@@ -314,18 +343,23 @@ fn draw_popup(f: &mut Frame, app: &App) {
match &app.mode {
Mode::Normal => {}
Mode::ConfirmPublish => {
let verb = match (editing, app.kind) {
(true, _) => "Save changes to ",
(false, Kind::Note) => "Post to ",
(false, Kind::Like) => "Like, on ",
(false, Kind::Repost) => "Repost, on ",
};
let mut lines = vec![Line::from(vec![
Span::raw(if editing {
"Save changes to "
} else {
"Post to "
}),
Span::raw(verb),
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)),
]));
if app.kind == Kind::Like && !editing {
lines.push(hint("Likes of ordinary web pages aren't crossposted."));
}
lines.push(Line::raw(""));
let keys = match (editing, app.default_draft()) {
(false, _) => "y / Enter publish · d save as draft · Esc cancel",
@@ -452,5 +486,10 @@ mod tests {
assert_eq!(headline(&p), "First line");
p.title = Some("Title".into());
assert_eq!(headline(&p), "Title");
let like = Post {
like_of: Some("https://x.example/".into()),
..Default::default()
};
assert_eq!(headline(&like), "https://x.example/");
}
}