1use 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
23const KEPT: usize = 5_000;
25
26const 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
53fn allowed(kind: NewChatRoomKind, capabilities: &ChatCapabilities) -> bool {
55 match kind {
56 NewChatRoomKind::Direct => capabilities.direct_messages,
57 _ => capabilities.create_rooms,
58 }
59}
60
61#[derive(Default)]
72pub struct ChatsPanel {
73 service: Option<Arc<dyn Chats>>,
74 inbox: Arc<Inbox>,
75 listener: Option<Listener>,
76 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 search_key: u64,
91 create_key: u64,
92 member_key: u64,
93 open: Option<String>,
95 action_error: Option<FeedError>,
96}
97
98impl ChatsPanel {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 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 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 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 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 #[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}