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
+179 -33
View File
@@ -4,13 +4,13 @@ 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, Note, Post, ServerConfig, SyndicationTarget, Update};
use crate::micropub::{self, Error, Kind, Note, Post, ServerConfig, SyndicationTarget, Update};
use crate::worker::{PAGE_SIZE, Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Title,
Reply,
Link,
Content,
Tags,
Syndication,
@@ -19,7 +19,7 @@ pub enum Focus {
impl Focus {
const ORDER: [Focus; 5] = [
Focus::Title,
Focus::Reply,
Focus::Link,
Focus::Content,
Focus::Tags,
Focus::Syndication,
@@ -81,13 +81,17 @@ pub struct App {
pub me: String,
pub screen: Screen,
pub title: TextArea<'static>,
pub reply: 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 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>,
@@ -119,8 +123,16 @@ fn title_area() -> TextArea<'static> {
text_area("Optional title")
}
fn reply_area() -> TextArea<'static> {
text_area("Optional: URL of the post you're replying to")
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).
@@ -151,13 +163,15 @@ impl App {
me,
screen: Screen::Compose,
title: title_area(),
reply: reply_area(),
link: link_area(),
content: content_area(),
tags: tags_area(),
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,
@@ -189,8 +203,13 @@ impl App {
fn focus_order(&self) -> Vec<Focus> {
Focus::ORDER
.into_iter()
// Crosspost targets are chosen once, when posting.
.filter(|f| !(self.editing.is_some() && *f == Focus::Syndication))
// 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()
}
@@ -205,7 +224,7 @@ impl App {
let focus = self.focus;
for (field, area) in [
(Focus::Title, &mut self.title),
(Focus::Reply, &mut self.reply),
(Focus::Link, &mut self.link),
(Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags),
] {
@@ -222,7 +241,7 @@ impl App {
pub fn is_dirty(&self) -> bool {
match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => [&self.title, &self.reply, &self.content, &self.tags]
None => [&self.title, &self.link, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
}
@@ -230,17 +249,44 @@ impl App {
fn clear_form(&mut self) {
self.title = title_area();
self.reply = reply_area();
self.link = link_area();
self.content = content_area();
self.tags = tags_area();
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.reply = filled(reply_area(), post.in_reply_to.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.editing = Some(post);
@@ -252,7 +298,8 @@ impl App {
pub fn note(&self, draft: bool) -> Note {
let title = single_line(&self.title);
let reply = single_line(&self.reply);
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(),
@@ -267,7 +314,9 @@ impl App {
),
_ => None,
},
in_reply_to: (!reply.is_empty()).then_some(reply),
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),
draft,
}
}
@@ -420,6 +469,7 @@ impl App {
fn set_config(&mut self, cfg: ServerConfig) {
self.can_list = cfg.can_list();
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
@@ -509,11 +559,26 @@ impl App {
fn send(&mut self, draft: bool) -> Option<Request> {
let note = self.note(draft);
self.mode = Mode::Normal;
if let Some(r) = &note.in_reply_to
&& !(r.starts_with("http://") || r.starts_with("https://"))
{
self.error("“In reply to” must be an http(s) URL.");
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);
@@ -590,8 +655,13 @@ impl App {
self.quit();
return None;
}
(true, KeyCode::Char('t')) => {
self.cycle_kind();
return None;
}
(true, KeyCode::Char('s')) => {
if self.note(false).content.trim().is_empty() {
// Likes and reposts don't need a comment.
if self.kind == Kind::Note && self.note(false).content.trim().is_empty() {
self.error("Write something first.");
} else {
self.status = None;
@@ -632,7 +702,7 @@ impl App {
Focus::Content => {
self.content.input(Input::from(key));
}
Focus::Title | Focus::Reply | Focus::Tags => {
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')) {
@@ -640,7 +710,7 @@ impl App {
} else {
let area = match self.focus {
Focus::Title => &mut self.title,
Focus::Reply => &mut self.reply,
Focus::Link => &mut self.link,
_ => &mut self.tags,
};
area.input(input);
@@ -756,8 +826,8 @@ impl App {
Focus::Title => {
self.title.insert_str(text.replace('\n', " "));
}
Focus::Reply => {
self.reply.insert_str(text.replace('\n', ""));
Focus::Link => {
self.link.insert_str(text.replace('\n', ""));
}
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
@@ -795,7 +865,7 @@ mod tests {
);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(),
q: vec![],
..Default::default()
})));
app
}
@@ -809,6 +879,7 @@ mod tests {
draft,
in_reply_to: None,
syndication: vec![],
..Default::default()
}
}
/// App on the post list with these posts loaded.
@@ -864,7 +935,7 @@ mod tests {
fn save_new_note_as_draft_with_reply() {
let mut app = loaded(&[]);
app.handle_key(key(KeyCode::BackTab)); // -> reply
assert_eq!(app.focus, Focus::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!");
@@ -890,7 +961,7 @@ mod tests {
#[test]
fn reply_must_be_a_url() {
let mut app = loaded(&[]);
app.focus = Focus::Reply;
app.focus = Focus::Link;
typ(&mut app, "not a url");
app.focus = Focus::Content;
typ(&mut app, "x");
@@ -906,7 +977,7 @@ mod tests {
app.focus = Focus::Title;
typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.focus, Focus::Reply);
assert_eq!(app.focus, Focus::Link);
assert_eq!(app.title.lines(), ["My title"]);
}
@@ -949,7 +1020,7 @@ mod tests {
app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")],
q: vec![],
..Default::default()
})));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]);
@@ -1152,9 +1223,9 @@ mod tests {
let mut app = with_posts(vec![orig]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.title.lines(), ["Old"]);
assert_eq!(app.reply.lines(), ["https://r.example/"]);
assert_eq!(app.link.lines(), ["https://r.example/"]);
app.title = title_area();
app.reply = reply_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");
@@ -1173,8 +1244,8 @@ mod tests {
fn server_without_listing() {
let mut app = loaded(&[]);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![],
q: vec!["config".into()],
..Default::default()
})));
assert!(app.handle_key(ctrl('p')).is_none());
assert!(matches!(app.status, Some(Status::Error(_))));
@@ -1186,4 +1257,79 @@ mod tests {
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"]);
}
}