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:
+179
-33
@@ -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) = ¬e.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, ¬e);
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user