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 |
| --- | --- |
| 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
+179 -33
View File
@@ -4,13 +4,13 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui_textarea::{CursorMove, Input, Key, TextArea, WrapMode};
use crate::micropub::{self, Error, Note, Post, ServerConfig, SyndicationTarget, Update};
use crate::micropub::{self, Error, Kind, Note, Post, ServerConfig, SyndicationTarget, Update};
use crate::worker::{PAGE_SIZE, Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Title,
Reply,
Link,
Content,
Tags,
Syndication,
@@ -19,7 +19,7 @@ pub enum Focus {
impl Focus {
const ORDER: [Focus; 5] = [
Focus::Title,
Focus::Reply,
Focus::Link,
Focus::Content,
Focus::Tags,
Focus::Syndication,
@@ -81,13 +81,17 @@ pub struct App {
pub me: String,
pub screen: Screen,
pub title: TextArea<'static>,
pub reply: TextArea<'static>,
/// In reply to / like of / repost of, depending on `kind`.
pub link: TextArea<'static>,
pub content: TextArea<'static>,
pub tags: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets,
pub target_cursor: usize,
pub focus: Focus,
pub kind: Kind,
/// Kinds the server takes (from q=config).
pub kinds: Vec<Kind>,
pub mode: Mode,
pub status: Option<Status>,
pub last_permalink: Option<String>,
@@ -119,8 +123,16 @@ fn title_area() -> TextArea<'static> {
text_area("Optional title")
}
fn reply_area() -> TextArea<'static> {
text_area("Optional: URL of the post you're replying to")
fn link_area() -> TextArea<'static> {
link_area_for(Kind::Note)
}
fn link_area_for(kind: Kind) -> TextArea<'static> {
text_area(match kind {
Kind::Note => "Optional: URL of the post you're replying to",
Kind::Like => "URL of the post you like",
Kind::Repost => "URL of the post you're reposting",
})
}
/// The note body: soft-wraps at word boundaries (long words such as URLs break mid-word).
@@ -151,13 +163,15 @@ impl App {
me,
screen: Screen::Compose,
title: title_area(),
reply: reply_area(),
link: link_area(),
content: content_area(),
tags: tags_area(),
targets: Vec::new(),
targets_state: Targets::Loading,
target_cursor: 0,
focus: Focus::Content,
kind: Kind::Note,
kinds: Kind::ALL.to_vec(),
mode: Mode::Normal,
status: None,
last_permalink: None,
@@ -189,8 +203,13 @@ impl App {
fn focus_order(&self) -> Vec<Focus> {
Focus::ORDER
.into_iter()
// Crosspost targets are chosen once, when posting.
.filter(|f| !(self.editing.is_some() && *f == Focus::Syndication))
// Crosspost targets are chosen once, when posting, and what a like or
// repost points at can't be changed either.
.filter(|f| match (*f, &self.editing) {
(Focus::Syndication, Some(_)) => false,
(Focus::Link, Some(p)) => p.kind() == Kind::Note,
_ => true,
})
.collect()
}
@@ -205,7 +224,7 @@ impl App {
let focus = self.focus;
for (field, area) in [
(Focus::Title, &mut self.title),
(Focus::Reply, &mut self.reply),
(Focus::Link, &mut self.link),
(Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags),
] {
@@ -222,7 +241,7 @@ impl App {
pub fn is_dirty(&self) -> bool {
match &self.editing {
Some(orig) => !Update::diff(orig, &self.note(orig.draft)).is_empty(),
None => [&self.title, &self.reply, &self.content, &self.tags]
None => [&self.title, &self.link, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty())),
}
@@ -230,17 +249,44 @@ impl App {
fn clear_form(&mut self) {
self.title = title_area();
self.reply = reply_area();
self.link = link_area();
self.content = content_area();
self.tags = tags_area();
self.editing = None;
self.kind = Kind::Note;
self.focus = Focus::Content;
self.update_cursors();
}
/// The link field is locked when editing a like or repost.
pub fn link_locked(&self) -> bool {
self.editing
.as_ref()
.is_some_and(|p| p.kind() != Kind::Note)
}
/// Ctrl-T: next post type the server supports. Keeps whatever is typed.
fn cycle_kind(&mut self) {
if self.editing.is_some() {
self.info("An existing post's type can't be changed.");
return;
}
let i = self.kinds.iter().position(|k| *k == self.kind).unwrap_or(0);
self.kind = self.kinds[(i + 1) % self.kinds.len()];
let url = single_line(&self.link);
self.link = filled(link_area_for(self.kind), &url);
self.link.move_cursor(CursorMove::End);
if self.kind != Kind::Note {
self.focus = Focus::Link;
}
self.update_cursors();
}
fn load_post(&mut self, post: Post) {
self.title = filled(title_area(), post.title.as_deref().unwrap_or(""));
self.reply = filled(reply_area(), post.in_reply_to.as_deref().unwrap_or(""));
self.kind = post.kind();
let link = post.target().or(post.in_reply_to.as_deref()).unwrap_or("");
self.link = filled(link_area_for(self.kind), link);
self.content = filled(content_area(), &post.content);
self.tags = filled(tags_area(), &post.tags.join(", "));
self.editing = Some(post);
@@ -252,7 +298,8 @@ impl App {
pub fn note(&self, draft: bool) -> Note {
let title = single_line(&self.title);
let reply = single_line(&self.reply);
let link = single_line(&self.link);
let link = (!link.is_empty()).then_some(link);
Note {
title: (!title.is_empty()).then_some(title),
content: self.content.lines().join("\n").trim_end().to_string(),
@@ -267,7 +314,9 @@ impl App {
),
_ => None,
},
in_reply_to: (!reply.is_empty()).then_some(reply),
in_reply_to: link.clone().filter(|_| self.kind == Kind::Note),
like_of: link.clone().filter(|_| self.kind == Kind::Like),
repost_of: link.filter(|_| self.kind == Kind::Repost),
draft,
}
}
@@ -420,6 +469,7 @@ impl App {
fn set_config(&mut self, cfg: ServerConfig) {
self.can_list = cfg.can_list();
self.kinds = Kind::ALL.into_iter().filter(|k| cfg.supports(*k)).collect();
// Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets);
self.targets = cfg
@@ -509,11 +559,26 @@ impl App {
fn send(&mut self, draft: bool) -> Option<Request> {
let note = self.note(draft);
self.mode = Mode::Normal;
if let Some(r) = &note.in_reply_to
&& !(r.starts_with("http://") || r.starts_with("https://"))
{
self.error("“In reply to” must be an http(s) URL.");
return None;
let link = note
.in_reply_to
.as_ref()
.or(note.like_of.as_ref())
.or(note.repost_of.as_ref());
match (self.kind, link) {
(Kind::Like | Kind::Repost, None) if !self.link_locked() => {
let what = if self.kind == Kind::Like {
"you like"
} else {
"you're reposting"
};
self.error(format!("Add the URL of the post {what}."));
return None;
}
(_, Some(u)) if !(u.starts_with("http://") || u.starts_with("https://")) => {
self.error("The link must be an http(s) URL.");
return None;
}
_ => {}
}
if let Some(orig) = &self.editing {
let update = Update::diff(orig, &note);
@@ -590,8 +655,13 @@ impl App {
self.quit();
return None;
}
(true, KeyCode::Char('t')) => {
self.cycle_kind();
return None;
}
(true, KeyCode::Char('s')) => {
if self.note(false).content.trim().is_empty() {
// Likes and reposts don't need a comment.
if self.kind == Kind::Note && self.note(false).content.trim().is_empty() {
self.error("Write something first.");
} else {
self.status = None;
@@ -632,7 +702,7 @@ impl App {
Focus::Content => {
self.content.input(Input::from(key));
}
Focus::Title | Focus::Reply | Focus::Tags => {
Focus::Title | Focus::Link | Focus::Tags => {
let input = Input::from(key);
// Single-line fields: Enter moves on instead of adding a line.
if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) {
@@ -640,7 +710,7 @@ impl App {
} else {
let area = match self.focus {
Focus::Title => &mut self.title,
Focus::Reply => &mut self.reply,
Focus::Link => &mut self.link,
_ => &mut self.tags,
};
area.input(input);
@@ -756,8 +826,8 @@ impl App {
Focus::Title => {
self.title.insert_str(text.replace('\n', " "));
}
Focus::Reply => {
self.reply.insert_str(text.replace('\n', ""));
Focus::Link => {
self.link.insert_str(text.replace('\n', ""));
}
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
@@ -795,7 +865,7 @@ mod tests {
);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(),
q: vec![],
..Default::default()
})));
app
}
@@ -809,6 +879,7 @@ mod tests {
draft,
in_reply_to: None,
syndication: vec![],
..Default::default()
}
}
/// App on the post list with these posts loaded.
@@ -864,7 +935,7 @@ mod tests {
fn save_new_note_as_draft_with_reply() {
let mut app = loaded(&[]);
app.handle_key(key(KeyCode::BackTab)); // -> reply
assert_eq!(app.focus, Focus::Reply);
assert_eq!(app.focus, Focus::Link);
typ(&mut app, "https://other.example/post");
app.handle_key(key(KeyCode::Enter)); // -> content
typ(&mut app, "Nice post!");
@@ -890,7 +961,7 @@ mod tests {
#[test]
fn reply_must_be_a_url() {
let mut app = loaded(&[]);
app.focus = Focus::Reply;
app.focus = Focus::Link;
typ(&mut app, "not a url");
app.focus = Focus::Content;
typ(&mut app, "x");
@@ -906,7 +977,7 @@ mod tests {
app.focus = Focus::Title;
typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.focus, Focus::Reply);
assert_eq!(app.focus, Focus::Link);
assert_eq!(app.title.lines(), ["My title"]);
}
@@ -949,7 +1020,7 @@ mod tests {
app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")],
q: vec![],
..Default::default()
})));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]);
@@ -1152,9 +1223,9 @@ mod tests {
let mut app = with_posts(vec![orig]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.title.lines(), ["Old"]);
assert_eq!(app.reply.lines(), ["https://r.example/"]);
assert_eq!(app.link.lines(), ["https://r.example/"]);
app.title = title_area();
app.reply = reply_area();
app.link = link_area();
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected update");
@@ -1173,8 +1244,8 @@ mod tests {
fn server_without_listing() {
let mut app = loaded(&[]);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![],
q: vec!["config".into()],
..Default::default()
})));
assert!(app.handle_key(ctrl('p')).is_none());
assert!(matches!(app.status, Some(Status::Error(_))));
@@ -1186,4 +1257,79 @@ mod tests {
app.handle_key(key(KeyCode::Char('o')));
assert!(matches!(app.status, Some(Status::Info(ref s)) if s.contains("Drafts")));
}
#[test]
fn like_without_comment() {
let mut app = loaded(&["gts"]);
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Like);
assert_eq!(app.focus, Focus::Link);
// A like needs its URL.
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::ConfirmPublish, "no comment needed");
assert!(app.handle_key(key(KeyCode::Enter)).is_none());
assert!(matches!(app.status, Some(Status::Error(ref e)) if e.contains("URL")));
typ(&mut app, "https://social.example/@a/1");
app.handle_key(ctrl('s'));
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected publish");
};
assert_eq!(note.like_of.as_deref(), Some("https://social.example/@a/1"));
assert_eq!(note.in_reply_to, None);
assert_eq!(note.content, "");
assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()]));
}
#[test]
fn ctrl_t_cycles_supported_kinds_and_keeps_the_url() {
let mut app = loaded(&[]);
app.focus = Focus::Link;
typ(&mut app, "https://x.example/");
app.handle_key(ctrl('t'));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Repost);
assert_eq!(
app.note(false).repost_of.as_deref(),
Some("https://x.example/")
);
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Note);
assert_eq!(
app.note(false).in_reply_to.as_deref(),
Some("https://x.example/")
);
// A server without likes or reposts: Ctrl-T stays on Note.
app.handle_response(Response::Config(Ok(ServerConfig {
post_types: vec![micropub::PostType {
kind: "note".into(),
}],
..Default::default()
})));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Note);
}
#[test]
fn editing_a_like_locks_its_url_and_type() {
let mut like = post("l", false);
like.content = String::new();
like.like_of = Some("https://x.example/".into());
let mut app = with_posts(vec![like]);
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.kind, Kind::Like);
assert!(app.link_locked());
assert!(!app.focus_order().contains(&Focus::Link));
app.handle_key(ctrl('t'));
assert_eq!(app.kind, Kind::Like);
// Adding a comment is an ordinary content update.
typ(&mut app, "So good");
app.handle_key(ctrl('s'));
let Some(Request::Update(u)) = app.handle_key(key(KeyCode::Enter)) else {
panic!("expected update");
};
assert_eq!(u.replace.keys().collect::<Vec<_>>(), ["content"]);
}
}
+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.
#[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();
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<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));
}
}
+58 -19
View File
@@ -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/");
}
}