diff --git a/README.md b/README.md index 2b5e80b..a34215b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app.rs b/src/app.rs index cb62e92..e0f05d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, pub mode: Mode, pub status: Option, pub last_permalink: Option, @@ -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::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 { 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 = 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::>(), ["content"]); + } } diff --git a/src/micropub.rs b/src/micropub.rs index 9335854..b16a683 100644 --- a/src/micropub.rs +++ b/src/micropub.rs @@ -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, + /// Post types the server says it supports. Empty if it doesn't say. + #[serde(rename = "post-types", default)] + pub post_types: Vec, +} + +#[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>, pub in_reply_to: Option, + pub like_of: Option, + pub repost_of: Option, /// 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(); - 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()) { 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, + pub like_of: Option, + pub repost_of: Option, /// Links to crossposted copies. pub syndication: Vec, } @@ -107,6 +167,21 @@ fn all_strs(props: &Value, key: &str) -> Vec { } 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 { 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)); + } } diff --git a/src/ui.rs b/src/ui.rs index 744e255..a863ddc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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/"); } }