From: Simon Tatham Date: Wed, 10 Jan 2024 08:41:48 +0000 (+0000) Subject: Support unfolding and refolding of sensitive toots. X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=b7013418fa93816bcbdb017f62b276ed003cfdbc;p=mastodonochrome.git Support unfolding and refolding of sensitive toots. There's a new highlight type for this, so that it can apply to InReplyTo lines as well as statuses themselves. The set of unfolded sensitive statuses needs to be shared between lots of views of the data, so I've put it in the TuiLogicalState, under a RefCell so that ActivityStates can all borrow it easily, not just to look up in but also to modify. --- diff --git a/TODO.md b/TODO.md index 92386e6..404f03c 100644 --- a/TODO.md +++ b/TODO.md @@ -40,12 +40,6 @@ way Mono did it. ## Reading -### Sensitive-content markers - -Currently we display sensitive-content tags, but then display the rest -of the post anyway. It would be better to (at least have the option -to) hide the post and then be able to show it on demand. - ### Scrolling to keep the selection in view Various keyboard commands let you select a user or a post from a file diff --git a/src/file.rs b/src/file.rs index 5b0e5e1..4928b9a 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,7 +1,9 @@ use itertools::Itertools; use regex::Regex; +use std::cell::RefCell; use std::cmp::{min, max}; use std::collections::{HashMap, HashSet, hash_map}; +use std::rc::Rc; use super::activity_stack::{ NonUtilityActivity, UtilityActivity, OverlayActivity, @@ -296,6 +298,7 @@ enum SelectionPurpose { StatusInfo, Favourite, Boost, + Unfold, Thread, Reply, Vote, @@ -312,11 +315,22 @@ enum UIMode { #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum SearchDirection { Up, Down } -#[derive(Default)] struct FileDisplayStyles { selected_poll_id: Option, selected_poll_options: HashSet, + unfolded: Option>>>, } + +impl FileDisplayStyles { + fn new(unfolded: Option>>>) -> Self { + FileDisplayStyles { + selected_poll_id: None, + selected_poll_options: HashSet::new(), + unfolded, + } + } +} + impl DisplayStyleGetter for FileDisplayStyles { fn poll_options(&self, id: &str) -> Option> { self.selected_poll_id.as_ref().and_then( @@ -328,7 +342,9 @@ impl DisplayStyleGetter for FileDisplayStyles { ) } - fn unfolded(&self, _id: &str) -> bool { true } + fn unfolded(&self, id: &str) -> bool { + self.unfolded.as_ref().is_some_and(|set| set.borrow().contains(id)) + } } struct File { @@ -350,7 +366,7 @@ struct File { impl File { fn new(client: &mut Client, source: Source, desc: ColouredString, file_desc: Type, saved_pos: Option<&SavedFilePos>, - show_new: bool) -> + unfolded: Option>>>, show_new: bool) -> Result { source.init(client)?; @@ -413,7 +429,7 @@ impl File { selection: None, selection_restrict_to_item: None, select_aux: None, - display_styles: FileDisplayStyles::default(), + display_styles: FileDisplayStyles::new(unfolded), file_desc, search_direction: None, last_search: None, @@ -896,6 +912,25 @@ impl File { Err(_) => LogicalAction::Beep, } } + SelectionPurpose::Unfold => { + let did_something = if let Some(ref rc) = + self.display_styles.unfolded + { + let mut unfolded = rc.borrow_mut(); + if !unfolded.remove(&id) { + unfolded.insert(id); + } + true + } else { + false + }; + + if did_something { + self.rendered.clear(); + self.clip_pos_within_item(); + } + LogicalAction::Nothing + } SelectionPurpose::Reply => LogicalAction::Goto( UtilityActivity::ComposeReply(id).into()), SelectionPurpose::Thread => LogicalAction::Goto( @@ -1056,6 +1091,13 @@ impl } else { fs }; + let fs = if Type::Item::can_highlight(HighlightType::Status) && + self.display_styles.unfolded.is_some() + { + fs.add(Pr('u'), "Unfold", 39) + } else { + fs + }; let fs = if Type::Item::can_highlight( HighlightType::WholeStatus) { @@ -1164,6 +1206,12 @@ impl .add(Ctrl('V'), "Submit Vote", 98) } } + SelectionPurpose::Unfold => { + let unfolded = self.contents.id_at_index(item) + .map_or(false, |id| self.display_styles.unfolded(id)); + let verb = if unfolded { "Fold" } else { "Unfold" }; + fs.add(Pr('U'), verb, 98) + } }; fs.add(Pr('+'), "Down", 99) .add(Pr('-'), "Up", 99) @@ -1257,6 +1305,18 @@ impl } } + Pr('u') | Pr('U') => { + if Type::Item::can_highlight(HighlightType::FoldableStatus) + && self.display_styles.unfolded.is_some() + { + self.start_selection(HighlightType::FoldableStatus, + SelectionPurpose::Unfold, + client) + } else { + LogicalAction::Nothing + } + } + Pr('f') | Pr('F') => { if Type::Item::can_highlight(HighlightType::WholeStatus) { self.start_selection(HighlightType::WholeStatus, @@ -1465,6 +1525,7 @@ impl } pub fn home_timeline(file_positions: &HashMap, + unfolded: Rc>>, client: &mut Client) -> Result, ClientError> { @@ -1474,11 +1535,12 @@ pub fn home_timeline(file_positions: &HashMap, let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Home timeline ", - "HHHHHHHHHHHHHHHHHKH"), desc, pos, false)?; + "HHHHHHHHHHHHHHHHHKH"), desc, pos, Some(unfolded), false)?; Ok(Box::new(file)) } pub fn local_timeline(file_positions: &HashMap, + unfolded: Rc>>, client: &mut Client) -> Result, ClientError> { @@ -1488,11 +1550,12 @@ pub fn local_timeline(file_positions: &HashMap, let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Local public timeline ", - "HHHHHHHHHHHHHHHHHHHHHHHHHKH"), desc, pos, false)?; + "HHHHHHHHHHHHHHHHHHHHHHHHHKH"), desc, pos, Some(unfolded), false)?; Ok(Box::new(file)) } pub fn public_timeline(file_positions: &HashMap, + unfolded: Rc>>, client: &mut Client) -> Result, ClientError> { @@ -1502,11 +1565,12 @@ pub fn public_timeline(file_positions: &HashMap, let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Public timeline

", - "HHHHHHHHHHHHHHHHHHHKH"), desc, pos, false)?; + "HHHHHHHHHHHHHHHHHHHKH"), desc, pos, Some(unfolded), false)?; Ok(Box::new(file)) } pub fn mentions(file_positions: &HashMap, + unfolded: Rc>>, client: &mut Client, is_interrupt: bool) -> Result, ClientError> { @@ -1516,7 +1580,7 @@ pub fn mentions(file_positions: &HashMap, let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Mentions [ESC][R]", - "HHHHHHHHHHHHKKKHHKH"), desc, pos, is_interrupt)?; + "HHHHHHHHHHHHKKKHHKH"), desc, pos, Some(unfolded), is_interrupt)?; Ok(Box::new(file)) } @@ -1530,13 +1594,14 @@ pub fn ego_log(file_positions: &HashMap, let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Ego Log [ESC][L][L][E]", - "HHHHHHHHHHHKKKHHKHHKHHKH"), desc, pos, false)?; + "HHHHHHHHHHHKKKHHKHHKHHKH"), desc, pos, None, false)?; Ok(Box::new(file)) } pub fn user_posts( - file_positions: &HashMap, client: &mut Client, - user: &str, boosts: Boosts, replies: Replies) + file_positions: &HashMap, + unfolded: Rc>>, + client: &mut Client, user: &str, boosts: Boosts, replies: Replies) -> Result, ClientError> { let feed = FeedId::User(user.to_owned(), boosts, replies); @@ -1545,7 +1610,7 @@ pub fn user_posts( let file = File::new( client, FeedSource::new(feed), ColouredString::general( "Public timeline

", - "HHHHHHHHHHHHHHHHHHHKH"), desc, pos, false)?; + "HHHHHHHHHHHHHHHHHHHKH"), desc, pos, Some(unfolded), false)?; Ok(Box::new(file)) } @@ -1556,7 +1621,7 @@ pub fn list_status_favouriters(client: &mut Client, id: &str) -> client, FeedSource::new(FeedId::Favouriters(id.to_owned())), ColouredString::uniform( &format!("Users who favourited post {id}"), 'H'), - UserListFeedType{}, None, false)?; + UserListFeedType{}, None, None, false)?; Ok(Box::new(file)) } @@ -1567,7 +1632,7 @@ pub fn list_status_boosters(client: &mut Client, id: &str) -> client, FeedSource::new(FeedId::Boosters(id.to_owned())), ColouredString::uniform( &format!("Users who boosted post {id}"), 'H'), - UserListFeedType{}, None, false)?; + UserListFeedType{}, None, None, false)?; Ok(Box::new(file)) } @@ -1581,7 +1646,7 @@ pub fn list_user_followers(client: &mut Client, id: &str) -> client, FeedSource::new(FeedId::Followers(id.to_owned())), ColouredString::uniform( &format!("Users who follow {name}"), 'H'), - UserListFeedType{}, None, false)?; + UserListFeedType{}, None, None, false)?; Ok(Box::new(file)) } @@ -1595,11 +1660,12 @@ pub fn list_user_followees(client: &mut Client, id: &str) -> client, FeedSource::new(FeedId::Followees(id.to_owned())), ColouredString::uniform( &format!("Users who {name} follows"), 'H'), - UserListFeedType{}, None, false)?; + UserListFeedType{}, None, None, false)?; Ok(Box::new(file)) } -pub fn hashtag_timeline(client: &mut Client, tag: &str) -> +pub fn hashtag_timeline(unfolded: Rc>>, + client: &mut Client, tag: &str) -> Result, ClientError> { let title = ColouredString::uniform( @@ -1608,7 +1674,7 @@ pub fn hashtag_timeline(client: &mut Client, tag: &str) -> let desc = StatusFeedType::with_feed(feed); let file = File::new( client, FeedSource::new(FeedId::Hashtag(tag.to_owned())), title, - desc, None, false)?; + desc, None, Some(unfolded), false)?; Ok(Box::new(file)) } @@ -1636,7 +1702,7 @@ pub fn examine_user(client: &mut Client, account_id: &str) -> let file = File::new( client, StaticSource::singleton(ac.id), title, ExamineUserFileType{}, - Some(&FilePosition::item_top(isize::MIN).into()), false)?; + Some(&FilePosition::item_top(isize::MIN).into()), None, false)?; Ok(Box::new(file)) } @@ -1653,7 +1719,8 @@ impl FileType for DetailedStatusFileType { } } -pub fn view_single_post(client: &mut Client, status_id: &str) -> +pub fn view_single_post(unfolded: Rc>>, + client: &mut Client, status_id: &str) -> Result, ClientError> { let st = client.status_by_id(status_id)?; @@ -1663,11 +1730,13 @@ pub fn view_single_post(client: &mut Client, status_id: &str) -> let file = File::new( client, StaticSource::singleton(st.id), title, DetailedStatusFileType{}, - Some(&FilePosition::item_top(isize::MIN).into()), false)?; + Some(&FilePosition::item_top(isize::MIN).into()), + Some(unfolded), false)?; Ok(Box::new(file)) } -pub fn view_thread(client: &mut Client, start_id: &str, full: bool) -> +pub fn view_thread(unfolded: Rc>>, + client: &mut Client, start_id: &str, full: bool) -> Result, ClientError> { let mut make_vec = |id: &str| -> Result, ClientError> { @@ -1705,6 +1774,6 @@ pub fn view_thread(client: &mut Client, start_id: &str, full: bool) -> let file = File::new( client, StaticSource::vector(ids), title, StatusFeedType::without_feed(), - Some(&FilePosition::item_top(index).into()), false)?; + Some(&FilePosition::item_top(index).into()), Some(unfolded), false)?; Ok(Box::new(file)) } diff --git a/src/text.rs b/src/text.rs index 845914d..6833f45 100644 --- a/src/text.rs +++ b/src/text.rs @@ -12,7 +12,13 @@ use super::tui::OurKey; use super::coloured_string::*; #[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum HighlightType { User, Status, WholeStatus, PollOption } +pub enum HighlightType { + User, + Status, + WholeStatus, + FoldableStatus, + PollOption, +} #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct Highlight(pub HighlightType, pub usize); @@ -986,10 +992,13 @@ impl InReplyToLine { } impl TextFragment for InReplyToLine { - fn render_highlighted(&self, width: usize, _highlight: Option, + fn render_highlighted(&self, width: usize, highlight: Option, style: &dyn DisplayStyleGetter) -> Vec { + let mut highlight = highlight; + let highlighting = + highlight.consume(HighlightType::FoldableStatus, 1) == Some(0); let which_para = match self.warning.as_ref() { Some(folded) => if !style.unfolded(&self.id) { folded @@ -1008,7 +1017,43 @@ impl TextFragment for InReplyToLine { } else { first_line.clone() }; - vec! { result.truncate(width).into() } + let result = result.truncate(width); + let result = if highlighting { + result.recolour('*') + } else { + result.into() + }; + vec! { result } + } + + fn can_highlight(htype: HighlightType) -> bool where Self : Sized { + htype == HighlightType::FoldableStatus + } + + fn count_highlightables(&self, htype: HighlightType) -> usize { + match htype { + HighlightType::FoldableStatus => { + if self.warning.is_some() { + 1 + } else { + 0 + } + } + _ => 0, + } + } + + fn highlighted_id(&self, highlight: Option) -> Option { + match highlight { + Some(Highlight(HighlightType::FoldableStatus, 0)) => { + if self.warning.is_some() { + Some(self.id.clone()) + } else { + None + } + } + _ => None, + } } } @@ -1017,7 +1062,7 @@ fn test_in_reply_to() { let post = Html::new( "

@stoat @weasel take a look at this otter!

@badger might also like it

"); - let irt = InReplyToLine::new(post.to_para()); + let irt = InReplyToLine::new(post.to_para(), None, "123"); assert_eq!(irt.render(48), vec!{ ColouredString::general( "Re: take a look at this otter! @badger might...", @@ -1931,24 +1976,27 @@ impl TextFragment for StatusDisplay { let mut lines = Vec::new(); let mut highlight = highlight; + push_fragment(&mut lines, self.sep.render(width)); + push_fragment(&mut lines, self.from.render_highlighted_update( + width, &mut highlight, style)); + push_fragment(&mut lines, self.via.render_highlighted_update( + width, &mut highlight, style)); + push_fragment(&mut lines, self.vis.render(width)); + push_fragment(&mut lines, self.irt.render_highlighted_update( + width, &mut highlight, style)); + push_fragment(&mut lines, self.blank.render(width)); + let highlighting_this_status = highlight.consume(HighlightType::Status, 1) == Some(0) || - highlight.consume(HighlightType::WholeStatus, 1) == Some(0); + highlight.consume(HighlightType::WholeStatus, 1) == Some(0) || + (self.warning.is_some() && + highlight.consume(HighlightType::FoldableStatus, 1) == Some(0)); let push_fragment_opt_highlight = if highlighting_this_status { push_fragment_highlighted } else { push_fragment }; - push_fragment(&mut lines, self.sep.render(width)); - push_fragment(&mut lines, self.from.render_highlighted_update( - width, &mut highlight, &DefaultDisplayStyle)); - push_fragment(&mut lines, self.via.render_highlighted_update( - width, &mut highlight, &DefaultDisplayStyle)); - push_fragment(&mut lines, self.vis.render(width)); - push_fragment(&mut lines, self.irt.render(width)); - push_fragment(&mut lines, self.blank.render(width)); - let folded = self.warning.is_some() && !style.unfolded(&self.id); if let Some(warning_text) = &self.warning { let mut para = Paragraph::new(); @@ -2007,6 +2055,7 @@ impl TextFragment for StatusDisplay { htype == HighlightType::User || htype == HighlightType::Status || htype == HighlightType::WholeStatus || + htype == HighlightType::FoldableStatus || htype == HighlightType::PollOption } @@ -2018,6 +2067,10 @@ impl TextFragment for StatusDisplay { } HighlightType::Status => 1, HighlightType::WholeStatus => 1, + HighlightType::FoldableStatus => { + self.irt.count_highlightables(htype) + + if self.warning.is_some() {1} else {0} + } HighlightType::PollOption => self.poll.as_ref() .filter(|poll| poll.eligible) @@ -2043,6 +2096,25 @@ impl TextFragment for StatusDisplay { } Some(Highlight(HighlightType::WholeStatus, 0)) | Some(Highlight(HighlightType::Status, 0)) => Some(self.id.clone()), + + Some(Highlight(HighlightType::FoldableStatus, _)) => { + let mut highlight = highlight; + if let result @ Some(..) = self.irt.highlighted_id_update( + &mut highlight) + { + return result; + } + + if self.warning.is_some() && + highlight.consume(HighlightType::FoldableStatus, 1) == + Some(0) + { + return Some(self.id.clone()); + } + + None + } + Some(Highlight(HighlightType::PollOption, i)) => self.poll.as_ref() .filter(|poll| poll.eligible) .filter(|poll| i < poll.options.len()) diff --git a/src/tui.rs b/src/tui.rs index a79c7fd..e1de892 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -10,10 +10,12 @@ use ratatui::{ prelude::{Buffer, CrosstermBackend, Rect, Terminal}, style::{Style, Color, Modifier}, }; +use std::cell::RefCell; use std::cmp::min; use std::collections::{BTreeMap, HashMap, HashSet, hash_map}; use std::io::{Stdout, Write, stdout}; use std::fs::File; +use std::rc::Rc; use std::time::Duration; use unicode_width::UnicodeWidthStr; @@ -514,6 +516,7 @@ struct TuiLogicalState { overlay_activity_state: Option>, last_area: Option, file_positions: HashMap, + unfolded_posts: Rc>>, cfgloc: ConfigLocation, } @@ -529,6 +532,7 @@ impl TuiLogicalState { last_area: None, file_positions: HashMap::new(), cfgloc, + unfolded_posts: Rc::new(RefCell::new(HashSet::new())), } } @@ -772,15 +776,19 @@ impl TuiLogicalState { Activity::Util(UtilityActivity::LogsMenu2) => Ok(logs_menu_2()), Activity::NonUtil(NonUtilityActivity::HomeTimelineFile) => - home_timeline(&self.file_positions, client), + home_timeline(&self.file_positions, + self.unfolded_posts.clone(), client), Activity::NonUtil(NonUtilityActivity::PublicTimelineFile) => - public_timeline(&self.file_positions, client), + public_timeline(&self.file_positions, + self.unfolded_posts.clone(), client), Activity::NonUtil(NonUtilityActivity::LocalTimelineFile) => - local_timeline(&self.file_positions, client), + local_timeline(&self.file_positions, + self.unfolded_posts.clone(), client), Activity::NonUtil(NonUtilityActivity::HashtagTimeline(ref id)) => - hashtag_timeline(client, id), + hashtag_timeline(self.unfolded_posts.clone(), client, id), Activity::Util(UtilityActivity::ReadMentions) => - mentions(&self.file_positions, client, is_interrupt), + mentions(&self.file_positions, self.unfolded_posts.clone(), + client, is_interrupt), Activity::Util(UtilityActivity::EgoLog) => ego_log(&self.file_positions, client), Activity::Overlay(OverlayActivity::GetUserToExamine) => @@ -794,7 +802,7 @@ impl TuiLogicalState { Activity::Util(UtilityActivity::ExamineUser(ref name)) => examine_user(client, name), Activity::Util(UtilityActivity::InfoStatus(ref id)) => - view_single_post(client, id), + view_single_post(self.unfolded_posts.clone(), client, id), Activity::NonUtil(NonUtilityActivity::ComposeToplevel) => compose_post(client, post.unwrap_or_else(Post::new)), Activity::NonUtil(NonUtilityActivity::PostComposeMenu) => @@ -814,7 +822,7 @@ impl TuiLogicalState { 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), + view_thread(self.unfolded_posts.clone(), client, id, full), Activity::Util(UtilityActivity::ListStatusFavouriters(ref id)) => list_status_favouriters(client, id), Activity::Util(UtilityActivity::ListStatusBoosters(ref id)) => @@ -825,7 +833,8 @@ impl TuiLogicalState { list_user_followees(client, id), Activity::NonUtil(NonUtilityActivity::UserPosts( ref user, boosts, replies)) => - user_posts(&self.file_positions, client, user, boosts, replies), + user_posts(&self.file_positions, self.unfolded_posts.clone(), + client, user, boosts, replies), }; result.expect("FIXME: need to implement the Error Log here")