From 8c1c2626d6a8e896fa34b8cb3a3ebf8c0f54a6e8 Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Thu, 4 Jan 2024 08:09:21 +0000 Subject: [PATCH] Implement replying to posts. This involves a new Post constructor that fills in in_reply_to, propagates visibility, and populates the post text with appropriate @mentions; and a new pair of activities (these ones Util rather than non-Util) to pass the reply back and forth between the composer and the post-compose menu. --- src/activity_stack.rs | 2 ++ src/client.rs | 9 +++++++++ src/editor.rs | 29 +++++++++++++++++++++++------ src/file.rs | 15 +++++++++++++++ src/posting.rs | 27 ++++++++++++++++++++++++++- src/tui.rs | 35 +++++++++++++++++++++++++++++------ src/types.rs | 9 +++++++++ 7 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/activity_stack.rs b/src/activity_stack.rs index 428565e..c5de929 100644 --- a/src/activity_stack.rs +++ b/src/activity_stack.rs @@ -23,6 +23,8 @@ pub enum UtilityActivity { InfoStatus(String), ListStatusFavouriters(String), ListStatusBoosters(String), + ComposeReply(String), + PostReplyMenu(String), ThreadFile(String, bool), } diff --git a/src/client.rs b/src/client.rs index d938c08..351a709 100644 --- a/src/client.rs +++ b/src/client.rs @@ -272,6 +272,10 @@ impl Client { self.auth.account_id.clone() } + pub fn our_account_fq(&self) -> String { + self.fq(&self.auth.username) + } + fn api_request_cl(&self, client: &reqwest::blocking::Client, req: Req) -> Result<(String, reqwest::blocking::RequestBuilder), ClientError> { @@ -824,6 +828,11 @@ impl Client { .param("sensitive", true) .param("spoiler_text", text), }; + let req = match &post.m.in_reply_to_id { + None => req, + Some(id) => req + .param("in_reply_to_id", id), + }; let (url, req) = self.api_request(req)?; let rsp = req.send()?; diff --git a/src/editor.rs b/src/editor.rs index d90afb7..75c63fb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -753,12 +753,14 @@ struct Composer { keystate: ComposerKeyState, goal_column: Option, header: FileHeader, + irt: Option, headersep: EditorHeaderSeparator, post_metadata: PostMetadata, } impl Composer { - fn new(conf: InstanceStatusConfig, header: FileHeader, post: Post) -> Self + fn new(conf: InstanceStatusConfig, header: FileHeader, + irt: Option, post: Post) -> Self { let point = post.text.len(); Composer { @@ -777,6 +779,7 @@ impl Composer { keystate: ComposerKeyState::Start, goal_column: None, header, + irt, headersep: EditorHeaderSeparator::new(), post_metadata: post.m, } @@ -1355,7 +1358,11 @@ impl ActivityState for Composer { if self.last_size != Some((w, h)) { self.last_size = Some((w, h)); self.page_len = h.saturating_sub( - self.header.render(w).len() + self.headersep.render(w).len() + self.header.render(w).len() + + match self.irt { + None => 0, + Some(ref irt) => irt.render(w).len(), + } + self.headersep.render(w).len() ); self.post_update(); } @@ -1365,6 +1372,9 @@ impl ActivityState for Composer { { let mut lines = Vec::new(); lines.extend_from_slice(&self.header.render(w)); + if let Some(irt) = &self.irt { + lines.extend_from_slice(&irt.render(w)); + } lines.extend_from_slice(&self.headersep.render(w)); let ytop = 0; // FIXME: vary this to keep cursor in view @@ -1452,11 +1462,18 @@ impl ActivityState for Composer { } } -pub fn compose_toplevel_post(client: &mut Client, post: Post) -> +pub fn compose_post(client: &mut Client, post: Post) -> Result, ClientError> { let inst = client.instance()?; - let header = FileHeader::new(ColouredString::uniform( - "Compose a post", 'H')); - Ok(Box::new(Composer::new(inst.configuration.statuses, header, post))) + let title = match post.m.in_reply_to_id { + None => "Compose a post".to_owned(), + Some(ref id) => format!("Reply to post {id}"), + }; + let header = FileHeader::new(ColouredString::uniform(&title, 'H')); + let irt = match post.m.in_reply_to_id { + None => None, + Some(ref id) => Some(InReplyToLine::from_id(id, client)), + }; + Ok(Box::new(Composer::new(inst.configuration.statuses, header, irt, post))) } diff --git a/src/file.rs b/src/file.rs index 39c8ea6..2b386d5 100644 --- a/src/file.rs +++ b/src/file.rs @@ -197,6 +197,7 @@ enum SelectionPurpose { Favourite, Boost, Thread, + Reply, } #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -691,6 +692,8 @@ impl File { Err(_) => LogicalAction::Beep, } } + SelectionPurpose::Reply => LogicalAction::Goto( + UtilityActivity::ComposeReply(id).into()), SelectionPurpose::Thread => LogicalAction::Goto( UtilityActivity::ThreadFile(id, alt).into()), } @@ -825,6 +828,8 @@ impl fs.add(Space, "Examine", 98), SelectionPurpose::StatusInfo => fs.add(Space, "Info", 98), + SelectionPurpose::Reply => + fs.add(Space, "Reply", 98), SelectionPurpose::Favourite => { if self.select_aux == Some(true) { fs.add(Pr('D'), "Unfave", 98) @@ -949,6 +954,16 @@ impl } } + Pr('s') | Pr('S') => { + if Type::Item::can_highlight(HighlightType::WholeStatus) { + self.start_selection(HighlightType::WholeStatus, + SelectionPurpose::Reply, + client) + } else { + LogicalAction::Nothing + } + } + Pr('t') | Pr('T') => { if Type::Item::can_highlight(HighlightType::Status) { self.start_selection(HighlightType::Status, diff --git a/src/posting.rs b/src/posting.rs index 030dc0a..ca98ac1 100644 --- a/src/posting.rs +++ b/src/posting.rs @@ -1,8 +1,9 @@ use itertools::Itertools; use std::cmp::max; +use std::iter::once; use strum::IntoEnumIterator; -use super::client::Client; +use super::client::{Client, ClientError}; use super::coloured_string::ColouredString; use super::tui::{ ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*, @@ -37,6 +38,30 @@ impl Post { }, } } + + pub fn reply_to(id: &str, client: &mut Client) -> + Result + { + let st = client.status_by_id(id)?.strip_boost(); + + let ourself = client.our_account_fq(); + + let userids = once(client.fq(&st.account.acct)) + .chain(st.mentions.iter().map(|m| client.fq(&m.acct) )) + .filter(|acct| acct != &ourself); + + let text = userids.map(|acct| format!("@{} ", acct)).collect(); + + Ok(Post { + text, + m: PostMetadata { + in_reply_to_id: Some(id.to_owned()), + visibility: st.visibility, // match the existing vis + content_warning: None, + language: "en".to_owned(), // FIXME: better default + }, + }) + } } #[derive(Debug, PartialEq, Eq, Clone, Copy)] diff --git a/src/tui.rs b/src/tui.rs index 50cd14d..943c450 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -458,9 +458,21 @@ fn new_activity_state(activity: Activity, client: &mut Client, Activity::Util(UtilityActivity::InfoStatus(ref id)) => view_single_post(client, id), Activity::NonUtil(NonUtilityActivity::ComposeToplevel) => - compose_toplevel_post(client, post.unwrap_or_else(|| Post::new())), + compose_post(client, post.unwrap_or_else(|| Post::new())), Activity::NonUtil(NonUtilityActivity::PostComposeMenu) => Ok(post_menu(post.expect("how did we get here without a Post?"))), + Activity::Util(UtilityActivity::ComposeReply(ref id)) => { + let post = match post { + Some(post) => Ok(post), + None => Post::reply_to(id, client), + }; + match post { + Ok(post) => compose_post(client, post), + Err(e) => Err(e), + } + } + Activity::Util(UtilityActivity::PostReplyMenu(_)) => + Ok(post_menu(post.expect("how did we get here without a Post?"))), Activity::Util(UtilityActivity::ThreadFile(ref id, full)) => view_thread(client, id, full), _ => todo!(), @@ -587,15 +599,26 @@ impl TuiLogicalState { } LogicalAction::Error(_) => PhysicalAction::Beep, // FIXME: Error Log LogicalAction::PostComposed(post) => { - self.activity_stack.chain_to( - NonUtilityActivity::PostComposeMenu.into()); + let newact = match self.activity_stack.top() { + Activity::NonUtil(NonUtilityActivity::ComposeToplevel) => + NonUtilityActivity::PostComposeMenu.into(), + Activity::Util(UtilityActivity::ComposeReply(id)) => + UtilityActivity::PostReplyMenu(id.clone()).into(), + act => panic!("can't postcompose {act:?}"), + }; + self.activity_stack.chain_to(newact); self.changed_activity(client, Some(post)); PhysicalAction::Nothing } LogicalAction::PostReEdit(post) => { - // FIXME: maybe not ComposeToplevel if we're replying - self.activity_stack.chain_to( - NonUtilityActivity::ComposeToplevel.into()); + let newact = match self.activity_stack.top() { + Activity::NonUtil(NonUtilityActivity::PostComposeMenu) => + NonUtilityActivity::ComposeToplevel.into(), + Activity::Util(UtilityActivity::PostReplyMenu(id)) => + UtilityActivity::ComposeReply(id.clone()).into(), + act => panic!("can't reedit {act:?}"), + }; + self.activity_stack.chain_to(newact); self.changed_activity(client, Some(post)); PhysicalAction::Nothing } diff --git a/src/types.rs b/src/types.rs index 9597aeb..fce4e20 100644 --- a/src/types.rs +++ b/src/types.rs @@ -200,6 +200,15 @@ pub struct Status { // pub filtered: Option>, } +impl Status { + pub fn strip_boost(self) -> Status { + match self.reblog { + Some(b) => *b, + None => self, + } + } +} + #[derive(Deserialize, Debug, PartialEq, Eq, Clone, Copy)] pub enum NotificationType { #[serde(rename = "mention")] Mention, -- 2.30.2