From: Simon Tatham Date: Fri, 12 Jan 2024 08:04:13 +0000 (+0000) Subject: Initial user-options menus. X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=436e640689d36de413e8301da1535dc68a5a8ab0;p=mastodonochrome.git Initial user-options menus. 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. --- diff --git a/TODO.md b/TODO.md index 404f03c..a1b45e4 100644 --- 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 diff --git a/src/activity_stack.rs b/src/activity_stack.rs index ad9b3c9..3ebdd12 100644 --- a/src/activity_stack.rs +++ b/src/activity_stack.rs @@ -30,6 +30,7 @@ pub enum UtilityActivity { ComposeReply(String), PostReplyMenu(String), ThreadFile(String, bool), + UserOptions(String), } #[derive(PartialEq, Eq, Debug, Clone)] diff --git a/src/client.rs b/src/client.rs index e16d92e..a488827 100644 --- a/src/client.rs +++ b/src/client.rs @@ -56,6 +56,37 @@ pub struct Feed { extend_future: Option>, } +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Followness { + NotFollowing, + Following { + boosts: Boosts, + languages: Vec, + } +} + +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(()) + } + } } diff --git a/src/file.rs b/src/file.rs index 4928b9a..0ca8bb2 100644 --- a/src/file.rs +++ b/src/file.rs @@ -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; @@ -1113,6 +1114,11 @@ impl } 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 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 diff --git a/src/lib.rs b/src/lib.rs index 33a8d90..5d44182 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 index 0000000..bf37ad0 --- /dev/null +++ b/src/options.rs @@ -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, 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); +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, + cl_boosts: CyclingMenuLine, + el_languages: EditableMenuLine, + cl_block: CyclingMenuLine, + cl_mute: CyclingMenuLine, + id: String, + prev_follow: Followness, + prev_block: bool, + prev_mute: bool, +} + +impl OtherUserOptionsMenu { + fn new(client: &mut Client, id: &str) -> Result { + 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, 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, ClientError> +{ + if id == client.our_account_id() { + Ok(Box::new(YourOptionsMenu::new())) + } else { + Ok(Box::new(OtherUserOptionsMenu::new(client, id)?)) + } +} diff --git a/src/tui.rs b/src/tui.rs index e1de892..b6997be 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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")