Skip to main content

egui_charts/ui/widget_bar/panels/
chats.rs

1//! The host's chat rooms: read a room and post to it, start chats and manage members.
2
3use crate::localization as l10n;
4use std::collections::{HashMap, VecDeque};
5use std::sync::Arc;
6
7use egui::{RichText, Ui};
8
9use super::controls::dropdown;
10use crate::data::FeedError;
11use crate::data::inbox::Inbox;
12use crate::icons::icons;
13use crate::services::{
14    ChatCapabilities, ChatEvent, ChatMessage, ChatUser, Chats, Listener, NewChatRoom,
15    NewChatRoomKind,
16};
17use crate::theme::ThemeExt;
18use crate::ui_kit::buttons::Button;
19use crate::ui_kit::filter::FilterWidget;
20use crate::ui_kit::status_message::{MessageKind, translated_status_message};
21use crate::ui_kit::{EmptyState, IconButton, PanelHeader};
22
23/// Messages kept at most across the rooms; older ones are dropped.
24const KEPT: usize = 5_000;
25
26/// Inbox key of the answers to [`Chats::send`]; streams and other requests use keys from 1.
27const SENT: u64 = 0;
28
29#[derive(Clone, Copy, Default, PartialEq)]
30enum View {
31    #[default]
32    Room,
33    NewChat,
34    Members,
35}
36
37struct Composer {
38    kind: NewChatRoomKind,
39    name: String,
40    recipients: Vec<ChatUser>,
41}
42
43impl Default for Composer {
44    fn default() -> Self {
45        Self {
46            kind: NewChatRoomKind::Direct,
47            name: String::new(),
48            recipients: Vec::new(),
49        }
50    }
51}
52
53/// Whether the host lets the user create a room of `kind`.
54fn allowed(kind: NewChatRoomKind, capabilities: &ChatCapabilities) -> bool {
55    match kind {
56        NewChatRoomKind::Direct => capabilities.direct_messages,
57        _ => capabilities.create_rooms,
58    }
59}
60
61/// Chat rooms pushed by the host's [`Chats`] service.
62///
63/// The user picks a room, reads its messages and posts; a posted message appears once the host
64/// pushes it. Where the service's [`capabilities`](Chats::capabilities) allow, the header offers
65/// to start a direct message or create a private or group room, choosing the recipients from
66/// the service's [`find_users`](Chats::find_users), and to add or remove the members of a room
67/// whose members the service pushes. Each action shows while it is pending and its failure, and
68/// attaching another service clears them. Call [`poll`](Self::poll) each frame, even while the
69/// panel is hidden, then [`show`](Self::show) while it is open. Accounts and moderation are the
70/// host's.
71#[derive(Default)]
72pub struct ChatsPanel {
73    service: Option<Arc<dyn Chats>>,
74    inbox: Arc<Inbox>,
75    listener: Option<Listener>,
76    /// The last inbox key handed out; streams and requests draw from it.
77    keys: u64,
78    generation: u64,
79    rooms: Vec<String>,
80    room: usize,
81    messages: VecDeque<ChatMessage>,
82    members: HashMap<String, Vec<ChatUser>>,
83    draft: FilterWidget,
84    error: Option<FeedError>,
85    view: View,
86    composer: Composer,
87    search: FilterWidget,
88    found: Vec<ChatUser>,
89    /// Inbox keys of the pending requests, or 0 for none.
90    search_key: u64,
91    create_key: u64,
92    member_key: u64,
93    /// A room to select once the service pushes it.
94    open: Option<String>,
95    action_error: Option<FeedError>,
96}
97
98impl ChatsPanel {
99    /// An unconnected panel. Attach a service with [`set_service`](Self::set_service).
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Attach a host service, clearing the previous one's rooms, members, recipients and pending
105    /// actions.
106    pub fn set_service(&mut self, service: Arc<dyn Chats>) {
107        self.service = Some(service);
108        self.view = View::Room;
109        self.composer = Composer::default();
110        self.reset_search();
111        self.create_key = 0;
112        self.member_key = 0;
113        self.open = None;
114        self.action_error = None;
115        self.watch();
116    }
117
118    fn next_key(&mut self) -> u64 {
119        self.keys += 1;
120        self.keys
121    }
122
123    fn reset_search(&mut self) {
124        self.search.clear();
125        self.found.clear();
126        self.search_key = 0;
127    }
128
129    fn watch(&mut self) {
130        self.rooms.clear();
131        self.messages.clear();
132        self.members.clear();
133        self.generation = self.next_key();
134        let (listener, events) = Listener::new(&self.inbox, self.generation);
135        self.listener = Some(listener);
136        if let Some(service) = &self.service {
137            service.watch(events);
138        }
139    }
140
141    fn add_room(&mut self, room: &str) {
142        if !self.rooms.iter().any(|known| known == room) {
143            self.rooms.push(room.to_owned());
144        }
145    }
146
147    /// Receive the service's pushes and answers, and watch again after a lagged stream.
148    pub fn poll(&mut self, ctx: &egui::Context) {
149        let repaint = ctx.clone();
150        self.inbox.set_waker(move || repaint.request_repaint());
151        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
152            self.watch();
153        }
154        for (key, answer) in self.inbox.drain().answers {
155            if key == SENT {
156                if let Ok(result) = answer.downcast::<Result<(), FeedError>>() {
157                    self.error = result.err();
158                }
159                continue;
160            }
161            if key == self.search_key {
162                self.search_key = 0;
163                if let Ok(result) = answer.downcast::<Result<Vec<ChatUser>, FeedError>>() {
164                    match *result {
165                        Ok(users) => self.found = users,
166                        Err(error) => self.action_error = Some(error),
167                    }
168                }
169                continue;
170            }
171            if key == self.create_key {
172                self.create_key = 0;
173                if let Ok(result) = answer.downcast::<Result<String, FeedError>>() {
174                    match *result {
175                        Ok(room) => {
176                            self.composer = Composer::default();
177                            self.reset_search();
178                            self.view = View::Room;
179                            self.open = Some(room);
180                        }
181                        Err(error) => self.action_error = Some(error),
182                    }
183                }
184                continue;
185            }
186            if key == self.member_key {
187                self.member_key = 0;
188                if let Ok(result) = answer.downcast::<Result<(), FeedError>>() {
189                    self.action_error = result.err();
190                }
191                continue;
192            }
193            if key != self.generation {
194                continue;
195            }
196            match answer.downcast::<ChatEvent>().map(|event| *event) {
197                Ok(ChatEvent::Room(room)) => self.add_room(&room),
198                Ok(ChatEvent::Message(message)) => {
199                    self.add_room(&message.room);
200                    self.messages.push_back(message);
201                    if self.messages.len() > KEPT {
202                        self.messages.pop_front();
203                    }
204                }
205                Ok(ChatEvent::Members { room, members }) => {
206                    self.add_room(&room);
207                    self.members.insert(room, members);
208                }
209                Ok(ChatEvent::Clear) => {
210                    self.rooms.clear();
211                    self.messages.clear();
212                    self.members.clear();
213                }
214                Err(_) => {}
215            }
216        }
217        if let Some(index) = self
218            .open
219            .as_ref()
220            .and_then(|open| self.rooms.iter().position(|room| room == open))
221        {
222            self.room = index;
223            self.open = None;
224        }
225        self.room = self.room.min(self.rooms.len().saturating_sub(1));
226    }
227
228    /// Render the chosen room, or the chat and member controls the header opens, after
229    /// [`poll`](Self::poll).
230    pub fn show(&mut self, ui: &mut Ui) {
231        let theme = ui.active_theme();
232        let capabilities = self
233            .service
234            .as_ref()
235            .map(|service| service.capabilities())
236            .unwrap_or_default();
237        let can_create = capabilities.direct_messages || capabilities.create_rooms;
238        let can_manage = capabilities.manage_members
239            && self
240                .rooms
241                .get(self.room)
242                .is_some_and(|room| self.members.contains_key(room));
243        if (!can_create && self.view == View::NewChat)
244            || (!can_manage && self.view == View::Members)
245        {
246            self.view = View::Room;
247        }
248        let mut view = self.view;
249        PanelHeader::new("Chats").show(ui, |ui| {
250            if can_manage
251                && IconButton::new(&icons::USERS)
252                    .tooltip("Members")
253                    .selected(self.view == View::Members)
254                    .show(ui)
255                    .clicked()
256            {
257                view = if self.view == View::Members {
258                    View::Room
259                } else {
260                    View::Members
261                };
262            }
263            if can_create
264                && IconButton::new(&icons::PLUS)
265                    .tooltip("New chat")
266                    .selected(self.view == View::NewChat)
267                    .show(ui)
268                    .clicked()
269            {
270                view = if self.view == View::NewChat {
271                    View::Room
272                } else {
273                    View::NewChat
274                };
275            }
276        });
277        if view != self.view {
278            self.view = view;
279            self.reset_search();
280            self.action_error = None;
281        }
282        if let Some(error) = &self.action_error {
283            translated_status_message(
284                ui,
285                MessageKind::Error,
286                l10n::format_text(
287                    ui.ctx(),
288                    "Chat action failed: {error}",
289                    &[("error", error.to_string().as_str())],
290                ),
291            );
292        }
293        match self.view {
294            View::NewChat => return self.show_new_chat(ui, &capabilities),
295            View::Members => return self.show_members(ui),
296            View::Room => {}
297        }
298        let Some(room) = self.rooms.get(self.room).cloned() else {
299            EmptyState::new("No chat rooms")
300                .icon(&icons::WIDGET_BAR_CHATS)
301                .fill_height()
302                .show(ui);
303            return;
304        };
305        dropdown(ui, "chat_room", &room, |ui| {
306            for (index, name) in self.rooms.iter().enumerate() {
307                ui.selectable_value(&mut self.room, index, l10n::text(ui.ctx(), name));
308            }
309        });
310        let room = self.rooms[self.room].clone();
311        if let Some(error) = &self.error {
312            translated_status_message(
313                ui,
314                MessageKind::Error,
315                l10n::format_text(
316                    ui.ctx(),
317                    "Message not sent: {error}",
318                    &[("error", error.to_string().as_str())],
319                ),
320            );
321        }
322        let mut send = false;
323        egui::Panel::bottom(ui.scope_id().with("chat_compose"))
324            .frame(egui::Frame::NONE)
325            .show_separator_line(false)
326            .show(ui, |ui| {
327                ui.add_space(theme.spacing.sm);
328                ui.horizontal(|ui| {
329                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
330                        send = Button::new("Send")
331                            .enabled(!self.draft.query().trim().is_empty())
332                            .show(ui)
333                            .clicked();
334                        self.draft = std::mem::take(&mut self.draft)
335                            .placeholder(l10n::format_text(
336                                ui.ctx(),
337                                "Message {room}",
338                                &[("room", room.as_str())],
339                            ))
340                            .show_icon(false)
341                            .show_clear_button(false);
342                        let response = self.draft.show(ui).response;
343                        let entered =
344                            response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
345                        send |= entered && !self.draft.query().trim().is_empty();
346                    });
347                });
348            });
349        egui::ScrollArea::vertical()
350            .auto_shrink([false, false])
351            .stick_to_bottom(true)
352            .show(ui, |ui| {
353                for message in self.messages.iter().filter(|m| m.room == room) {
354                    ui.scope_builder(egui::UiBuilder::new().sense(egui::Sense::hover()), |ui| {
355                        let hovered = ui.response().hovered();
356                        egui::Frame::new()
357                            .fill(if hovered {
358                                theme.ui.btn_bg_hover
359                            } else {
360                                egui::Color32::TRANSPARENT
361                            })
362                            .inner_margin(egui::Margin::symmetric(8, 0))
363                            .show(ui, |ui| {
364                                ui.set_width(ui.available_width());
365                                ui.set_min_height(26.0);
366                                ui.horizontal(|ui| {
367                                    ui.label(
368                                        RichText::new(l10n::text(ui.ctx(), &message.author))
369                                            .strong(),
370                                    );
371                                    ui.label(
372                                        RichText::new(l10n::text(
373                                            ui.ctx(),
374                                            message.time.format("%H:%M").to_string(),
375                                        ))
376                                        .size(theme.text.small)
377                                        .color(theme.ui.text_secondary),
378                                    );
379                                });
380                                ui.label(l10n::text(ui.ctx(), &message.text));
381                            });
382                    });
383                    ui.add_space(theme.spacing.sm);
384                }
385            });
386        if send && let Some(service) = &self.service {
387            let text = std::mem::take(self.draft.query_mut());
388            service.send(room, text.trim().to_owned(), self.inbox.reply(SENT));
389        }
390    }
391
392    /// The search field for people and the matches to choose from, other than those in `skip`.
393    /// The host is asked again whenever the text changes.
394    fn find_people(&mut self, ui: &mut Ui, skip: &[ChatUser]) -> Option<ChatUser> {
395        let before = self.search.query().to_owned();
396        self.search = std::mem::take(&mut self.search)
397            .placeholder("Find people")
398            .show_icon(false)
399            .show_clear_button(false);
400        self.search.show(ui);
401        if self.search.query() != before {
402            self.found.clear();
403            self.search_key = 0;
404            self.action_error = None;
405            let query = self.search.query().trim().to_owned();
406            if !query.is_empty()
407                && let Some(service) = self.service.clone()
408            {
409                self.search_key = self.next_key();
410                service.find_users(query, self.inbox.reply(self.search_key));
411            }
412        }
413        if self.search_key != 0 {
414            ui.horizontal(|ui| {
415                ui.spinner();
416                ui.label(l10n::text(ui.ctx(), "Searching"));
417            });
418        }
419        let mut picked = None;
420        for user in &self.found {
421            if !skip.iter().any(|known| known.id == user.id)
422                && ui
423                    .selectable_label(false, l10n::text(ui.ctx(), &user.name))
424                    .clicked()
425            {
426                picked = Some(user.clone());
427            }
428        }
429        picked
430    }
431
432    fn show_new_chat(&mut self, ui: &mut Ui, capabilities: &ChatCapabilities) {
433        let kinds = [
434            (NewChatRoomKind::Direct, "Direct message"),
435            (NewChatRoomKind::Private, "Private group"),
436            (NewChatRoomKind::Group, "Group"),
437        ];
438        if !allowed(self.composer.kind, capabilities)
439            && let Some((kind, _)) = kinds.iter().find(|(kind, _)| allowed(*kind, capabilities))
440        {
441            self.composer.kind = *kind;
442        }
443        ui.horizontal_wrapped(|ui| {
444            for (kind, label) in kinds {
445                if allowed(kind, capabilities)
446                    && Button::new(label)
447                        .small()
448                        .selected(self.composer.kind == kind)
449                        .show(ui)
450                        .clicked()
451                {
452                    self.composer.kind = kind;
453                }
454            }
455        });
456        let direct = self.composer.kind == NewChatRoomKind::Direct;
457        if direct {
458            self.composer.recipients.truncate(1);
459        } else {
460            ui.add(
461                egui::TextEdit::singleline(&mut self.composer.name)
462                    .hint_text(l10n::text(ui.ctx(), "Room name"))
463                    .desired_width(f32::INFINITY),
464            );
465        }
466        let mut remove = None;
467        for (index, user) in self.composer.recipients.iter().enumerate() {
468            ui.push_id(&user.id, |ui| {
469                ui.horizontal(|ui| {
470                    ui.label(l10n::text(ui.ctx(), &user.name));
471                    if IconButton::new(&icons::CLOSE)
472                        .tooltip("Remove")
473                        .show(ui)
474                        .clicked()
475                    {
476                        remove = Some(index);
477                    }
478                })
479            });
480        }
481        if let Some(index) = remove {
482            self.composer.recipients.remove(index);
483        }
484        let recipients = self.composer.recipients.clone();
485        if let Some(user) = self.find_people(ui, &recipients) {
486            if direct {
487                self.composer.recipients.clear();
488            }
489            self.composer.recipients.push(user);
490        }
491        let ready = !self.composer.recipients.is_empty()
492            && (direct || !self.composer.name.trim().is_empty());
493        let pending = self.create_key != 0;
494        if pending {
495            ui.horizontal(|ui| {
496                ui.spinner();
497                ui.label(l10n::text(ui.ctx(), "Creating chat"));
498            });
499        }
500        if Button::new("Create")
501            .enabled(ready && !pending)
502            .show(ui)
503            .clicked()
504            && let Some(service) = self.service.clone()
505        {
506            self.action_error = None;
507            self.create_key = self.next_key();
508            let room = NewChatRoom {
509                kind: self.composer.kind,
510                name: if direct {
511                    String::new()
512                } else {
513                    self.composer.name.trim().to_owned()
514                },
515                members: self
516                    .composer
517                    .recipients
518                    .iter()
519                    .map(|u| u.id.clone())
520                    .collect(),
521            };
522            service.create_room(room, self.inbox.reply(self.create_key));
523        }
524    }
525
526    fn show_members(&mut self, ui: &mut Ui) {
527        let Some(room) = self.rooms.get(self.room).cloned() else {
528            return;
529        };
530        let members = self.members.get(&room).cloned().unwrap_or_default();
531        let pending = self.member_key != 0;
532        let mut remove = None;
533        for user in &members {
534            ui.push_id(&user.id, |ui| {
535                ui.horizontal(|ui| {
536                    ui.label(l10n::text(ui.ctx(), &user.name));
537                    if IconButton::new(&icons::CLOSE)
538                        .tooltip("Remove from room")
539                        .enabled(!pending)
540                        .show(ui)
541                        .clicked()
542                    {
543                        remove = Some(user.id.clone());
544                    }
545                })
546            });
547        }
548        let add = self.find_people(ui, &members);
549        if pending {
550            ui.horizontal(|ui| {
551                ui.spinner();
552                ui.label(l10n::text(ui.ctx(), "Updating members"));
553            });
554        }
555        let Some(service) = self.service.clone() else {
556            return;
557        };
558        if let Some(user) = remove {
559            self.action_error = None;
560            self.member_key = self.next_key();
561            service.remove_member(room, user, self.inbox.reply(self.member_key));
562        } else if let Some(user) = add.filter(|_| !pending) {
563            self.action_error = None;
564            self.member_key = self.next_key();
565            service.add_member(room, user.id, self.inbox.reply(self.member_key));
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::data::Reply;
574    use crate::services::Events;
575    use egui::{Role, Vec2};
576    use egui_kittest::{Harness, kittest::Queryable};
577    use std::sync::Mutex;
578
579    #[derive(Default)]
580    struct Host {
581        created: Mutex<Vec<(NewChatRoom, Reply<String>)>>,
582        events: Mutex<Option<Events<ChatEvent>>>,
583    }
584
585    impl Chats for Host {
586        fn watch(&self, events: Events<ChatEvent>) {
587            *self.events.lock().unwrap() = Some(events);
588        }
589        fn send(&self, _: String, _: String, reply: Reply<()>) {
590            reply.send(Ok(()));
591        }
592        fn capabilities(&self) -> ChatCapabilities {
593            ChatCapabilities {
594                direct_messages: true,
595                create_rooms: true,
596                manage_members: false,
597            }
598        }
599        fn create_room(&self, room: NewChatRoom, reply: Reply<String>) {
600            self.created.lock().unwrap().push((room, reply));
601        }
602    }
603
604    /// Guards creating a room through the host: the chosen recipients and name are sent once, a
605    /// refusal keeps the form and shows the failure, an acceptance opens the pushed room, and
606    /// attaching another service clears the form.
607    #[test]
608    fn creating_a_room_sends_the_recipients_and_keeps_the_form_on_refusal() {
609        let host = Arc::new(Host::default());
610        let mut panel = ChatsPanel::new();
611        panel.set_service(host.clone());
612        panel.view = View::NewChat;
613        panel.composer.kind = NewChatRoomKind::Private;
614        panel.composer.name = " Desk ".into();
615        panel.composer.recipients = vec![ChatUser::new("u1", "Ada"), ChatUser::new("u2", "Bo")];
616        let mut h = Harness::builder()
617            .with_size(Vec2::new(320.0, 600.0))
618            .with_context_setup(|ctx| {
619                crate::theme::apply_to_egui(ctx, &crate::theme::Theme::dark());
620                egui_extras::install_image_loaders(ctx);
621            })
622            .build_ui_state(|ui, panel: &mut ChatsPanel| panel.show(ui), panel);
623        h.run();
624        h.get_by_role_and_label(Role::Button, "Create").click();
625        h.step();
626        let ctx = egui::Context::default();
627        let (room, reply) = {
628            let mut created = host.created.lock().unwrap();
629            assert_eq!(created.len(), 1);
630            created.pop().unwrap()
631        };
632        assert_eq!(
633            room,
634            NewChatRoom {
635                kind: NewChatRoomKind::Private,
636                name: "Desk".into(),
637                members: vec!["u1".into(), "u2".into()],
638            }
639        );
640        reply.send(Err(FeedError::Other("not allowed".into())));
641        h.state_mut().poll(&ctx);
642        h.run();
643        let panel = h.state();
644        assert!(panel.view == View::NewChat);
645        assert_eq!(panel.composer.recipients.len(), 2);
646        assert_eq!(
647            panel.action_error,
648            Some(FeedError::Other("not allowed".into()))
649        );
650
651        h.get_by_role_and_label(Role::Button, "Create").click();
652        h.step();
653        let (_, reply) = host.created.lock().unwrap().pop().unwrap();
654        host.events
655            .lock()
656            .unwrap()
657            .as_ref()
658            .unwrap()
659            .push(ChatEvent::Room("r1".into()));
660        reply.send(Ok("r1".into()));
661        h.state_mut().poll(&ctx);
662        let panel = h.state();
663        assert!(panel.view == View::Room);
664        assert_eq!(panel.rooms, ["r1"]);
665        assert!(panel.composer.recipients.is_empty() && panel.action_error.is_none());
666
667        let panel = h.state_mut();
668        panel.view = View::NewChat;
669        panel.composer.recipients = vec![ChatUser::new("u1", "Ada")];
670        panel.set_service(Arc::new(Host::default()));
671        assert!(panel.composer.recipients.is_empty() && panel.view == View::Room);
672    }
673}