1use std::collections::HashMap;
2use std::io;
3
4#[cfg(unix)]
5use std::os::unix::io::AsRawFd;
6
7use crossterm::{
8 event::{self, DisableBracketedPaste, EnableBracketedPaste, Event},
9 execute,
10 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use ratatui::{
13 backend::CrosstermBackend,
14 layout::{Alignment, Constraint, Direction, Layout, Rect},
15 style::{Color, Modifier, Style},
16 text::{Line, Span, Text},
17 widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
18 Terminal,
19};
20use tokio::{
21 io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
22 sync::mpsc,
23};
24
25mod input;
26mod render;
27mod render_bots;
28mod widgets;
29
30use room_protocol::SubscriptionTier;
31
32use crate::message::Message;
33use input::{
34 build_payload, cursor_display_pos, handle_key, parse_kick_broadcast, parse_status_broadcast,
35 parse_subscription_broadcast, seed_online_users_from_who, wrap_input_display, Action,
36 InputState,
37};
38use render::{
39 assign_color, find_view_start, format_message, render_tab_bar, user_color, welcome_splash,
40 ColorMap, TabInfo,
41};
42
43const MAX_INPUT_LINES: usize = 6;
45
46struct RoomTab {
49 room_id: String,
50 messages: Vec<Message>,
51 online_users: Vec<String>,
52 user_statuses: HashMap<String, String>,
53 subscription_tiers: HashMap<String, SubscriptionTier>,
54 unread_count: usize,
55 scroll_offset: usize,
56 msg_rx: mpsc::UnboundedReceiver<Message>,
57 write_half: tokio::net::unix::OwnedWriteHalf,
58}
59
60enum DrainResult {
62 Ok,
64 Disconnected,
66}
67
68impl RoomTab {
69 fn process_message(&mut self, msg: Message, color_map: &mut ColorMap, is_active: bool) {
73 match &msg {
74 Message::Join { user, .. } if !self.online_users.contains(user) => {
75 assign_color(user, color_map);
76 self.online_users.push(user.clone());
77 }
78 Message::Leave { user, .. } => {
79 self.online_users.retain(|u| u != user);
80 self.user_statuses.remove(user);
81 self.subscription_tiers.remove(user);
82 }
83 Message::Message { user, .. } if !self.online_users.contains(user) => {
84 assign_color(user, color_map);
85 self.online_users.push(user.clone());
86 }
87 Message::Message { user, .. } => {
88 assign_color(user, color_map);
89 }
90 Message::System { user, content, .. } if user == "broker" => {
91 seed_online_users_from_who(
92 content,
93 &mut self.online_users,
94 &mut self.user_statuses,
95 );
96 if let Some((name, status)) = parse_status_broadcast(content) {
97 self.user_statuses.insert(name, status);
98 }
99 if let Some(kicked) = parse_kick_broadcast(content) {
100 self.online_users.retain(|u| u != kicked);
101 self.user_statuses.remove(kicked);
102 self.subscription_tiers.remove(kicked);
103 }
104 if let Some((name, tier)) = parse_subscription_broadcast(content) {
105 self.subscription_tiers.insert(name, tier);
106 }
107 for u in &self.online_users {
108 assign_color(u, color_map);
109 }
110 }
111 _ => {}
112 }
113 if !is_active {
114 self.unread_count += 1;
115 }
116 self.messages.push(msg);
117 }
118
119 fn drain_messages(&mut self, color_map: &mut ColorMap, is_active: bool) -> DrainResult {
121 loop {
122 match self.msg_rx.try_recv() {
123 Ok(msg) => self.process_message(msg, color_map, is_active),
124 Err(mpsc::error::TryRecvError::Empty) => return DrainResult::Ok,
125 Err(mpsc::error::TryRecvError::Disconnected) => return DrainResult::Disconnected,
126 }
127 }
128 }
129}
130
131fn read_user_token(username: &str) -> Option<String> {
135 let path = crate::paths::global_token_path(username);
136 let data = std::fs::read_to_string(&path).ok()?;
137 let v: serde_json::Value = serde_json::from_str(data.trim()).ok()?;
138 v["token"].as_str().map(|s| s.to_owned())
139}
140
141async fn open_dm_tab(
149 socket_path: &std::path::Path,
150 dm_room_id: &str,
151 username: &str,
152 target_user: &str,
153 history_lines: usize,
154) -> anyhow::Result<RoomTab> {
155 use tokio::net::UnixStream;
156
157 let token = read_user_token(username).unwrap_or_default();
160 let config = room_protocol::RoomConfig::dm(username, target_user);
161 let config_json = serde_json::to_string(&config)?;
162 let authed_config = crate::oneshot::transport::inject_token_into_config(&config_json, &token);
163 match crate::oneshot::transport::create_room(socket_path, dm_room_id, &authed_config).await {
164 Ok(_) => {}
165 Err(e) if e.to_string().contains("already exists") => {}
166 Err(e) => return Err(e),
167 }
168
169 let stream = UnixStream::connect(socket_path).await?;
171 let (read_half, mut write_half) = stream.into_split();
172 let handshake = format!("ROOM:{dm_room_id}:{username}\n");
173 write_half.write_all(handshake.as_bytes()).await?;
174
175 let (tx, rx) = mpsc::unbounded_channel::<Message>();
177 let username_owned = username.to_owned();
178 let reader = BufReader::new(read_half);
179
180 tokio::spawn(async move {
181 let mut reader = reader;
182 let mut history_buf: Vec<Message> = Vec::new();
183 let mut joined = false;
184 let mut line = String::new();
185
186 loop {
187 line.clear();
188 match reader.read_line(&mut line).await {
189 Ok(0) => break,
190 Ok(_) => {
191 let trimmed = line.trim();
192 if trimmed.is_empty() {
193 continue;
194 }
195 let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
196 continue;
197 };
198
199 if joined {
200 let _ = tx.send(msg);
201 } else {
202 let is_own_join =
203 matches!(&msg, Message::Join { user, .. } if user == &username_owned);
204 if is_own_join {
205 joined = true;
206 let start = history_buf.len().saturating_sub(history_lines);
207 for h in history_buf.drain(start..) {
208 let _ = tx.send(h);
209 }
210 let _ = tx.send(msg);
211 } else {
212 history_buf.push(msg);
213 }
214 }
215 }
216 Err(_) => break,
217 }
218 }
219 });
220
221 let who_payload = build_payload("/who");
223 write_half
224 .write_all(format!("{who_payload}\n").as_bytes())
225 .await?;
226
227 Ok(RoomTab {
228 room_id: dm_room_id.to_owned(),
229 messages: Vec::new(),
230 online_users: Vec::new(),
231 user_statuses: HashMap::new(),
232 subscription_tiers: HashMap::new(),
233 unread_count: 0,
234 scroll_offset: 0,
235 msg_rx: rx,
236 write_half,
237 })
238}
239
240fn switch_to_tab(
242 tabs: &mut [RoomTab],
243 active_tab: &mut usize,
244 input_state: &mut InputState,
245 idx: usize,
246) {
247 tabs[*active_tab].scroll_offset = input_state.scroll_offset;
248 *active_tab = idx;
249 tabs[*active_tab].unread_count = 0;
250 input_state.scroll_offset = tabs[*active_tab].scroll_offset;
251}
252
253async fn write_payload_to_tab(
255 write_half: &mut tokio::net::unix::OwnedWriteHalf,
256 payload: &str,
257) -> anyhow::Result<()> {
258 write_half
259 .write_all(format!("{payload}\n").as_bytes())
260 .await
261 .map_err(Into::into)
262}
263
264struct DmTabConfig<'a> {
267 socket_path: &'a std::path::Path,
268 username: &'a str,
269 history_lines: usize,
270}
271
272async fn handle_dm_action(
277 tabs: &mut Vec<RoomTab>,
278 active_tab: &mut usize,
279 input_state: &mut InputState,
280 cfg: &DmTabConfig<'_>,
281 target_user: String,
282 content: String,
283) -> anyhow::Result<()> {
284 let fallback = serde_json::json!({
285 "type": "dm",
286 "to": target_user,
287 "content": content
288 })
289 .to_string();
290
291 let Ok(dm_id) = room_protocol::dm_room_id(cfg.username, &target_user) else {
292 return write_payload_to_tab(&mut tabs[*active_tab].write_half, &fallback).await;
294 };
295
296 if let Some(idx) = tabs.iter().position(|t| t.room_id == dm_id) {
297 switch_to_tab(tabs, active_tab, input_state, idx);
299 return write_payload_to_tab(&mut tabs[*active_tab].write_half, &build_payload(&content))
300 .await;
301 }
302
303 match open_dm_tab(
305 cfg.socket_path,
306 &dm_id,
307 cfg.username,
308 &target_user,
309 cfg.history_lines,
310 )
311 .await
312 {
313 Ok(new_tab) => {
314 tabs.push(new_tab);
315 tabs[*active_tab].scroll_offset = input_state.scroll_offset;
316 *active_tab = tabs.len() - 1;
317 input_state.scroll_offset = 0;
318 write_payload_to_tab(&mut tabs[*active_tab].write_half, &build_payload(&content)).await
319 }
320 Err(_) => {
321 write_payload_to_tab(&mut tabs[*active_tab].write_half, &fallback).await
323 }
324 }
325}
326
327pub async fn run(
328 reader: BufReader<tokio::net::unix::OwnedReadHalf>,
329 write_half: tokio::net::unix::OwnedWriteHalf,
330 room_id: &str,
331 username: &str,
332 history_lines: usize,
333 socket_path: std::path::PathBuf,
334) -> anyhow::Result<()> {
335 let (msg_tx, msg_rx) = mpsc::unbounded_channel::<Message>();
336 let username_owned = username.to_owned();
337
338 tokio::spawn(async move {
341 let mut reader = reader;
342 let mut history_buf: Vec<Message> = Vec::new();
343 let mut joined = false;
344 let mut line = String::new();
345
346 loop {
347 line.clear();
348 match reader.read_line(&mut line).await {
349 Ok(0) => break,
350 Ok(_) => {
351 let trimmed = line.trim();
352 if trimmed.is_empty() {
353 continue;
354 }
355 let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
356 continue;
357 };
358
359 if joined {
360 let _ = msg_tx.send(msg);
361 } else {
362 let is_own_join =
363 matches!(&msg, Message::Join { user, .. } if user == &username_owned);
364 if is_own_join {
365 joined = true;
366 let start = history_buf.len().saturating_sub(history_lines);
368 for h in history_buf.drain(start..) {
369 let _ = msg_tx.send(h);
370 }
371 let _ = msg_tx.send(msg);
372 } else {
373 history_buf.push(msg);
374 }
375 }
376 }
377 Err(_) => break,
378 }
379 }
380 });
381
382 let tab = RoomTab {
383 room_id: room_id.to_owned(),
384 messages: Vec::new(),
385 online_users: Vec::new(),
386 user_statuses: HashMap::new(),
387 subscription_tiers: HashMap::new(),
388 unread_count: 0,
389 scroll_offset: 0,
390 msg_rx,
391 write_half,
392 };
393
394 #[cfg(unix)]
397 let saved_stderr_fd = redirect_stderr_to_log();
398
399 enable_raw_mode()?;
401 let mut stdout = io::stdout();
402 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
403 let backend = CrosstermBackend::new(stdout);
404 let mut terminal = Terminal::new(backend)?;
405
406 let mut tabs: Vec<RoomTab> = vec![tab];
407 let mut active_tab: usize = 0;
408 let mut color_map = ColorMap::new();
409 let mut input_state = InputState::new();
410 let mut result: anyhow::Result<()> = Ok(());
411 let mut frame_count: usize = 0;
412
413 let splash_seed = std::time::SystemTime::now()
415 .duration_since(std::time::UNIX_EPOCH)
416 .map(|d| {
417 d.as_secs()
418 .wrapping_mul(6364136223846793005)
419 .wrapping_add(d.subsec_nanos() as u64)
420 })
421 .unwrap_or(0xdeadbeef_cafebabe);
422
423 let who_payload = build_payload("/who");
426 tabs[active_tab]
427 .write_half
428 .write_all(format!("{who_payload}\n").as_bytes())
429 .await?;
430
431 'main: loop {
432 tabs[active_tab].scroll_offset = input_state.scroll_offset;
435
436 for (i, t) in tabs.iter_mut().enumerate() {
438 let is_active = i == active_tab;
439 if matches!(
440 t.drain_messages(&mut color_map, is_active),
441 DrainResult::Disconnected
442 ) && is_active
443 {
444 break 'main;
445 }
446 }
447
448 let show_tab_bar = tabs.len() > 1;
449
450 let term_area = terminal.size()?;
451 let input_content_width = term_area.width.saturating_sub(2) as usize;
453
454 let input_display_rows = wrap_input_display(&input_state.input, input_content_width);
456 let total_input_rows = input_display_rows.len();
457 let visible_input_lines = total_input_rows.min(MAX_INPUT_LINES);
458 let input_box_height = (visible_input_lines + 2) as u16;
460
461 let (cursor_row, cursor_col) = cursor_display_pos(
462 &input_state.input,
463 input_state.cursor_pos,
464 input_content_width,
465 );
466
467 if cursor_row < input_state.input_row_scroll {
469 input_state.input_row_scroll = cursor_row;
470 }
471 if visible_input_lines > 0
472 && cursor_row >= input_state.input_row_scroll + visible_input_lines
473 {
474 input_state.input_row_scroll = cursor_row + 1 - visible_input_lines;
475 }
476
477 let content_width = term_area.width.saturating_sub(2) as usize;
478
479 let constraints = if show_tab_bar {
481 vec![
482 Constraint::Length(1),
483 Constraint::Min(3),
484 Constraint::Length(input_box_height),
485 ]
486 } else {
487 vec![Constraint::Min(3), Constraint::Length(input_box_height)]
488 };
489
490 let msg_area_height = {
492 let chunks = Layout::default()
493 .direction(Direction::Vertical)
494 .constraints(constraints.clone())
495 .split(Rect::new(0, 0, term_area.width, term_area.height));
496 let msg_chunk = if show_tab_bar { chunks[1] } else { chunks[0] };
497 msg_chunk.height.saturating_sub(2) as usize
498 };
499
500 let msg_texts: Vec<Text<'static>> = tabs[active_tab]
501 .messages
502 .iter()
503 .map(|m| format_message(m, content_width, &color_map))
504 .collect();
505
506 let heights: Vec<usize> = msg_texts.iter().map(|t| t.lines.len().max(1)).collect();
507 let total_lines: usize = heights.iter().sum();
508
509 tabs[active_tab].scroll_offset = tabs[active_tab]
511 .scroll_offset
512 .min(total_lines.saturating_sub(msg_area_height));
513 input_state.scroll_offset = tabs[active_tab].scroll_offset;
515
516 let scroll_offset = tabs[active_tab].scroll_offset;
518 let room_id_display = tabs[active_tab].room_id.clone();
519 let online_users_ref = &tabs[active_tab].online_users;
520 let user_statuses_ref = &tabs[active_tab].user_statuses;
521 let subscription_tiers_ref = &tabs[active_tab].subscription_tiers;
522 let messages_ref = &tabs[active_tab].messages;
523
524 let tab_infos: Vec<TabInfo> = tabs
526 .iter()
527 .enumerate()
528 .map(|(i, t)| TabInfo {
529 room_id: t.room_id.clone(),
530 active: i == active_tab,
531 unread: t.unread_count,
532 })
533 .collect();
534
535 terminal.draw(|f| {
536 let chunks = Layout::default()
537 .direction(Direction::Vertical)
538 .constraints(constraints.clone())
539 .split(f.area());
540
541 let (tab_bar_chunk, msg_chunk, input_chunk) = if show_tab_bar {
542 (Some(chunks[0]), chunks[1], chunks[2])
543 } else {
544 (None, chunks[0], chunks[1])
545 };
546
547 if let Some(bar_area) = tab_bar_chunk {
549 if let Some(bar_line) = render_tab_bar(&tab_infos) {
550 let bar_widget =
551 Paragraph::new(bar_line).style(Style::default().bg(Color::Black));
552 f.render_widget(bar_widget, bar_area);
553 }
554 }
555
556 let view_bottom = total_lines.saturating_sub(scroll_offset);
557 let view_top = view_bottom.saturating_sub(msg_area_height);
558
559 let (start_msg_idx, skip_first) = find_view_start(&heights, view_top);
560
561 let visible: Vec<ListItem> = msg_texts[start_msg_idx..]
562 .iter()
563 .enumerate()
564 .map(|(i, text)| {
565 if i == 0 && skip_first > 0 {
566 ListItem::new(Text::from(text.lines[skip_first..].to_vec()))
567 } else {
568 ListItem::new(text.clone())
569 }
570 })
571 .collect();
572
573 let title = if scroll_offset > 0 {
574 format!(" {} [↑ {} lines] ", room_id_display, scroll_offset)
575 } else {
576 format!(" {} ", room_id_display)
577 };
578
579 let has_chat = messages_ref.iter().any(|m| {
581 matches!(
582 m,
583 Message::Message { .. }
584 | Message::Reply { .. }
585 | Message::Command { .. }
586 | Message::DirectMessage { .. }
587 )
588 });
589
590 let version_title =
591 Line::from(format!(" v{} ", env!("CARGO_PKG_VERSION"))).alignment(Alignment::Right);
592
593 if !has_chat {
594 let splash_width = msg_chunk.width.saturating_sub(2) as usize;
595 let splash_height = msg_chunk.height.saturating_sub(2) as usize;
596 let splash = welcome_splash(frame_count, splash_width, splash_height, splash_seed);
597 let splash_widget = Paragraph::new(splash)
598 .block(
599 Block::default()
600 .title(title.clone())
601 .title_top(version_title)
602 .borders(Borders::ALL)
603 .border_style(Style::default().fg(Color::DarkGray)),
604 )
605 .alignment(Alignment::Left);
606 f.render_widget(splash_widget, msg_chunk);
607 } else {
608 let msg_list = List::new(visible).block(
609 Block::default()
610 .title(title)
611 .title_top(version_title)
612 .borders(Borders::ALL)
613 .border_style(Style::default().fg(Color::DarkGray)),
614 );
615 f.render_widget(msg_list, msg_chunk);
616 }
617
618 let end = (input_state.input_row_scroll + visible_input_lines).min(total_input_rows);
620 let display_text = input_display_rows[input_state.input_row_scroll..end].join("\n");
621
622 let input_widget = Paragraph::new(display_text)
623 .block(
624 Block::default()
625 .title(format!(" {username} "))
626 .borders(Borders::ALL)
627 .border_style(Style::default().fg(Color::Cyan)),
628 )
629 .style(Style::default().fg(Color::White));
630 f.render_widget(input_widget, input_chunk);
631
632 let visible_cursor_row = cursor_row - input_state.input_row_scroll;
634 let cursor_x = input_chunk.x + 1 + cursor_col as u16;
635 let cursor_y = input_chunk.y + 1 + visible_cursor_row as u16;
636 f.set_cursor_position((cursor_x, cursor_y));
637
638 const PANEL_MIN_TERM_WIDTH: u16 = 80;
641 if f.area().width >= PANEL_MIN_TERM_WIDTH && !online_users_ref.is_empty() {
642 let panel_items: Vec<ListItem> = online_users_ref
643 .iter()
644 .map(|u| {
645 let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
646 let tier = subscription_tiers_ref.get(u).copied();
647 let mut spans = vec![Span::styled(
648 format!(" {u}"),
649 Style::default()
650 .fg(user_color(u, &color_map))
651 .add_modifier(Modifier::BOLD),
652 )];
653 match tier {
654 Some(SubscriptionTier::MentionsOnly) => {
655 spans.push(Span::styled(" @", Style::default().fg(Color::Yellow)));
656 }
657 Some(SubscriptionTier::Unsubscribed) => {
658 spans.push(Span::styled(
659 " \u{2717}",
660 Style::default().fg(Color::Red),
661 ));
662 }
663 _ => {}
664 }
665 if !status.is_empty() {
666 spans.push(Span::styled(
667 format!(" {status}"),
668 Style::default().fg(Color::DarkGray),
669 ));
670 }
671 spans.push(Span::raw(" "));
672 ListItem::new(Line::from(spans))
673 })
674 .collect();
675
676 let panel_content_width = online_users_ref
677 .iter()
678 .map(|u| {
679 let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
680 let status_len = if status.is_empty() {
681 0
682 } else {
683 status.len() + 2 };
685 let tier = subscription_tiers_ref.get(u).copied();
686 let tier_len = match tier {
687 Some(SubscriptionTier::MentionsOnly)
688 | Some(SubscriptionTier::Unsubscribed) => 2, _ => 0,
690 };
691 u.len() + 1 + tier_len + status_len + 1 })
693 .max()
694 .unwrap_or(10);
695 let panel_width = (panel_content_width as u16 + 2)
696 .min(msg_chunk.width / 3)
697 .max(12);
698 let panel_height =
699 (online_users_ref.len() as u16 + 2).min(msg_chunk.height.saturating_sub(1));
700
701 let panel_x = msg_chunk.x + msg_chunk.width - panel_width - 1;
702 let panel_y = msg_chunk.y + 1;
703
704 let panel_rect = Rect {
705 x: panel_x,
706 y: panel_y,
707 width: panel_width,
708 height: panel_height,
709 };
710
711 f.render_widget(Clear, panel_rect);
712 let panel = List::new(panel_items).block(
713 Block::default()
714 .title(" members ")
715 .borders(Borders::ALL)
716 .border_style(Style::default().fg(Color::DarkGray)),
717 );
718 f.render_widget(panel, panel_rect);
719 }
720
721 if input_state.palette.active && !input_state.palette.filtered.is_empty() {
723 let palette_items: Vec<ListItem> = input_state
724 .palette
725 .filtered
726 .iter()
727 .enumerate()
728 .map(|(row, &idx)| {
729 let item = &input_state.palette.commands[idx];
730 let style = if row == input_state.palette.selected {
731 Style::default()
732 .fg(Color::Black)
733 .bg(Color::Cyan)
734 .add_modifier(Modifier::BOLD)
735 } else {
736 Style::default().fg(Color::White)
737 };
738 ListItem::new(Line::from(vec![
739 Span::styled(
740 format!("{:<16}", item.usage),
741 style.add_modifier(Modifier::BOLD),
742 ),
743 Span::styled(
744 format!(" {}", item.description),
745 if row == input_state.palette.selected {
746 Style::default().fg(Color::Black).bg(Color::Cyan)
747 } else {
748 Style::default().fg(Color::DarkGray)
749 },
750 ),
751 ]))
752 })
753 .collect();
754
755 let popup_height =
756 (input_state.palette.filtered.len() as u16 + 2).min(msg_chunk.height);
757 let popup_y = input_chunk.y.saturating_sub(popup_height);
758 let popup_rect = Rect {
759 x: input_chunk.x,
760 y: popup_y,
761 width: input_chunk.width,
762 height: popup_height,
763 };
764
765 f.render_widget(Clear, popup_rect);
766 let palette_list = List::new(palette_items).block(
767 Block::default()
768 .title(" commands ")
769 .borders(Borders::ALL)
770 .border_style(Style::default().fg(Color::Cyan)),
771 );
772 f.render_widget(palette_list, popup_rect);
773 }
774
775 if input_state.mention.active && !input_state.mention.filtered.is_empty() {
777 let mention_items: Vec<ListItem> = input_state
778 .mention
779 .filtered
780 .iter()
781 .enumerate()
782 .map(|(row, user)| {
783 let style = if row == input_state.mention.selected {
784 Style::default()
785 .fg(Color::Black)
786 .bg(user_color(user, &color_map))
787 .add_modifier(Modifier::BOLD)
788 } else {
789 Style::default().fg(user_color(user, &color_map))
790 };
791 ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
792 })
793 .collect();
794
795 let popup_height =
796 (input_state.mention.filtered.len() as u16 + 2).min(msg_chunk.height);
797 let popup_y = input_chunk.y.saturating_sub(popup_height);
798 let max_width = input_state
799 .mention
800 .filtered
801 .iter()
802 .map(|u| u.len() + 1) .max()
804 .unwrap_or(8) as u16
805 + 4; let popup_width = max_width.min(input_chunk.width / 2).max(8);
807 let popup_x = cursor_x
808 .saturating_sub(1)
809 .min(input_chunk.x + input_chunk.width.saturating_sub(popup_width));
810 let popup_rect = Rect {
811 x: popup_x,
812 y: popup_y,
813 width: popup_width,
814 height: popup_height,
815 };
816
817 f.render_widget(Clear, popup_rect);
818 let mention_list = List::new(mention_items).block(
819 Block::default()
820 .title(" @ ")
821 .borders(Borders::ALL)
822 .border_style(Style::default().fg(Color::Yellow)),
823 );
824 f.render_widget(mention_list, popup_rect);
825 }
826 })?;
827
828 if event::poll(std::time::Duration::from_millis(50))? {
829 match event::read()? {
830 Event::Key(key) => {
831 let online_users = &tabs[active_tab].online_users;
832 match handle_key(
833 key,
834 &mut input_state,
835 online_users,
836 msg_area_height,
837 input_content_width,
838 ) {
839 Some(Action::Send(payload)) => {
840 if let Err(e) = tabs[active_tab]
841 .write_half
842 .write_all(format!("{payload}\n").as_bytes())
843 .await
844 {
845 result = Err(e.into());
846 break 'main;
847 }
848 }
849 Some(Action::Quit) => break 'main,
850 Some(Action::NextTab) => {
851 if tabs.len() > 1 {
852 let next = (active_tab + 1) % tabs.len();
853 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, next);
854 }
855 }
856 Some(Action::PrevTab) => {
857 if tabs.len() > 1 {
858 let prev = if active_tab == 0 {
859 tabs.len() - 1
860 } else {
861 active_tab - 1
862 };
863 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, prev);
864 }
865 }
866 Some(Action::SwitchTab(idx)) => {
867 if idx < tabs.len() {
868 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, idx);
869 }
870 }
871 Some(Action::DmRoom {
872 target_user,
873 content,
874 }) => {
875 let cfg = DmTabConfig {
876 socket_path: &socket_path,
877 username,
878 history_lines,
879 };
880 if let Err(e) = handle_dm_action(
881 &mut tabs,
882 &mut active_tab,
883 &mut input_state,
884 &cfg,
885 target_user,
886 content,
887 )
888 .await
889 {
890 result = Err(e);
891 break 'main;
892 }
893 }
894 None => {}
895 }
896 }
897 Event::Paste(text) => {
898 let clean = text.replace("\r\n", "\n").replace('\r', "\n");
900 input_state.input.insert_str(input_state.cursor_pos, &clean);
901 input_state.cursor_pos += clean.len();
902 input_state.mention.active = false;
903 }
904 Event::Resize(_, _) => {}
905 _ => {}
906 }
907 }
908
909 for (i, t) in tabs.iter_mut().enumerate() {
911 let is_active = i == active_tab;
912 if matches!(
913 t.drain_messages(&mut color_map, is_active),
914 DrainResult::Disconnected
915 ) && is_active
916 {
917 break 'main;
918 }
919 }
920
921 frame_count = frame_count.wrapping_add(1);
922 }
923
924 disable_raw_mode()?;
925 execute!(
926 terminal.backend_mut(),
927 DisableBracketedPaste,
928 LeaveAlternateScreen
929 )?;
930 terminal.show_cursor()?;
931
932 #[cfg(unix)]
934 restore_stderr(saved_stderr_fd);
935
936 result
937}
938
939#[cfg(unix)]
945fn redirect_stderr_to_log() -> Option<i32> {
946 let log_path = crate::paths::room_home().join("room.log");
947
948 let file = match std::fs::OpenOptions::new()
949 .create(true)
950 .append(true)
951 .open(&log_path)
952 {
953 Ok(f) => f,
954 Err(_) => return None,
955 };
956
957 let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
959 if saved < 0 {
960 return None;
961 }
962
963 let log_fd = file.as_raw_fd();
964 if unsafe { libc::dup2(log_fd, libc::STDERR_FILENO) } < 0 {
965 unsafe { libc::close(saved) };
966 return None;
967 }
968
969 Some(saved)
970}
971
972#[cfg(unix)]
974fn restore_stderr(saved: Option<i32>) {
975 if let Some(fd) = saved {
976 unsafe {
977 libc::dup2(fd, libc::STDERR_FILENO);
978 libc::close(fd);
979 }
980 }
981}
982
983#[cfg(test)]
986mod tests {
987 use super::*;
988 use chrono::Utc;
989
990 fn make_msg(user: &str, content: &str) -> Message {
991 Message::Message {
992 id: "test-id".into(),
993 room: "test-room".into(),
994 user: user.into(),
995 ts: Utc::now(),
996 content: content.into(),
997 seq: None,
998 }
999 }
1000
1001 fn make_join(user: &str) -> Message {
1002 Message::Join {
1003 id: "test-id".into(),
1004 room: "test-room".into(),
1005 user: user.into(),
1006 ts: Utc::now(),
1007 seq: None,
1008 }
1009 }
1010
1011 fn make_leave(user: &str) -> Message {
1012 Message::Leave {
1013 id: "test-id".into(),
1014 room: "test-room".into(),
1015 user: user.into(),
1016 ts: Utc::now(),
1017 seq: None,
1018 }
1019 }
1020
1021 fn make_system(content: &str) -> Message {
1022 Message::System {
1023 id: "test-id".into(),
1024 room: "test-room".into(),
1025 user: "broker".into(),
1026 ts: Utc::now(),
1027 content: content.into(),
1028 seq: None,
1029 }
1030 }
1031
1032 #[tokio::test]
1035 async fn process_message_adds_user_on_join() {
1036 let (_, rx) = mpsc::unbounded_channel();
1037 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1038 let mut tab = RoomTab {
1039 room_id: "test".into(),
1040 messages: Vec::new(),
1041 online_users: Vec::new(),
1042 user_statuses: HashMap::new(),
1043 subscription_tiers: HashMap::new(),
1044 unread_count: 0,
1045 scroll_offset: 0,
1046 msg_rx: rx,
1047 write_half: wh,
1048 };
1049 let mut cm = ColorMap::new();
1050
1051 tab.process_message(make_join("alice"), &mut cm, true);
1052 assert_eq!(tab.online_users, vec!["alice"]);
1053 assert_eq!(tab.messages.len(), 1);
1054 }
1055
1056 #[tokio::test]
1057 async fn process_message_removes_user_on_leave() {
1058 let (_, rx) = mpsc::unbounded_channel();
1059 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1060 let mut tab = RoomTab {
1061 room_id: "test".into(),
1062 messages: Vec::new(),
1063 online_users: vec!["alice".into()],
1064 user_statuses: HashMap::new(),
1065 subscription_tiers: HashMap::new(),
1066 unread_count: 0,
1067 scroll_offset: 0,
1068 msg_rx: rx,
1069 write_half: wh,
1070 };
1071 let mut cm = ColorMap::new();
1072
1073 tab.process_message(make_leave("alice"), &mut cm, true);
1074 assert!(tab.online_users.is_empty());
1075 }
1076
1077 #[tokio::test]
1078 async fn process_message_increments_unread_when_inactive() {
1079 let (_, rx) = mpsc::unbounded_channel();
1080 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1081 let mut tab = RoomTab {
1082 room_id: "test".into(),
1083 messages: Vec::new(),
1084 online_users: Vec::new(),
1085 user_statuses: HashMap::new(),
1086 subscription_tiers: HashMap::new(),
1087 unread_count: 0,
1088 scroll_offset: 0,
1089 msg_rx: rx,
1090 write_half: wh,
1091 };
1092 let mut cm = ColorMap::new();
1093
1094 tab.process_message(make_msg("bob", "hello"), &mut cm, false);
1095 assert_eq!(tab.unread_count, 1);
1096
1097 tab.process_message(make_msg("bob", "world"), &mut cm, false);
1098 assert_eq!(tab.unread_count, 2);
1099 }
1100
1101 #[tokio::test]
1102 async fn process_message_no_unread_when_active() {
1103 let (_, rx) = mpsc::unbounded_channel();
1104 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1105 let mut tab = RoomTab {
1106 room_id: "test".into(),
1107 messages: Vec::new(),
1108 online_users: Vec::new(),
1109 user_statuses: HashMap::new(),
1110 subscription_tiers: HashMap::new(),
1111 unread_count: 0,
1112 scroll_offset: 0,
1113 msg_rx: rx,
1114 write_half: wh,
1115 };
1116 let mut cm = ColorMap::new();
1117
1118 tab.process_message(make_msg("bob", "hello"), &mut cm, true);
1119 assert_eq!(tab.unread_count, 0);
1120 }
1121
1122 #[tokio::test]
1123 async fn process_message_seeds_user_from_message_sender() {
1124 let (_, rx) = mpsc::unbounded_channel();
1125 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1126 let mut tab = RoomTab {
1127 room_id: "test".into(),
1128 messages: Vec::new(),
1129 online_users: Vec::new(),
1130 user_statuses: HashMap::new(),
1131 subscription_tiers: HashMap::new(),
1132 unread_count: 0,
1133 scroll_offset: 0,
1134 msg_rx: rx,
1135 write_half: wh,
1136 };
1137 let mut cm = ColorMap::new();
1138
1139 tab.process_message(make_msg("charlie", "hi"), &mut cm, true);
1140 assert_eq!(tab.online_users, vec!["charlie"]);
1141 assert!(cm.contains_key("charlie"));
1142 }
1143
1144 #[tokio::test]
1145 async fn process_message_does_not_duplicate_existing_user() {
1146 let (_, rx) = mpsc::unbounded_channel();
1147 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1148 let mut tab = RoomTab {
1149 room_id: "test".into(),
1150 messages: Vec::new(),
1151 online_users: vec!["alice".into()],
1152 user_statuses: HashMap::new(),
1153 subscription_tiers: HashMap::new(),
1154 unread_count: 0,
1155 scroll_offset: 0,
1156 msg_rx: rx,
1157 write_half: wh,
1158 };
1159 let mut cm = ColorMap::new();
1160
1161 tab.process_message(make_msg("alice", "hi"), &mut cm, true);
1162 assert_eq!(tab.online_users.len(), 1);
1163 }
1164
1165 #[tokio::test]
1168 async fn drain_messages_processes_pending() {
1169 let (tx, rx) = mpsc::unbounded_channel();
1170 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1171 let mut tab = RoomTab {
1172 room_id: "test".into(),
1173 messages: Vec::new(),
1174 online_users: Vec::new(),
1175 user_statuses: HashMap::new(),
1176 subscription_tiers: HashMap::new(),
1177 unread_count: 0,
1178 scroll_offset: 0,
1179 msg_rx: rx,
1180 write_half: wh,
1181 };
1182 let mut cm = ColorMap::new();
1183
1184 tx.send(make_msg("bob", "one")).unwrap();
1185 tx.send(make_msg("bob", "two")).unwrap();
1186
1187 let result = tab.drain_messages(&mut cm, true);
1188 assert!(matches!(result, DrainResult::Ok));
1189 assert_eq!(tab.messages.len(), 2);
1190 }
1191
1192 #[tokio::test]
1193 async fn drain_messages_detects_disconnect() {
1194 let (tx, rx) = mpsc::unbounded_channel();
1195 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1196 let mut tab = RoomTab {
1197 room_id: "test".into(),
1198 messages: Vec::new(),
1199 online_users: Vec::new(),
1200 user_statuses: HashMap::new(),
1201 subscription_tiers: HashMap::new(),
1202 unread_count: 0,
1203 scroll_offset: 0,
1204 msg_rx: rx,
1205 write_half: wh,
1206 };
1207 let mut cm = ColorMap::new();
1208
1209 drop(tx);
1210 let result = tab.drain_messages(&mut cm, true);
1211 assert!(matches!(result, DrainResult::Disconnected));
1212 }
1213
1214 #[tokio::test]
1215 async fn drain_messages_empty_returns_ok() {
1216 let (_tx, rx) = mpsc::unbounded_channel::<Message>();
1217 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1218 let mut tab = RoomTab {
1219 room_id: "test".into(),
1220 messages: Vec::new(),
1221 online_users: Vec::new(),
1222 user_statuses: HashMap::new(),
1223 subscription_tiers: HashMap::new(),
1224 unread_count: 0,
1225 scroll_offset: 0,
1226 msg_rx: rx,
1227 write_half: wh,
1228 };
1229 let mut cm = ColorMap::new();
1230
1231 let result = tab.drain_messages(&mut cm, true);
1232 assert!(matches!(result, DrainResult::Ok));
1233 assert!(tab.messages.is_empty());
1234 }
1235
1236 #[tokio::test]
1237 async fn process_system_message_parses_status() {
1238 let (_, rx) = mpsc::unbounded_channel();
1239 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1240 let mut tab = RoomTab {
1241 room_id: "test".into(),
1242 messages: Vec::new(),
1243 online_users: vec!["alice".into()],
1244 user_statuses: HashMap::new(),
1245 subscription_tiers: HashMap::new(),
1246 unread_count: 0,
1247 scroll_offset: 0,
1248 msg_rx: rx,
1249 write_half: wh,
1250 };
1251 let mut cm = ColorMap::new();
1252
1253 tab.process_message(make_system("alice set status: coding"), &mut cm, true);
1254 assert_eq!(tab.user_statuses.get("alice").unwrap(), "coding");
1255 }
1256
1257 #[tokio::test]
1258 async fn process_subscription_broadcast_sets_tier() {
1259 let (_, rx) = mpsc::unbounded_channel();
1260 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1261 let mut tab = RoomTab {
1262 room_id: "test".into(),
1263 messages: Vec::new(),
1264 online_users: vec!["alice".into()],
1265 user_statuses: HashMap::new(),
1266 subscription_tiers: HashMap::new(),
1267 unread_count: 0,
1268 scroll_offset: 0,
1269 msg_rx: rx,
1270 write_half: wh,
1271 };
1272 let mut cm = ColorMap::new();
1273
1274 tab.process_message(
1275 make_system("alice subscribed to test (tier: mentions_only)"),
1276 &mut cm,
1277 true,
1278 );
1279 assert_eq!(
1280 tab.subscription_tiers.get("alice").copied(),
1281 Some(SubscriptionTier::MentionsOnly),
1282 );
1283
1284 tab.process_message(
1286 make_system("alice subscribed to test (tier: full)"),
1287 &mut cm,
1288 true,
1289 );
1290 assert_eq!(
1291 tab.subscription_tiers.get("alice").copied(),
1292 Some(SubscriptionTier::Full),
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn process_leave_clears_subscription_tier() {
1298 let (_, rx) = mpsc::unbounded_channel();
1299 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1300 let mut tab = RoomTab {
1301 room_id: "test".into(),
1302 messages: Vec::new(),
1303 online_users: vec!["alice".into()],
1304 user_statuses: HashMap::new(),
1305 subscription_tiers: HashMap::from([(
1306 "alice".to_owned(),
1307 SubscriptionTier::MentionsOnly,
1308 )]),
1309 unread_count: 0,
1310 scroll_offset: 0,
1311 msg_rx: rx,
1312 write_half: wh,
1313 };
1314 let mut cm = ColorMap::new();
1315
1316 tab.process_message(make_leave("alice"), &mut cm, true);
1317 assert!(tab.subscription_tiers.get("alice").is_none());
1318 }
1319}