chiark / gitweb /
Implement the various user lists.
authorSimon Tatham <anakin@pobox.com>
Thu, 4 Jan 2024 08:30:49 +0000 (08:30 +0000)
committerSimon Tatham <anakin@pobox.com>
Thu, 4 Jan 2024 12:03:19 +0000 (12:03 +0000)
Lists of users who followed or boosted a given post, or who a given
user follows or is followed by.

But something goes wrong when you try to extend these feeds. I'll
debug that in a moment ... but first, let's write some debugging
framework.

src/client.rs
src/file.rs
src/text.rs
src/tui.rs

index 351a70910cd1cdc7b187d4affafa23cbbf658d94..61286711092517c5c5e6e4044c5a73454533c795 100644 (file)
@@ -22,6 +22,10 @@ pub enum FeedId {
     User(String, Boosts, Replies),
     Mentions,
     Ego,
+    Favouriters(String),
+    Boosters(String),
+    Followers(String),
+    Followees(String),
 }
 
 #[derive(Debug, PartialEq, Eq, Clone)]
@@ -521,6 +525,18 @@ impl Client {
                     .param("types[]", "follow")
                     .param("types[]", "favourite")
             }
+            FeedId::Favouriters(id) => {
+                Req::get(&format!("v1/statuses/{id}/favourited_by"))
+            }
+            FeedId::Boosters(id) => {
+                Req::get(&format!("v1/statuses/{id}/reblogged_by"))
+            }
+            FeedId::Followers(id) => {
+                Req::get(&format!("v1/accounts/{id}/followers"))
+            }
+            FeedId::Followees(id) => {
+                Req::get(&format!("v1/accounts/{id}/following"))
+            }
         };
 
         let req = match ext {
@@ -600,6 +616,19 @@ impl Client {
                 }
                 nots.iter().rev().map(|not| not.id.clone()).collect()
             }
+            FeedId::Favouriters(..) | FeedId::Boosters(..) |
+            FeedId::Followers(..) | FeedId::Followees(..) => {
+                let acs: Vec<Account> = match serde_json::from_str(&body) {
+                    Ok(acs) => Ok(acs),
+                    Err(e) => {
+                        Err(ClientError::UrlError(url.clone(), e.to_string()))
+                    }
+                }?;
+                for ac in &acs {
+                    self.cache_account(ac);
+                }
+                acs.iter().rev().map(|ac| ac.id.clone()).collect()
+            }
         };
         let any_new = !ids.is_empty();
 
index 2b386d58f76438bd3ce1cc9a6a7abbe30b830196..7c13dc2f984ca979d43eb179aab6c2e5a04a7df0 100644 (file)
@@ -1,3 +1,4 @@
+use itertools::Itertools;
 use std::cmp::{min, max};
 use std::collections::{HashMap, HashSet};
 
@@ -31,6 +32,10 @@ trait FileDataSource {
     fn try_extend(&self, client: &mut Client) -> Result<bool, ClientError>;
     fn updated(&self, feeds_updated: &HashSet<FeedId>) -> bool;
     fn extendable(&self) -> bool;
+
+    fn single_id(&self) -> String {
+        panic!("Should only call this if the FileType sets CAN_LIST");
+    }
 }
 
 struct FeedSource {
@@ -83,10 +88,21 @@ impl FileDataSource for StaticSource {
     }
     fn updated(&self, _feeds_updated: &HashSet<FeedId>) -> bool { false }
     fn extendable(&self) -> bool { false }
+    fn single_id(&self) -> String {
+        self.ids.iter().exactly_one()
+            .expect("Should only call this on singleton StaticSources")
+            .to_owned()
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum CanList {
+    Nothing, ForPost, ForUser,
 }
 
 trait FileType {
     type Item: TextFragment + Sized;
+    const CAN_LIST: CanList = CanList::Nothing;
 
     fn get_from_client(id: &str, client: &mut Client) ->
         Result<Self::Item, ClientError>;
@@ -130,6 +146,18 @@ impl FileType for EgoNotificationFeedType {
     }
 }
 
+struct UserListFeedType {}
+impl FileType for UserListFeedType {
+    type Item = UserListEntry;
+
+    fn get_from_client(id: &str, client: &mut Client) ->
+        Result<Self::Item, ClientError>
+    {
+        let ac = client.account_by_id(&id)?;
+        Ok(UserListEntry::from_account(&ac, client))
+    }
+}
+
 struct FileContents<Type: FileType, Source: FileDataSource> {
     source: Source,
     header: FileHeader,
@@ -203,6 +231,7 @@ enum SelectionPurpose {
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 enum UIMode {
     Normal,
+    ListSubmenu,
     Select(HighlightType, SelectionPurpose),
 }
 
@@ -260,7 +289,7 @@ impl<Type: FileType, Source: FileDataSource> File<Type, Source> {
             let mut lines = Vec::new();
 
             let highlight = match self.ui_mode {
-                UIMode::Normal => None,
+                UIMode::Normal | UIMode::ListSubmenu => None,
                 UIMode::Select(htype, _purpose) => match self.selection {
                     None => None,
                     Some((item, sub)) => if item == index {
@@ -783,6 +812,11 @@ impl<Type: FileType, Source: FileDataSource>
                 } else {
                     fs
                 };
+                let fs = if Type::CAN_LIST != CanList::Nothing {
+                    fs.add(Pr('l'), "List", 40)
+                } else {
+                    fs
+                };
                 let fs = if Type::Item::can_highlight(HighlightType::Status) {
                     fs.add(Pr('i'), "Post Info", 38)
                 } else {
@@ -822,6 +856,19 @@ impl<Type: FileType, Source: FileDataSource>
                     full_items * mult + start_line,
                     total_items * mult)
             }
+            UIMode::ListSubmenu => {
+                let fs = match Type::CAN_LIST {
+                    CanList::ForUser => fs
+                        .add(Pr('I'), "List Followers", 99)
+                        .add(Pr('O'), "List Followed", 99),
+                    CanList::ForPost => fs
+                        .add(Pr('F'), "List Favouriters", 99)
+                        .add(Pr('B'), "List Boosters", 99),
+                    CanList::Nothing =>
+                        panic!("Then we shouldn't be in this submenu"),
+                };
+                fs.add(Pr('Q'), "Quit", 100)
+            }
             UIMode::Select(_htype, purpose) => {
                 let fs = match purpose {
                     SelectionPurpose::ExamineUser =>
@@ -974,6 +1021,48 @@ impl<Type: FileType, Source: FileDataSource>
                     }
                 }
 
+                Pr('l') | Pr('L') => {
+                    if Type::CAN_LIST != CanList::Nothing {
+                        self.ui_mode = UIMode::ListSubmenu;
+                    }
+                    LogicalAction::Nothing
+                }
+
+                _ => LogicalAction::Nothing,
+            }
+            UIMode::ListSubmenu => match key {
+                Pr('f') | Pr('F') => if Type::CAN_LIST == CanList::ForPost {
+                    LogicalAction::Goto(UtilityActivity::ListStatusFavouriters(
+                        self.contents.source.single_id()).into())
+                } else {
+                    LogicalAction::Nothing
+                }
+
+                Pr('b') | Pr('B') => if Type::CAN_LIST == CanList::ForPost {
+                    LogicalAction::Goto(UtilityActivity::ListStatusBoosters(
+                        self.contents.source.single_id()).into())
+                } else {
+                    LogicalAction::Nothing
+                }
+
+                Pr('i') | Pr('I') => if Type::CAN_LIST == CanList::ForUser {
+                    LogicalAction::Goto(UtilityActivity::ListUserFollowers(
+                        self.contents.source.single_id()).into())
+                } else {
+                    LogicalAction::Nothing
+                }
+
+                Pr('o') | Pr('O') => if Type::CAN_LIST == CanList::ForUser {
+                    LogicalAction::Goto(UtilityActivity::ListUserFollowees(
+                        self.contents.source.single_id()).into())
+                } else {
+                    LogicalAction::Nothing
+                }
+
+                Pr('q') | Pr('Q') => {
+                    self.ui_mode = UIMode::Normal;
+                    LogicalAction::Nothing
+                }
                 _ => LogicalAction::Nothing,
             }
             UIMode::Select(_, purpose) => match key {
@@ -1061,6 +1150,52 @@ pub fn ego_log(client: &mut Client) ->
     Ok(Box::new(file))
 }
 
+pub fn list_status_favouriters(client: &mut Client, id: &str) ->
+    Result<Box<dyn ActivityState>, ClientError>
+{
+    let file = File::<UserListFeedType, _>::new(
+        client, FeedSource::new(FeedId::Favouriters(id.to_owned())),
+        ColouredString::uniform(
+            &format!("Users who favourited post {id}"), 'H'))?;
+    Ok(Box::new(file))
+}
+
+pub fn list_status_boosters(client: &mut Client, id: &str) ->
+    Result<Box<dyn ActivityState>, ClientError>
+{
+    let file = File::<UserListFeedType, _>::new(
+        client, FeedSource::new(FeedId::Boosters(id.to_owned())),
+        ColouredString::uniform(
+            &format!("Users who boosted post {id}"), 'H'))?;
+    Ok(Box::new(file))
+}
+
+pub fn list_user_followers(client: &mut Client, id: &str) ->
+    Result<Box<dyn ActivityState>, ClientError>
+{
+    let ac = client.account_by_id(&id)?;
+    let name = client.fq(&ac.acct);
+
+    let file = File::<UserListFeedType, _>::new(
+        client, FeedSource::new(FeedId::Followers(id.to_owned())),
+        ColouredString::uniform(
+            &format!("Users who follow {name}"), 'H'))?;
+    Ok(Box::new(file))
+}
+
+pub fn list_user_followees(client: &mut Client, id: &str) ->
+    Result<Box<dyn ActivityState>, ClientError>
+{
+    let ac = client.account_by_id(&id)?;
+    let name = client.fq(&ac.acct);
+
+    let file = File::<UserListFeedType, _>::new(
+        client, FeedSource::new(FeedId::Followees(id.to_owned())),
+        ColouredString::uniform(
+            &format!("Users who {name} follows"), 'H'))?;
+    Ok(Box::new(file))
+}
+
 pub fn hashtag_timeline(client: &mut Client, tag: &str) ->
     Result<Box<dyn ActivityState>, ClientError>
 {
@@ -1074,6 +1209,7 @@ pub fn hashtag_timeline(client: &mut Client, tag: &str) ->
 struct ExamineUserFileType {}
 impl FileType for ExamineUserFileType {
     type Item = ExamineUserDisplay;
+    const CAN_LIST: CanList = CanList::ForUser;
 
     fn get_from_client(id: &str, client: &mut Client) ->
         Result<Self::Item, ClientError>
@@ -1099,6 +1235,7 @@ pub fn examine_user(client: &mut Client, account_id: &str) ->
 struct DetailedStatusFileType {}
 impl FileType for DetailedStatusFileType {
     type Item = DetailedStatusDisplay;
+    const CAN_LIST: CanList = CanList::ForPost;
 
     fn get_from_client(id: &str, client: &mut Client) ->
         Result<Self::Item, ClientError>
index 0c4019c4b51d083b9492263fb16380dc85c35aba..9a6e5c255267a6b58ccc4f2ee480112f63f487c9 100644 (file)
@@ -1141,6 +1141,10 @@ impl UserListEntry {
             account_desc: format!("{} ({})", nameline, account),
         }
     }
+
+    pub fn from_account(ac: &Account, client: &mut Client) -> Self {
+        Self::new(&client.fq(&ac.acct), &ac.display_name)
+    }
 }
 
 impl TextFragment for UserListEntry {
index 943c450f4b81b4f4f28d88f961da501de1e16ea4..e6756d3c8895d0ca21361bffef55507a479ab6c3 100644 (file)
@@ -475,7 +475,14 @@ fn new_activity_state(activity: Activity, client: &mut Client,
             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!(),
+        Activity::Util(UtilityActivity::ListStatusFavouriters(ref id)) =>
+            list_status_favouriters(client, id),
+        Activity::Util(UtilityActivity::ListStatusBoosters(ref id)) =>
+            list_status_boosters(client, id),
+        Activity::Util(UtilityActivity::ListUserFollowers(ref id)) =>
+            list_user_followers(client, id),
+        Activity::Util(UtilityActivity::ListUserFollowees(ref id)) =>
+            list_user_followees(client, id),
     };
 
     result.expect("FIXME: need to implement the Error Log here")