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:
@@ -26,6 +26,7 @@ Running `tuipub` with no account starts the login flow first.
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| 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-P | Your posts |
|
||||
| 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) |
|
||||
| ↑ ↓ / 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.
|
||||
|
||||
### Your posts (Ctrl-P)
|
||||
|
||||
| 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 |
|
||||
| p | Publish a draft |
|
||||
| d | Delete (asks first) |
|
||||
@@ -70,7 +73,7 @@ Content is sent as written (Markdown for StarPunk). Tags are comma-separated, an
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
|
||||
+178
-32
@@ -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,12 +559,27 @@ 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.");
|
||||
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);
|
||||
if update.is_empty() {
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
+126
-1
@@ -18,6 +18,45 @@ pub struct ServerConfig {
|
||||
/// Queries the server says it supports (`q` in q=config). Empty if it doesn't say.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -26,6 +65,14 @@ impl ServerConfig {
|
||||
pub fn can_list(&self) -> bool {
|
||||
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)]
|
||||
@@ -36,6 +83,8 @@ pub struct Note {
|
||||
/// `None` leaves syndication to the server's default. `Some(vec![])` means none.
|
||||
pub syndicate_to: Option<Vec<String>>,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub like_of: Option<String>,
|
||||
pub repost_of: Option<String>,
|
||||
/// Save unpublished (`post-status: draft`).
|
||||
pub draft: bool,
|
||||
}
|
||||
@@ -45,7 +94,10 @@ impl Note {
|
||||
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut props = serde_json::Map::new();
|
||||
// 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()) {
|
||||
props.insert("name".into(), json!([t]));
|
||||
}
|
||||
@@ -55,6 +107,12 @@ impl Note {
|
||||
if let Some(u) = &self.in_reply_to {
|
||||
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 {
|
||||
props.insert("post-status".into(), json!(["draft"]));
|
||||
}
|
||||
@@ -77,6 +135,8 @@ pub struct Post {
|
||||
pub published: String,
|
||||
pub draft: bool,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub like_of: Option<String>,
|
||||
pub repost_of: Option<String>,
|
||||
/// Links to crossposted copies.
|
||||
pub syndication: Vec<String>,
|
||||
}
|
||||
@@ -107,6 +167,21 @@ fn all_strs(props: &Value, key: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn from_mf2(item: &Value) -> Option<Post> {
|
||||
let p = item.get("properties")?;
|
||||
@@ -118,6 +193,8 @@ impl Post {
|
||||
published: first_str(p, "published").unwrap_or_default(),
|
||||
draft: first_str(p, "post-status").as_deref() == Some("draft"),
|
||||
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"),
|
||||
})
|
||||
}
|
||||
@@ -133,7 +210,8 @@ pub struct Update {
|
||||
|
||||
impl Update {
|
||||
/// 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 {
|
||||
let mut u = Update {
|
||||
url: orig.url.clone(),
|
||||
@@ -532,6 +610,7 @@ mod tests {
|
||||
draft: true,
|
||||
in_reply_to: Some("https://other.example/x".into()),
|
||||
syndication: vec![],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +622,7 @@ mod tests {
|
||||
syndicate_to: None,
|
||||
in_reply_to: p.in_reply_to.clone(),
|
||||
draft: p.draft,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,4 +775,49 @@ mod tests {
|
||||
assert!(!cfg.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user