Skip to main content

egui_charts/services/
profile_directory.rs

1//! The profiles and follow requests of one account, for the terminal's social panels.
2
3use std::collections::{BTreeMap, HashMap};
4use std::sync::Arc;
5
6use super::{Listener, Profile, ProfileEvent, ProfileId, Profiles};
7use crate::data::FeedError;
8use crate::data::inbox::Inbox;
9
10/// Where a follow or unfollow request stands.
11#[derive(Clone, Debug, PartialEq)]
12#[non_exhaustive]
13pub enum FollowStatus {
14    /// Sent to the host, which has not answered.
15    Pending,
16    /// The host refused or could not make the change; the message is the host's. The profile
17    /// keeps its last reported state.
18    Failed(String),
19}
20
21/// A request to follow or unfollow, and how it stands.
22#[derive(Clone, Debug, PartialEq)]
23pub struct FollowOperation {
24    /// True for a follow request, false for an unfollow request.
25    pub follow: bool,
26    /// Where it stands.
27    pub status: FollowStatus,
28}
29
30/// The profiles the host pushed and the follow requests the user made, for one account. Call
31/// [`poll`](Self::poll) every frame; show [`profile`](Self::profile) with its
32/// [`operation`](Self::operation), which says whether a request is pending or failed.
33///
34/// A new service starts from nothing: [`set_service`](Self::set_service) drops the previous
35/// account's profiles and requests, and answers still on their way from the old service are
36/// ignored.
37#[derive(Default)]
38pub struct ProfileDirectory {
39    service: Option<Arc<dyn Profiles>>,
40    inbox: Arc<Inbox>,
41    listener: Option<Listener>,
42    profiles: BTreeMap<ProfileId, Profile>,
43    operations: HashMap<ProfileId, FollowOperation>,
44    /// The key of each reply awaited, with the profile it concerns.
45    requests: HashMap<u64, ProfileId>,
46    /// Requests sent since the last watch, which numbers their keys after the stream's 0.
47    sent: u64,
48}
49
50impl ProfileDirectory {
51    /// A directory without a service. Attach one with [`set_service`](Self::set_service).
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Connects to `service`, dropping the previous account's profiles and follow requests.
57    pub fn set_service(&mut self, service: Arc<dyn Profiles>) {
58        self.service = Some(service);
59        self.watch();
60    }
61
62    fn watch(&mut self) {
63        self.profiles.clear();
64        self.operations.clear();
65        self.requests.clear();
66        self.sent = 0;
67        // A new inbox, so answers to the previous stream and requests are never read.
68        self.inbox = Arc::default();
69        let (listener, events) = Listener::new(&self.inbox, 0);
70        self.listener = Some(listener);
71        if let Some(service) = &self.service {
72            service.watch(events);
73        }
74    }
75
76    /// Applies the host's pushes and answers, and watches again after a lagged stream.
77    pub fn poll(&mut self, ctx: &egui::Context) {
78        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
79            self.watch();
80        }
81        let repaint = ctx.clone();
82        self.inbox.set_waker(move || repaint.request_repaint());
83        for (key, answer) in self.inbox.drain().answers {
84            if key == 0 {
85                match answer.downcast::<ProfileEvent>().map(|event| *event) {
86                    Ok(ProfileEvent::Upsert(profile)) => {
87                        self.profiles.insert(profile.id.clone(), profile);
88                    }
89                    Ok(ProfileEvent::Remove(id)) => {
90                        self.profiles.remove(&id);
91                        self.operations.remove(&id);
92                        self.requests.retain(|_, profile| profile != &id);
93                    }
94                    Ok(ProfileEvent::Clear) => {
95                        self.profiles.clear();
96                        self.operations.clear();
97                        self.requests.clear();
98                    }
99                    Err(_) => {}
100                }
101            } else if let Some(id) = self.requests.remove(&key)
102                && let Ok(answer) = answer.downcast::<Result<(), FeedError>>()
103            {
104                match *answer {
105                    Ok(()) => {
106                        self.operations.remove(&id);
107                    }
108                    Err(error) => {
109                        if let Some(operation) = self.operations.get_mut(&id) {
110                            operation.status = FollowStatus::Failed(error.to_string());
111                        }
112                    }
113                }
114            }
115        }
116    }
117
118    /// The profile `id`, as the host last pushed it.
119    pub fn profile(&self, id: &ProfileId) -> Option<&Profile> {
120        self.profiles.get(id)
121    }
122
123    /// Every profile, ordered by identifier.
124    pub fn profiles(&self) -> impl Iterator<Item = &Profile> {
125        self.profiles.values()
126    }
127
128    /// The follow request for `id` that is pending or failed, if any.
129    pub fn operation(&self, id: &ProfileId) -> Option<&FollowOperation> {
130        self.operations.get(id)
131    }
132
133    /// Asks the host to follow (`follow`) or unfollow `id`. Ignored for an unknown profile, one
134    /// already in the asked state, or while an earlier request for it is pending; a failed
135    /// request may be repeated.
136    pub fn follow(&mut self, id: &ProfileId, follow: bool) {
137        let pending = self
138            .operations
139            .get(id)
140            .is_some_and(|operation| operation.status == FollowStatus::Pending);
141        if pending || self.profiles.get(id).is_none_or(|p| p.followed == follow) {
142            return;
143        }
144        let Some(service) = &self.service else {
145            return;
146        };
147        self.sent += 1;
148        let key = self.sent;
149        self.requests.insert(key, id.clone());
150        self.operations.insert(
151            id.clone(),
152            FollowOperation {
153                follow,
154                status: FollowStatus::Pending,
155            },
156        );
157        service.follow(id.clone(), follow, self.inbox.reply(key));
158    }
159
160    /// Forgets a failed request, such as after the user dismissed its message.
161    pub fn dismiss(&mut self, id: &ProfileId) {
162        if self
163            .operations
164            .get(id)
165            .is_some_and(|operation| operation.status != FollowStatus::Pending)
166        {
167            self.operations.remove(id);
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::data::Reply;
176    use crate::services::Events;
177    use std::sync::Mutex;
178
179    /// A host that pushes one profile and holds the follow answers for the test to send.
180    #[derive(Default)]
181    struct Host(Mutex<Held>);
182
183    /// The stream, and the follow answers held back.
184    type Held = (Option<Events<ProfileEvent>>, Vec<(bool, Reply<()>)>);
185
186    impl Profiles for Host {
187        fn watch(&self, events: Events<ProfileEvent>) {
188            events.push(ProfileEvent::Upsert(Profile::new(
189                ProfileId("1".into()),
190                "Ada",
191            )));
192            self.0.lock().unwrap().0 = Some(events);
193        }
194        fn follow(&self, _: ProfileId, follow: bool, reply: Reply<()>) {
195            self.0.lock().unwrap().1.push((follow, reply));
196        }
197    }
198
199    #[test]
200    fn follow_requests_show_pending_and_failed_and_a_new_service_forgets_the_old_account() {
201        let ctx = egui::Context::default();
202        let (old, id) = (Arc::new(Host::default()), ProfileId("1".into()));
203        let mut directory = ProfileDirectory::new();
204        directory.set_service(old.clone());
205        directory.poll(&ctx);
206        assert_eq!(directory.profile(&id).map(|p| p.followed), Some(false));
207        // Unfollowing a profile that is not followed asks nothing; following is pending.
208        directory.follow(&id, false);
209        directory.follow(&id, true);
210        directory.follow(&id, true);
211        let (follow, reply) = old.0.lock().unwrap().1.pop().unwrap();
212        assert!(follow && old.0.lock().unwrap().1.is_empty(), "one request");
213        assert_eq!(
214            directory.operation(&id).unwrap().status,
215            FollowStatus::Pending
216        );
217        reply.send(Err(FeedError::Other("blocked".into())));
218        directory.poll(&ctx);
219        assert!(matches!(
220            &directory.operation(&id).unwrap().status,
221            FollowStatus::Failed(message) if message.contains("blocked")
222        ));
223        assert_eq!(directory.profile(&id).map(|p| p.followed), Some(false));
224        // A failed request may be repeated; the host's push and answer settle it.
225        directory.follow(&id, true);
226        let (_, reply) = old.0.lock().unwrap().1.pop().unwrap();
227        let mut followed = Profile::new(id.clone(), "Ada");
228        followed.followed = true;
229        old.0
230            .lock()
231            .unwrap()
232            .0
233            .as_ref()
234            .unwrap()
235            .push(ProfileEvent::Upsert(followed));
236        reply.send(Ok(()));
237        directory.poll(&ctx);
238        assert!(directory.operation(&id).is_none());
239        assert_eq!(directory.profile(&id).map(|p| p.followed), Some(true));
240        // Another account: nothing of the old one stays, and its late answer is ignored.
241        directory.follow(&id, false);
242        let (_, late) = old.0.lock().unwrap().1.pop().unwrap();
243        let next = Arc::new(Host::default());
244        directory.set_service(next.clone());
245        late.send(Ok(()));
246        directory.poll(&ctx);
247        assert_eq!(directory.profile(&id).map(|p| p.followed), Some(false));
248        assert!(directory.operation(&id).is_none());
249
250        // Removing or clearing profiles invalidates their requests, even if the same id returns.
251        for event in [ProfileEvent::Remove(id.clone()), ProfileEvent::Clear] {
252            directory.follow(&id, true);
253            let (_, stale) = next.0.lock().unwrap().1.pop().unwrap();
254            let held = next.0.lock().unwrap();
255            let events = held.0.as_ref().unwrap();
256            events.push(event);
257            events.push(ProfileEvent::Upsert(Profile::new(id.clone(), "Ada")));
258            drop(held);
259            directory.poll(&ctx);
260            assert!(directory.operation(&id).is_none());
261            directory.follow(&id, true);
262            let (_, current) = next.0.lock().unwrap().1.pop().unwrap();
263            stale.send(Err(FeedError::Other("stale refusal".into())));
264            directory.poll(&ctx);
265            assert_eq!(
266                directory.operation(&id).unwrap().status,
267                FollowStatus::Pending
268            );
269            current.send(Err(FeedError::Other("current refusal".into())));
270            directory.poll(&ctx);
271            directory.dismiss(&id);
272        }
273    }
274}