chiark / gitweb /
Implement replying to posts.
authorSimon Tatham <anakin@pobox.com>
Thu, 4 Jan 2024 08:09:21 +0000 (08:09 +0000)
committerSimon Tatham <anakin@pobox.com>
Thu, 4 Jan 2024 08:16:16 +0000 (08:16 +0000)
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
src/client.rs
src/editor.rs
src/file.rs
src/posting.rs
src/tui.rs
src/types.rs

index 428565ebb824aec8daada3df027dcf2da1210042..c5de9290aff4841388b81fc28f5531c25aed4788 100644 (file)
@@ -23,6 +23,8 @@ pub enum UtilityActivity {
     InfoStatus(String),
     ListStatusFavouriters(String),
     ListStatusBoosters(String),
+    ComposeReply(String),
+    PostReplyMenu(String),
     ThreadFile(String, bool),
 }
 
index d938c0827c15b2db2929703983e0129bed8b7d8b..351a70910cd1cdc7b187d4affafa23cbbf658d94 100644 (file)
@@ -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()?;
index d90afb7f8b0fa8cc0dc006572a0197935d5e1005..75c63fba3dfd1d5a5d157be6ec2640ecc553d2f5 100644 (file)
@@ -753,12 +753,14 @@ struct Composer {
     keystate: ComposerKeyState,
     goal_column: Option<usize>,
     header: FileHeader,
+    irt: Option<InReplyToLine>,
     headersep: EditorHeaderSeparator,
     post_metadata: PostMetadata,
 }
 
 impl Composer {
-    fn new(conf: InstanceStatusConfig, header: FileHeader, post: Post) -> Self
+    fn new(conf: InstanceStatusConfig, header: FileHeader,
+           irt: Option<InReplyToLine>, 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<Box<dyn ActivityState>, 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)))
 }
index 39c8ea63a80d05d6b8364ef42ab999505ad84c00..2b386d58f76438bd3ce1cc9a6a7abbe30b830196 100644 (file)
@@ -197,6 +197,7 @@ enum SelectionPurpose {
     Favourite,
     Boost,
     Thread,
+    Reply,
 }
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -691,6 +692,8 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
                         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<Type: FileType, Source: FileDataSource>
                         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<Type: FileType, Source: FileDataSource>
                     }
                 }
 
+                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,
index 030dc0a7c81a080059ec3d18253f5789a4cd0c2e..ca98ac11083b0e31310e99676d653d92d221d960 100644 (file)
@@ -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<Self, ClientError>
+    {
+        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)]
index 50cd14deee20d9a0c332fbd69e2b8b7d6697a582..943c450f4b81b4f4f28d88f961da501de1e16ea4 100644 (file)
@@ -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
             }
index 9597aeb4ecd2e5513e3bbb109a86d07ec6243fbe..fce4e209add2776c27a8bf87b03ed1cc1fccc12c 100644 (file)
@@ -200,6 +200,15 @@ pub struct Status {
     // pub filtered: Option<Vec<FilterResult>>,
 }
 
+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,