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
+5 -2
View File
@@ -26,6 +26,7 @@ Running `tuipub` with no account starts the login flow first.
| Key | Action | | 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, 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-S | Publish or save. The confirm dialog offers **d** (draft) / **p** (publish) |
| Ctrl-P | Your posts | | Ctrl-P | Your posts |
| Ctrl-N | New note (asks before discarding changes) | | Ctrl-N | New note (asks before discarding changes) |
@@ -34,13 +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) | | Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) |
| ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none | | ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none |
**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. 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.
### Your posts (Ctrl-P) ### Your posts (Ctrl-P)
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| ↑ ↓ / j k | Move (scrolling past the end loads more) | | ↑ ↓ / j k | Move (scrolling past the end loads more). ♥ like · 🔁 repost · ↩ reply |
| Enter / e | Edit | | Enter / e | Edit |
| p | Publish a draft | | p | Publish a draft |
| d | Delete (asks first) | | d | Delete (asks first) |
@@ -70,7 +73,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
## StarPunk notes ## 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. - 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.
- Listing, editing, deleting, drafts and replies need StarPunk 2.4.0+. Ctrl-P shows a message instead of the list when a server says it can't list posts (`q` in `q=config`). - 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 ## Develop
+179 -33
View File
@@ -4,13 +4,13 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode}; 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}; use crate::worker::{PAGE_SIZE, Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
Title, Title,
Reply, Link,
Content, Content,
Tags, Tags,
Syndication, Syndication,
@@ -19,7 +19,7 @@ pub enum Focus {
impl Focus { impl Focus {
const ORDER: [Focus; 5] = [ const ORDER: [Focus; 5] = [
Focus::Title, Focus::Title,
Focus::Reply, Focus::Link,
Focus::Content, Focus::Content,
Focus::Tags, Focus::Tags,
Focus::Syndication, Focus::Syndication,
@@ -81,13 +81,17 @@ pub struct App {
pub me: String, pub me: String,
pub screen: Screen, pub screen: Screen,
pub title: TextArea<'static>, 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 content: TextArea<'static>,
pub tags: TextArea<'static>, pub tags: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>, pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets, pub targets_state: Targets,
pub target_cursor: usize, pub target_cursor: usize,
pub focus: Focus, pub focus: Focus,
pub kind: Kind,
/// Kinds the server takes (from q=config).
pub kinds: Vec<Kind>,
pub mode: Mode, pub mode: Mode,
pub status: Option<Status>, pub status: Option<Status>,
pub last_permalink: Option<String>, pub last_permalink: Option<String>,
@@ -119,8 +123,16 @@ fn title_area() -> TextArea<'static> {
text_area("Optional title") text_area("Optional title")
} }
fn reply_area() -> TextArea<'static> { fn link_area() -> TextArea<'static> {
text_area("Optional: URL of the post you're replying to") 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). /// The note body: soft-wraps at word boundaries (long words such as URLs break mid-word).
@@ -151,13 +163,15 @@ impl App {
me, me,
screen: Screen::Compose, screen: Screen::Compose,
title: title_area(), title: title_area(),
reply: reply_area(), link: link_area(),
content: content_area(), content: content_area(),
tags: tags_area(), tags: tags_area(),
targets: Vec::new(), targets: Vec::new(),
targets_state: Targets::Loading, targets_state: Targets::Loading,
target_cursor: 0, target_cursor: 0,
focus: Focus::Content, focus: Focus::Content,
kind: Kind::Note,
kinds: Kind::ALL.to_vec(),
mode: Mode::Normal, mode: Mode::Normal,
status: None, status: None,
last_permalink: None, last_permalink: None,
@@ -189,8 +203,13 @@ impl App {
fn focus_order(&self) -> Vec<Focus> { fn focus_order(&self) -> Vec<Focus> {
Focus::ORDER Focus::ORDER
.into_iter() .into_iter()
// Crosspost targets are chosen once, when posting. // Crosspost targets are chosen once, when posting, and what a like or
.filter(|f| !(self.editing.is_some() && *f == Focus::Syndication)) // 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() .collect()
} }
@@ -205,7 +224,7 @@ impl App {
let focus = self.focus; let focus = self.focus;
for (field, area) in [ for (field, area) in [
(Focus::Title, &mut self.title), (Focus::Title, &mut self.title),
(Focus::Reply, &mut self.reply), (Focus::Link, &mut self.link),
(Focus::Content, &mut self.content), (Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags), (Focus::Tags, &mut self.tags),
] { ] {
@@ -222,7 +241,7 @@ impl App {
pub fn is_dirty(&self) -> bool { pub fn is_dirty(&self) -> bool {
match &self.editing { match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(), 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() .iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())), .any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
} }
@@ -230,17 +249,44 @@ impl App {
fn clear_form(&mut self) { fn clear_form(&mut self) {
self.title = title_area(); self.title = title_area();
self.reply = reply_area(); self.link = link_area();
self.content = content_area(); self.content = content_area();
self.tags = tags_area(); self.tags = tags_area();
self.editing = None; self.editing = None;
self.kind = Kind::Note;
self.focus = Focus::Content; self.focus = Focus::Content;
self.update_cursors(); 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) { fn load_post(&mut self, post: Post) {
self.title = filled(title_area(), post.title.as_deref().unwrap_or("")); 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.content = filled(content_area(), &post.content);
self.tags = filled(tags_area(), &post.tags.join(", ")); self.tags = filled(tags_area(), &post.tags.join(", "));
self.editing = Some(post); self.editing = Some(post);
@@ -252,7 +298,8 @@ impl App {
pub fn note(&self, draft: bool) -> Note { pub fn note(&self, draft: bool) -> Note {
let title = single_line(&self.title); 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 { Note {
title: (!title.is_empty()).then_some(title), title: (!title.is_empty()).then_some(title),
content: self.content.lines().join("\n").trim_end().to_string(), content: self.content.lines().join("\n").trim_end().to_string(),
@@ -267,7 +314,9 @@ impl App {
), ),
_ => None, _ => 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, draft,
} }
} }
@@ -420,6 +469,7 @@ impl App {
fn set_config(&mut self, cfg: ServerConfig) { fn set_config(&mut self, cfg: ServerConfig) {
self.can_list = cfg.can_list(); 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). // Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets); let previous = std::mem::take(&mut self.targets);
self.targets = cfg self.targets = cfg
@@ -509,11 +559,26 @@ impl App {
fn send(&mut self, draft: bool) -> Option<Request> { fn send(&mut self, draft: bool) -> Option<Request> {
let note = self.note(draft); let note = self.note(draft);
self.mode = Mode::Normal; self.mode = Mode::Normal;
if let Some(r) = &note.in_reply_to let link = note
&& !(r.starts_with("http://") || r.starts_with("https://")) .in_reply_to
{ .as_ref()
self.error("“In reply to” must be an http(s) URL."); .or(note.like_of.as_ref())
return None; .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 { if let Some(orig) = &self.editing {
let update = Update::diff(orig, &note); let update = Update::diff(orig, &note);
@@ -590,8 +655,13 @@ impl App {
self.quit(); self.quit();
return None; return None;
} }
(true, KeyCode::Char('t')) => {
self.cycle_kind();
return None;
}
(true, KeyCode::Char('s')) => { (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."); self.error("Write something first.");
} else { } else {
self.status = None; self.status = None;
@@ -632,7 +702,7 @@ impl App {
Focus::Content => { Focus::Content => {
self.content.input(Input::from(key)); self.content.input(Input::from(key));
} }
Focus::Title | Focus::Reply | Focus::Tags => { Focus::Title | Focus::Link | Focus::Tags => {
let input = Input::from(key); let input = Input::from(key);
// Single-line fields: Enter moves on instead of adding a line. // Single-line fields: Enter moves on instead of adding a line.
if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) { if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) {
@@ -640,7 +710,7 @@ impl App {
} else { } else {
let area = match self.focus { let area = match self.focus {
Focus::Title => &mut self.title, Focus::Title => &mut self.title,
Focus::Reply => &mut self.reply, Focus::Link => &mut self.link,
_ => &mut self.tags, _ => &mut self.tags,
}; };
area.input(input); area.input(input);
@@ -756,8 +826,8 @@ impl App {
Focus::Title => { Focus::Title => {
self.title.insert_str(text.replace('\n', " ")); self.title.insert_str(text.replace('\n', " "));
} }
Focus::Reply => { Focus::Link => {
self.reply.insert_str(text.replace('\n', "")); self.link.insert_str(text.replace('\n', ""));
} }
Focus::Tags => { Focus::Tags => {
self.tags.insert_str(text.replace('\n', ",")); self.tags.insert_str(text.replace('\n', ","));
@@ -795,7 +865,7 @@ mod tests {
); );
app.handle_response(Response::Config(Ok(ServerConfig { app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(), syndicate_to: targets.iter().map(|t| target(t)).collect(),
q: vec![], ..Default::default()
}))); })));
app app
} }
@@ -809,6 +879,7 @@ mod tests {
draft, draft,
in_reply_to: None, in_reply_to: None,
syndication: vec![], syndication: vec![],
..Default::default()
} }
} }
/// App on the post list with these posts loaded. /// App on the post list with these posts loaded.
@@ -864,7 +935,7 @@ mod tests {
fn save_new_note_as_draft_with_reply() { fn save_new_note_as_draft_with_reply() {
let mut app = loaded(&[]); let mut app = loaded(&[]);
app.handle_key(key(KeyCode::BackTab)); // -> reply 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"); typ(&mut app, "https://other.example/post");
app.handle_key(key(KeyCode::Enter)); // -> content app.handle_key(key(KeyCode::Enter)); // -> content
typ(&mut app, "Nice post!"); typ(&mut app, "Nice post!");
@@ -890,7 +961,7 @@ mod tests {
#[test] #[test]
fn reply_must_be_a_url() { fn reply_must_be_a_url() {
let mut app = loaded(&[]); let mut app = loaded(&[]);
app.focus = Focus::Reply; app.focus = Focus::Link;
typ(&mut app, "not a url"); typ(&mut app, "not a url");
app.focus = Focus::Content; app.focus = Focus::Content;
typ(&mut app, "x"); typ(&mut app, "x");
@@ -906,7 +977,7 @@ mod tests {
app.focus = Focus::Title; app.focus = Focus::Title;
typ(&mut app, "My title"); typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter)); 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"]); assert_eq!(app.title.lines(), ["My title"]);
} }
@@ -949,7 +1020,7 @@ mod tests {
app.targets[0].1 = false; app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig { app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")], syndicate_to: vec![target("gts"), target("bsky"), target("new")],
q: vec![], ..Default::default()
}))); })));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect(); let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]); assert_eq!(on, [false, true, true]);
@@ -1152,9 +1223,9 @@ mod tests {
let mut app = with_posts(vec![orig]); let mut app = with_posts(vec![orig]);
app.handle_key(key(KeyCode::Enter)); app.handle_key(key(KeyCode::Enter));
assert_eq!(app.title.lines(), ["Old"]); 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.title = title_area();
app.reply = reply_area(); app.link = link_area();
app.handle_key(ctrl('s')); app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else { let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected update"); panic!("expected update");
@@ -1173,8 +1244,8 @@ mod tests {
fn server_without_listing() { fn server_without_listing() {
let mut app = loaded(&[]); let mut app = loaded(&[]);
app.handle_response(Response::Config(Ok(ServerConfig { app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![],
q: vec!["config".into()], q: vec!["config".into()],
..Default::default()
}))); })));
assert!(app.handle_key(ctrl('p')).is_none()); assert!(app.handle_key(ctrl('p')).is_none());
assert!(matches!(app.status, Some(Status::Error(_)))); assert!(matches!(app.status, Some(Status::Error(_))));
@@ -1186,4 +1257,79 @@ mod tests {
app.handle_key(key(KeyCode::Char('o'))); app.handle_key(key(KeyCode::Char('o')));
assert!(matches!(app.status, Some(Status::Info(ref s)) if s.contains("Drafts"))); 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"]);
}
} }
+127 -2
View File
@@ -18,6 +18,45 @@ pub struct ServerConfig {
/// Queries the server says it supports (`q` in q=config). Empty if it doesn't say. /// Queries the server says it supports (`q` in q=config). Empty if it doesn't say.
#[serde(default)] #[serde(default)]
pub q: Vec<String>, pub q: Vec<String>,
/// Post types the server says it supports. Empty if it doesn't say.
#[serde(rename = "post-types", default)]
pub post_types: Vec<PostType>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct PostType {
#[serde(rename = "type")]
pub kind: String,
}
/// What a post is: a note (optionally a reply), a like or a repost of a URL.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Kind {
#[default]
Note,
Like,
Repost,
}
impl Kind {
pub const ALL: [Kind; 3] = [Kind::Note, Kind::Like, Kind::Repost];
/// The Micropub post-type name in q=config.
pub fn post_type(self) -> &'static str {
match self {
Kind::Note => "note",
Kind::Like => "like",
Kind::Repost => "repost",
}
}
pub fn label(self) -> &'static str {
match self {
Kind::Note => "Note",
Kind::Like => "Like",
Kind::Repost => "Repost",
}
}
} }
impl ServerConfig { impl ServerConfig {
@@ -26,6 +65,14 @@ impl ServerConfig {
pub fn can_list(&self) -> bool { pub fn can_list(&self) -> bool {
self.q.is_empty() || self.q.iter().any(|q| q == "source") self.q.is_empty() || self.q.iter().any(|q| q == "source")
} }
/// 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 {
kind == Kind::Note
|| self.post_types.is_empty()
|| self.post_types.iter().any(|t| t.kind == kind.post_type())
}
} }
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
@@ -36,6 +83,8 @@ pub struct Note {
/// `None` leaves syndication to the server's default. `Some(vec![])` means none. /// `None` leaves syndication to the server's default. `Some(vec![])` means none.
pub syndicate_to: Option<Vec<String>>, pub syndicate_to: Option<Vec<String>>,
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
pub like_of: Option<String>,
pub repost_of: Option<String>,
/// Save unpublished (`post-status: draft`). /// Save unpublished (`post-status: draft`).
pub draft: bool, pub draft: bool,
} }
@@ -45,7 +94,10 @@ impl Note {
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere". /// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
let mut props = serde_json::Map::new(); let mut props = serde_json::Map::new();
props.insert("content".into(), json!([self.content])); // Likes and reposts may have no comment.
if !self.content.is_empty() || (self.like_of.is_none() && self.repost_of.is_none()) {
props.insert("content".into(), json!([self.content]));
}
if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) { if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) {
props.insert("name".into(), json!([t])); props.insert("name".into(), json!([t]));
} }
@@ -55,6 +107,12 @@ impl Note {
if let Some(u) = &self.in_reply_to { if let Some(u) = &self.in_reply_to {
props.insert("in-reply-to".into(), json!([u])); props.insert("in-reply-to".into(), json!([u]));
} }
if let Some(u) = &self.like_of {
props.insert("like-of".into(), json!([u]));
}
if let Some(u) = &self.repost_of {
props.insert("repost-of".into(), json!([u]));
}
if self.draft { if self.draft {
props.insert("post-status".into(), json!(["draft"])); props.insert("post-status".into(), json!(["draft"]));
} }
@@ -77,6 +135,8 @@ pub struct Post {
pub published: String, pub published: String,
pub draft: bool, pub draft: bool,
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
pub like_of: Option<String>,
pub repost_of: Option<String>,
/// Links to crossposted copies. /// Links to crossposted copies.
pub syndication: Vec<String>, pub syndication: Vec<String>,
} }
@@ -107,6 +167,21 @@ fn all_strs(props: &Value, key: &str) -> Vec<String> {
} }
impl Post { impl Post {
pub fn kind(&self) -> Kind {
if self.like_of.is_some() {
Kind::Like
} else if self.repost_of.is_some() {
Kind::Repost
} else {
Kind::Note
}
}
/// The URL a like or repost points at.
pub fn target(&self) -> Option<&str> {
self.like_of.as_deref().or(self.repost_of.as_deref())
}
/// Read an mf2 JSON item. Needs at least a url. /// Read an mf2 JSON item. Needs at least a url.
pub fn from_mf2(item: &Value) -> Option<Post> { pub fn from_mf2(item: &Value) -> Option<Post> {
let p = item.get("properties")?; let p = item.get("properties")?;
@@ -118,6 +193,8 @@ impl Post {
published: first_str(p, "published").unwrap_or_default(), published: first_str(p, "published").unwrap_or_default(),
draft: first_str(p, "post-status").as_deref() == Some("draft"), draft: first_str(p, "post-status").as_deref() == Some("draft"),
in_reply_to: first_str(p, "in-reply-to"), in_reply_to: first_str(p, "in-reply-to"),
like_of: first_str(p, "like-of"),
repost_of: first_str(p, "repost-of"),
syndication: all_strs(p, "syndication"), syndication: all_strs(p, "syndication"),
}) })
} }
@@ -133,7 +210,8 @@ pub struct Update {
impl Update { impl Update {
/// Only what changed between `orig` and the edited `new`, so unchanged posts /// Only what changed between `orig` and the edited `new`, so unchanged posts
/// (and their crossposts) aren't touched. Crosspost targets can't be changed. /// (and their crossposts) aren't touched. Crosspost targets and what a like or
/// repost points at can't be changed, so they're never sent.
pub fn diff(orig: &Post, new: &Note) -> Update { pub fn diff(orig: &Post, new: &Note) -> Update {
let mut u = Update { let mut u = Update {
url: orig.url.clone(), url: orig.url.clone(),
@@ -532,6 +610,7 @@ mod tests {
draft: true, draft: true,
in_reply_to: Some("https://other.example/x".into()), in_reply_to: Some("https://other.example/x".into()),
syndication: vec![], syndication: vec![],
..Default::default()
} }
} }
@@ -543,6 +622,7 @@ mod tests {
syndicate_to: None, syndicate_to: None,
in_reply_to: p.in_reply_to.clone(), in_reply_to: p.in_reply_to.clone(),
draft: p.draft, draft: p.draft,
..Default::default()
} }
} }
@@ -695,4 +775,49 @@ mod tests {
assert!(!cfg.can_list()); assert!(!cfg.can_list());
assert!(ServerConfig::default().can_list()); assert!(ServerConfig::default().can_list());
} }
#[test]
fn like_and_repost_json_and_parsing() {
let like = Note {
like_of: Some("https://social.example/@a/1".into()),
..Default::default()
};
assert_eq!(
like.to_json()["properties"],
json!({"like-of": ["https://social.example/@a/1"]}),
"a like without a comment has no content"
);
let repost = Note {
content: "Worth reading".into(),
repost_of: Some("https://blog.example/post".into()),
..Default::default()
};
assert_eq!(
repost.to_json()["properties"],
json!({"content": ["Worth reading"], "repost-of": ["https://blog.example/post"]})
);
assert_eq!(
Note::default().to_json()["properties"],
json!({"content": [""]})
);
let p = Post::from_mf2(&json!({"properties": {
"url": ["u"], "content": [""], "like-of": ["https://x.example/"]
}}))
.unwrap();
assert_eq!(p.kind(), Kind::Like);
assert_eq!(p.target(), Some("https://x.example/"));
assert_eq!(post().kind(), Kind::Note);
}
#[test]
fn post_types_gate_kinds() {
let cfg: ServerConfig = serde_json::from_value(json!({"post-types": [
{"type": "note", "name": "Note"}, {"type": "like", "name": "Like"}
]}))
.unwrap();
assert!(cfg.supports(Kind::Note) && cfg.supports(Kind::Like));
assert!(!cfg.supports(Kind::Repost));
assert!(ServerConfig::default().supports(Kind::Repost));
}
} }
+58 -19
View File
@@ -9,7 +9,7 @@ use ratatui::{
}; };
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets}; 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 ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray; 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) { let place = match (app.screen, &app.editing) {
(Screen::Posts, _) => "Posts".to_string(), (Screen::Posts, _) => "Posts".to_string(),
(Screen::Compose, Some(p)) => format!("Editing {}", p.url), (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( f.render_widget(
Line::from(vec![ Line::from(vec![
@@ -94,13 +94,25 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
app.title app.title
.set_block(field_block("Title", app.focus == Focus::Title)); .set_block(field_block("Title", app.focus == Focus::Title));
f.render_widget(&app.title, title); f.render_widget(&app.title, title);
app.reply let link_label = match (app.kind, app.link_locked()) {
.set_block(field_block("In reply to", app.focus == Focus::Reply)); (Kind::Note, _) => "In reply to",
f.render_widget(&app.reply, reply); (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 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 { let label = match &app.editing {
Some(p) if p.draft => format!("Note · draft · {chars} chars"), Some(p) if p.draft => format!("{name} · draft · {chars} chars"),
_ => format!("Note · {chars} chars"), _ => format!("{name} · {chars} chars"),
}; };
app.content app.content
.set_block(field_block(&label, app.focus == Focus::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" " Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts"
} }
(_, false) => { (_, 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 { fn headline(p: &Post) -> String {
p.title.clone().unwrap_or_else(|| { p.title.clone().unwrap_or_else(|| {
p.content p.content
.lines() .lines()
.find(|l| !l.trim().is_empty()) .find(|l| !l.trim().is_empty())
.unwrap_or("(empty)") .map(|l| l.trim().to_string())
.trim() .or_else(|| p.target().map(str::to_string))
.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(), Style::default().fg(Color::Yellow).bold(),
)); ));
} }
if p.in_reply_to.is_some() { match p.kind() {
spans.push(Span::styled("↩ ", Style::default().fg(ACCENT))); 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); let head = headline(p);
spans.push(if p.title.is_some() { 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 { if let Some(t) = &p.title {
lines.push(Line::styled(t.clone(), Style::default().bold())); 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 { if let Some(r) = &p.in_reply_to {
lines.push(Line::styled( lines.push(Line::styled(
format!("In reply to {r}"), format!("In reply to {r}"),
@@ -314,18 +343,23 @@ fn draw_popup(f: &mut Frame, app: &App) {
match &app.mode { match &app.mode {
Mode::Normal => {} Mode::Normal => {}
Mode::ConfirmPublish => { 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![ let mut lines = vec![Line::from(vec![
Span::raw(if editing { Span::raw(verb),
"Save changes to "
} else {
"Post to "
}),
Span::styled(app.me.clone(), Style::default().bold()), Span::styled(app.me.clone(), Style::default().bold()),
])]; ])];
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw("Crosspost: "), Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)), 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("")); lines.push(Line::raw(""));
let keys = match (editing, app.default_draft()) { let keys = match (editing, app.default_draft()) {
(false, _) => "y / Enter publish · d save as draft · Esc cancel", (false, _) => "y / Enter publish · d save as draft · Esc cancel",
@@ -452,5 +486,10 @@ mod tests {
assert_eq!(headline(&p), "First line"); assert_eq!(headline(&p), "First line");
p.title = Some("Title".into()); p.title = Some("Title".into());
assert_eq!(headline(&p), "Title"); assert_eq!(headline(&p), "Title");
let like = Post {
like_of: Some("https://x.example/".into()),
..Default::default()
};
assert_eq!(headline(&like), "https://x.example/");
} }
} }