chiark / gitweb /
Initial user-options menus.
authorSimon Tatham <anakin@pobox.com>
Fri, 12 Jan 2024 08:04:13 +0000 (08:04 +0000)
committerSimon Tatham <anakin@pobox.com>
Fri, 12 Jan 2024 18:48:23 +0000 (18:48 +0000)
The one for your own options is currently unimplemented (but it had to
_exist_, because both options menus are launched via the same UI
action, and only the target user id distinguishes the two).

But the one for other users supports following and its suboptions,
blocking, and muting.

TODO.md
src/activity_stack.rs
src/client.rs
src/file.rs
src/lib.rs
src/options.rs [new file with mode: 0644]
src/tui.rs

diff --git a/TODO.md b/TODO.md
index 404f03cc590392ec320c32ed47bc9d99463e8918..a1b45e4501ba90a3c821f56d3e6ab0ab33b5ba87 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -185,12 +185,9 @@ ESC L should go to a summary page showing general health info; in
 particular, whether streaming subthread(s) are currently working. Then
 the second L keypress goes to the actual log files.
 
-## Options key from the Examine User screen
+## More options in the Examine → Options menus
 
-When you examine a user, pressing O should take you to a page of
-options.
-
-### Client and account configuration: ESC Y O
+### Your own options: ESC Y O
 
 If the user is you, then this menu sets client configuration. For
 example, the default language for your posts (if you want to override
@@ -202,14 +199,16 @@ options](https://docs.joinmastodon.org/methods/accounts/#update_credentials)
 about your account, such as your display name, flags like `bot`, and
 info fields.
 
-### Following/blocking/etc: ESC E [user] O
+### Options for other users
 
-If the user you're examining _isn't_ you, then you should get a
-different options page which sets your relationship to that user:
-following, blocking, muting (or undoing any of those)
+I think we've got all the sensible server-side options about other
+users (except _maybe_ getting a notification when they post, mentioned
+separately below).
 
-Definitely also the various sub-options of following, such as whether
-to include boosts, and which languages to include.
+But I wonder if it might be worth having client-side options for
+users. For example, perhaps the client could usefully keep a list of
+usernames we know about and occasionally read but don't Properly
+Follow, so as to provide their home feeds in a convenient submenu.
 
 ## Outlying protocol features
 
@@ -218,6 +217,13 @@ to include boosts, and which languages to include.
 You can ask the server to schedule a post to go out later. This could
 be slotted in as another option in the post-composer menu.
 
+### Being notified of users' posts
+
+There's an option in the account relationship API to ask for a user's
+posts to show up in your notifications feed. Perhaps we should support
+setting that flag on a user, and receiving the resulting notifications
+in some useful part of the API?
+
 ### Locked accounts
 
 You can lock your account so that people can only follow you if you
index ad9b3c905bc1555bf1d56ef43bc40b24f6dbe57d..3ebdd12420817e09ba1b450bdf582044c2c08a7d 100644 (file)
@@ -30,6 +30,7 @@ pub enum UtilityActivity {
     ComposeReply(String),
     PostReplyMenu(String),
     ThreadFile(String, bool),
+    UserOptions(String),
 }
 
 #[derive(PartialEq, Eq, Debug, Clone)]
index e16d92e34b31a3e5dafbfb69bcf3fb7f048d2d30..a4888271c43250eaec97428e1267aa44c9b513aa 100644 (file)
@@ -56,6 +56,37 @@ pub struct Feed {
     extend_future: Option<HashMap<String, String>>,
 }
 
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum Followness {
+    NotFollowing,
+    Following {
+        boosts: Boosts,
+        languages: Vec<String>,
+    }
+}
+
+impl Followness {
+    pub fn from_rel(rel: &Relationship) -> Followness {
+        if !rel.following {
+            Followness::NotFollowing
+        } else {
+            let boosts = if rel.showing_reblogs {
+                Boosts::Show
+            } else {
+                Boosts::Hide
+            };
+            let languages = rel.languages.clone().unwrap_or(Vec::new());
+            Followness::Following {
+                boosts,
+                languages,
+            }
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum AccountFlag { Block, Mute }
+
 pub struct Client {
     auth: AuthConfig,
     client: reqwest::blocking::Client,
@@ -1164,4 +1195,50 @@ impl Client {
         self.cache_poll(&poll);
         Ok(())
     }
+
+    pub fn set_following(&mut self, id: &str, follow: Followness)
+                         -> Result<(), ClientError>
+    {
+        let req = match follow {
+            Followness::NotFollowing =>
+                Req::post(&format!("v1/accounts/{id}/unfollow")),
+            Followness::Following { boosts, languages } => {
+                let mut req = Req::post(&format!("v1/accounts/{id}/follow"))
+                    .param("reblogs", boosts == Boosts::Show);
+                for language in languages {
+                    req = req.param("languages[]", &language);
+                }
+                req
+            }
+        };
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            Ok(())
+        }
+    }
+
+    pub fn set_account_flag(&mut self, id: &str, flag: AccountFlag,
+                            enable: bool) -> Result<(), ClientError>
+    {
+        let req = match (flag, enable) {
+            (AccountFlag::Block, true) =>
+                Req::post(&format!("v1/accounts/{id}/block")),
+            (AccountFlag::Block, false) =>
+                Req::post(&format!("v1/accounts/{id}/unblock")),
+            (AccountFlag::Mute, true) =>
+                Req::post(&format!("v1/accounts/{id}/mute")),
+            (AccountFlag::Mute, false) =>
+                Req::post(&format!("v1/accounts/{id}/unmute")),
+        };
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            Ok(())
+        }
+    }
 }
index 4928b9a7b0b6d4d353c371f323c63abfb7755e71..0ca8bb263192dff8dc4364f001b47407d49b2445 100644 (file)
@@ -66,7 +66,7 @@ trait FileDataSource {
     fn extendable(&self) -> bool;
 
     fn single_id(&self) -> String {
-        panic!("Should only call this if the FileType sets CAN_LIST or CAN_GET_POSTS");
+        panic!("Should only call this if the FileType sets CAN_LIST, CAN_GET_POSTS or IS_EXAMINE_USER");
     }
 }
 
@@ -136,6 +136,7 @@ trait FileType {
     type Item: TextFragment + Sized;
     const CAN_LIST: CanList = CanList::Nothing;
     const CAN_GET_POSTS: bool = false;
+    const IS_EXAMINE_USER: bool = false;
 
     fn get_from_client(id: &str, client: &mut Client) ->
         Result<Self::Item, ClientError>;
@@ -1113,6 +1114,11 @@ impl<Type: FileType, Source: FileDataSource>
                 } else {
                     fs
                 };
+                let fs = if Type::IS_EXAMINE_USER {
+                    fs.add(Pr('O'), "Options", 41)
+                } else {
+                    fs
+                };
                 let fs = fs
                     .add(Pr('/'), "Search Down", 20)
                     .add(Pr('\\'), "Search Up", 20)
@@ -1396,6 +1402,15 @@ impl<Type: FileType, Source: FileDataSource>
                     LogicalAction::Nothing
                 }
 
+                Pr('o') | Pr('O') => {
+                    if Type::IS_EXAMINE_USER {
+                        LogicalAction::Goto(UtilityActivity::UserOptions(
+                            self.contents.source.single_id()).into())
+                    } else {
+                        LogicalAction::Nothing
+                    }
+                }
+
                 _ => LogicalAction::Nothing,
             }
             UIMode::ListSubmenu => match key {
@@ -1683,6 +1698,7 @@ impl FileType for ExamineUserFileType {
     type Item = ExamineUserDisplay;
     const CAN_LIST: CanList = CanList::ForUser;
     const CAN_GET_POSTS: bool = true;
+    const IS_EXAMINE_USER: bool = true;
 
     fn get_from_client(id: &str, client: &mut Client) ->
         Result<Self::Item, ClientError>
index 33a8d90449c672d241f2d140e76ffbfc6224004f..5d44182e8e2733bf98315a4393b0884b6dceb7f7 100644 (file)
@@ -13,6 +13,7 @@ pub mod menu;
 pub mod file;
 pub mod editor;
 pub mod posting;
+pub mod options;
 
 #[derive(Debug)]
 pub struct TopLevelError {
diff --git a/src/options.rs b/src/options.rs
new file mode 100644 (file)
index 0000000..bf37ad0
--- /dev/null
@@ -0,0 +1,330 @@
+use super::client::{Client, ClientError, Boosts, Followness, AccountFlag};
+use super::coloured_string::ColouredString;
+use super::tui::{
+    ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*,
+};
+use super::text::*;
+use super::editor::{EditableMenuLine, EditableMenuLineData};
+
+struct YourOptionsMenu {
+    title: FileHeader,
+    coming_soon: CentredInfoLine,
+    normal_status: FileStatusLineFinal,
+    edit_status: FileStatusLineFinal,
+}
+
+impl YourOptionsMenu {
+    fn new() -> Self {
+        let title = FileHeader::new(ColouredString::general(
+            "Your user options [ESC][Y][O]",
+            "HHHHHHHHHHHHHHHHHHHKKKHHKHHKH"));
+
+        let normal_status = FileStatusLine::new()
+            .add(Return, "Back", 10).finalise();
+        let edit_status = FileStatusLine::new()
+            .message("Edit line and press Return").finalise();
+
+        let coming_soon = CentredInfoLine::new(
+            ColouredString::uniform("Not yet implemented", '!'));
+
+        let mut menu = YourOptionsMenu {
+            title,
+            coming_soon,
+            normal_status,
+            edit_status,
+        };
+        menu.fix_widths();
+        menu
+    }
+
+    fn fix_widths(&mut self) -> (usize, usize) {
+        let /* mut */ lmaxwid = 0;
+        let /* mut */ rmaxwid = 0;
+        // self.cl_WHATEVER.check_widths(&mut lmaxwid, &mut rmaxwid);
+
+        // self.cl_WHATEVER.reset_widths();
+
+        // self.cl_WHATEVER.ensure_widths(lmaxwid, rmaxwid);
+
+        (lmaxwid, rmaxwid)
+    }
+}
+
+impl ActivityState for YourOptionsMenu {
+    fn draw(&self, w: usize, h: usize)
+            -> (Vec<ColouredString>, CursorPosition) {
+        let mut lines = Vec::new();
+        let /* mut */ cursorpos = CursorPosition::End;
+        lines.extend_from_slice(&self.title.render(w));
+        lines.extend_from_slice(&BlankLine::render_static());
+        lines.extend_from_slice(&self.coming_soon.render(w));
+        // FIXME menu items
+
+        while lines.len() + 1 < h {
+            lines.extend_from_slice(&BlankLine::render_static());
+        }
+
+        if false /* self.el_WHATEVER.is_editing() */ {
+            lines.extend_from_slice(&self.edit_status.render(w));
+        } else {
+            lines.extend_from_slice(&self.normal_status.render(w));
+        }
+
+        (lines, cursorpos)
+    }
+
+    fn handle_keypress(&mut self, key: OurKey, _client: &mut Client) ->
+        LogicalAction
+    {
+        // Let editable menu lines have first crack at the keypress
+        if false /* self.el_WHATEVER.handle_keypress(key) */
+        {
+            self.fix_widths();
+            return LogicalAction::Nothing;
+        }
+
+        match key {
+            Pr('q') | Pr('Q') => LogicalAction::Pop,
+            _ => LogicalAction::Nothing,
+        }
+    }
+
+    fn resize(&mut self, _w: usize, _h: usize) {
+        /*
+        self.el_WHATEVER.resize(w);
+         */
+    }
+}
+
+struct LanguageVector(Vec<String>);
+impl EditableMenuLineData for LanguageVector {
+    fn display(&self) -> ColouredString {
+        if self.0.is_empty() {
+            ColouredString::uniform("any", '0')
+        } else {
+            // Make the comma separators appear in the 'boring' colour
+            let mut s = ColouredString::plain("");
+            let mut sep = ColouredString::plain("");
+            for lang in &self.0 {
+                s = s + &sep + ColouredString::plain(lang);
+                sep = ColouredString::uniform(",", '0');
+            }
+            s
+        }
+    }
+
+    fn to_text(&self) -> String { self.0.as_slice().join(",") }
+
+    fn from_text(text: &str) -> Self {
+        Self(text.split(|c| c == ' ' || c == ',')
+             .filter(|s| !s.is_empty())
+             .map(|s| s.to_owned()).collect())
+    }
+}
+
+struct OtherUserOptionsMenu {
+    title: FileHeader,
+    normal_status: FileStatusLineFinal,
+    edit_status: FileStatusLineFinal,
+    cl_follow: CyclingMenuLine<bool>,
+    cl_boosts: CyclingMenuLine<Boosts>,
+    el_languages: EditableMenuLine<LanguageVector>,
+    cl_block: CyclingMenuLine<bool>,
+    cl_mute: CyclingMenuLine<bool>,
+    id: String,
+    prev_follow: Followness,
+    prev_block: bool,
+    prev_mute: bool,
+}
+
+impl OtherUserOptionsMenu {
+    fn new(client: &mut Client, id: &str) -> Result<Self, ClientError> {
+        let ac = client.account_by_id(id)?;
+        let name = client.fq(&ac.acct);
+        let rel = client.account_relationship_by_id(id)?;
+
+        let title = FileHeader::new(ColouredString::uniform(
+            &format!("Your options for user {name}"), 'H'));
+
+        let normal_status = FileStatusLine::new()
+            .add(Return, "Back", 10).finalise();
+        let edit_status = FileStatusLine::new()
+            .message("Edit line and press Return").finalise();
+        let cl_follow = CyclingMenuLine::new(
+            Pr('F'), ColouredString::plain("Follow this user: "),
+            &[(false, ColouredString::plain("no")),
+              (true, ColouredString::uniform("yes", 'f'))],
+            rel.following);
+        let boosts = if rel.following {
+            if rel.showing_reblogs { Boosts::Show } else { Boosts::Hide }
+        } else {
+            // Default, if we start off not following the user
+            Boosts::Show
+        };
+        let cl_boosts = CyclingMenuLine::new(
+            Pr('B'), ColouredString::plain("  Include their boosts: "),
+            &[(Boosts::Hide, ColouredString::plain("no")),
+              (Boosts::Show, ColouredString::uniform("yes", 'f'))], boosts);
+        let el_languages = EditableMenuLine::new(
+            Pr('L'), ColouredString::plain("  Include languages: "),
+            LanguageVector(rel.languages.clone()
+                           .unwrap_or_else(|| Vec::new())));
+
+        let cl_block = CyclingMenuLine::new(
+            Ctrl('B'), ColouredString::plain("Block this user: "),
+            &[(false, ColouredString::plain("no")),
+              (true, ColouredString::uniform("yes", 'r'))],
+            rel.blocking);
+        // Can't use the obvious ^M because it's also Return, of course!
+        let cl_mute = CyclingMenuLine::new(
+            Ctrl('U'), ColouredString::plain("Mute this user: "),
+            &[(false, ColouredString::plain("no")),
+              (true, ColouredString::uniform("yes", 'r'))],
+            rel.muting);
+
+        let prev_follow = Followness::from_rel(&rel);
+
+        let mut menu = OtherUserOptionsMenu {
+            title,
+            normal_status,
+            edit_status,
+            cl_follow,
+            cl_boosts,
+            el_languages,
+            cl_block,
+            cl_mute,
+            id: id.to_owned(),
+            prev_follow,
+            prev_block: rel.blocking,
+            prev_mute: rel.muting,
+        };
+        menu.fix_widths();
+        Ok(menu)
+    }
+
+    fn fix_widths(&mut self) -> (usize, usize) {
+        let mut lmaxwid = 0;
+        let mut rmaxwid = 0;
+        self.cl_follow.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.cl_boosts.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_languages.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.cl_block.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.cl_mute.check_widths(&mut lmaxwid, &mut rmaxwid);
+
+        self.cl_follow.reset_widths();
+        self.cl_boosts.reset_widths();
+        self.el_languages.reset_widths();
+        self.cl_block.reset_widths();
+        self.cl_mute.reset_widths();
+
+        self.cl_follow.ensure_widths(lmaxwid, rmaxwid);
+        self.cl_boosts.ensure_widths(lmaxwid, rmaxwid);
+        self.el_languages.ensure_widths(lmaxwid, rmaxwid);
+        self.cl_block.ensure_widths(lmaxwid, rmaxwid);
+        self.cl_mute.ensure_widths(lmaxwid, rmaxwid);
+
+        (lmaxwid, rmaxwid)
+    }
+
+    fn submit(&self, client: &mut Client) -> LogicalAction {
+        let new_follow = if self.cl_follow.get_value() {
+            Followness::Following {
+                boosts: self.cl_boosts.get_value(),
+                languages: self.el_languages.get_data().0.clone(),
+            }
+        } else {
+            Followness::NotFollowing
+        };
+
+        if new_follow != self.prev_follow {
+            if client.set_following(&self.id, new_follow).is_err() {
+                return LogicalAction::Beep; // FIXME: report the error!
+            }
+        }
+
+        let new_block = self.cl_block.get_value();
+        if new_block != self.prev_block {
+            if client.set_account_flag(&self.id, AccountFlag::Block,
+                                       new_block).is_err()
+            {
+                return LogicalAction::Beep; // FIXME: report the error!
+            }
+        }
+
+        let new_mute = self.cl_mute.get_value();
+        if new_mute != self.prev_mute {
+            if client.set_account_flag(&self.id, AccountFlag::Mute,
+                                       new_mute).is_err()
+            {
+                return LogicalAction::Beep; // FIXME: report the error!
+            }
+        }
+
+        LogicalAction::Pop
+    }
+}
+
+impl ActivityState for OtherUserOptionsMenu {
+    fn draw(&self, w: usize, h: usize)
+            -> (Vec<ColouredString>, CursorPosition) {
+        let mut lines = Vec::new();
+        let mut cursorpos = CursorPosition::End;
+        lines.extend_from_slice(&self.title.render(w));
+        lines.extend_from_slice(&BlankLine::render_static());
+        lines.extend_from_slice(&self.cl_follow.render(w));
+        lines.extend_from_slice(&self.cl_boosts.render(w));
+        lines.push(self.el_languages.render(
+            w, &mut cursorpos, lines.len()));
+        lines.extend_from_slice(&BlankLine::render_static());
+        lines.extend_from_slice(&self.cl_block.render(w));
+        lines.extend_from_slice(&self.cl_mute.render(w));
+
+        while lines.len() + 1 < h {
+            lines.extend_from_slice(&BlankLine::render_static());
+        }
+
+        if self.el_languages.is_editing() {
+            lines.extend_from_slice(&self.edit_status.render(w));
+        } else {
+            lines.extend_from_slice(&self.normal_status.render(w));
+        }
+
+        (lines, cursorpos)
+    }
+
+    fn handle_keypress(&mut self, key: OurKey, client: &mut Client) ->
+        LogicalAction
+    {
+        // Let editable menu lines have first crack at the keypress
+        if self.el_languages.handle_keypress(key)
+        {
+            self.fix_widths();
+            return LogicalAction::Nothing;
+        }
+
+        match key {
+            Space => self.submit(client),
+            Pr('q') | Pr('Q') => LogicalAction::Pop,
+            Pr('f') | Pr('F') => self.cl_follow.cycle(), 
+            Pr('b') | Pr('B') => self.cl_boosts.cycle(), 
+            Pr('l') | Pr('L') => self.el_languages.start_editing(), 
+            Ctrl('B') => self.cl_block.cycle(),
+            Ctrl('U') => self.cl_mute.cycle(),
+            _ => LogicalAction::Nothing,
+        }
+    }
+
+    fn resize(&mut self, w: usize, _h: usize) {
+        self.el_languages.resize(w);
+    }
+}
+
+pub fn user_options_menu(client: &mut Client, id: &str)
+                         -> Result<Box<dyn ActivityState>, ClientError>
+{
+    if id == client.our_account_id() {
+        Ok(Box::new(YourOptionsMenu::new()))
+    } else {
+        Ok(Box::new(OtherUserOptionsMenu::new(client, id)?))
+    }
+}
index e1de89247962a62b94db1fb6cc68a5c9894c8009..b6997be17c68aaf3c826849d3c5cfd91276585bb 100644 (file)
@@ -29,6 +29,7 @@ use super::menu::*;
 use super::file::*;
 use super::editor::*;
 use super::posting::*;
+use super::options::*;
 
 fn ratatui_style_from_colour(colour: char) -> Style {
     match colour {
@@ -835,6 +836,8 @@ impl TuiLogicalState {
                 ref user, boosts, replies)) =>
                 user_posts(&self.file_positions, self.unfolded_posts.clone(),
                            client, user, boosts, replies),
+            Activity::Util(UtilityActivity::UserOptions(ref id)) =>
+                user_options_menu(client, id),
         };
 
         result.expect("FIXME: need to implement the Error Log here")