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, normalize_paste, parse_kick_broadcast,
35 parse_status_broadcast, parse_subscription_broadcast, seed_online_users_from_who,
36 wrap_input_display, Action, InputState,
37};
38use render::{
39 assign_color, build_member_panel_spans, find_view_start, format_message,
40 member_panel_row_width, render_tab_bar, user_color, welcome_splash, 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 spans = build_member_panel_spans(u, status, tier, &color_map);
648 ListItem::new(Line::from(spans))
649 })
650 .collect();
651
652 let panel_content_width = online_users_ref
653 .iter()
654 .map(|u| {
655 let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
656 let tier = subscription_tiers_ref.get(u).copied();
657 member_panel_row_width(u, status, tier)
658 })
659 .max()
660 .unwrap_or(10);
661 let panel_width = (panel_content_width as u16 + 2)
662 .min(msg_chunk.width / 3)
663 .max(12);
664 let panel_height =
665 (online_users_ref.len() as u16 + 2).min(msg_chunk.height.saturating_sub(1));
666
667 let panel_x = msg_chunk.x + msg_chunk.width - panel_width - 1;
668 let panel_y = msg_chunk.y + 1;
669
670 let panel_rect = Rect {
671 x: panel_x,
672 y: panel_y,
673 width: panel_width,
674 height: panel_height,
675 };
676
677 f.render_widget(Clear, panel_rect);
678 let panel = List::new(panel_items).block(
679 Block::default()
680 .title(" members ")
681 .borders(Borders::ALL)
682 .border_style(Style::default().fg(Color::DarkGray)),
683 );
684 f.render_widget(panel, panel_rect);
685 }
686
687 if input_state.palette.active && !input_state.palette.filtered.is_empty() {
689 let palette_items: Vec<ListItem> = input_state
690 .palette
691 .filtered
692 .iter()
693 .enumerate()
694 .map(|(row, &idx)| {
695 let item = &input_state.palette.commands[idx];
696 let style = if row == input_state.palette.selected {
697 Style::default()
698 .fg(Color::Black)
699 .bg(Color::Cyan)
700 .add_modifier(Modifier::BOLD)
701 } else {
702 Style::default().fg(Color::White)
703 };
704 ListItem::new(Line::from(vec![
705 Span::styled(
706 format!("{:<16}", item.usage),
707 style.add_modifier(Modifier::BOLD),
708 ),
709 Span::styled(
710 format!(" {}", item.description),
711 if row == input_state.palette.selected {
712 Style::default().fg(Color::Black).bg(Color::Cyan)
713 } else {
714 Style::default().fg(Color::DarkGray)
715 },
716 ),
717 ]))
718 })
719 .collect();
720
721 let popup_height =
722 (input_state.palette.filtered.len() as u16 + 2).min(msg_chunk.height);
723 let popup_y = input_chunk.y.saturating_sub(popup_height);
724 let popup_rect = Rect {
725 x: input_chunk.x,
726 y: popup_y,
727 width: input_chunk.width,
728 height: popup_height,
729 };
730
731 f.render_widget(Clear, popup_rect);
732 let palette_list = List::new(palette_items).block(
733 Block::default()
734 .title(" commands ")
735 .borders(Borders::ALL)
736 .border_style(Style::default().fg(Color::Cyan)),
737 );
738 f.render_widget(palette_list, popup_rect);
739 }
740
741 if input_state.mention.active && !input_state.mention.filtered.is_empty() {
743 let mention_items: Vec<ListItem> = input_state
744 .mention
745 .filtered
746 .iter()
747 .enumerate()
748 .map(|(row, user)| {
749 let style = if row == input_state.mention.selected {
750 Style::default()
751 .fg(Color::Black)
752 .bg(user_color(user, &color_map))
753 .add_modifier(Modifier::BOLD)
754 } else {
755 Style::default().fg(user_color(user, &color_map))
756 };
757 ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
758 })
759 .collect();
760
761 let popup_height =
762 (input_state.mention.filtered.len() as u16 + 2).min(msg_chunk.height);
763 let popup_y = input_chunk.y.saturating_sub(popup_height);
764 let max_width = input_state
765 .mention
766 .filtered
767 .iter()
768 .map(|u| u.len() + 1) .max()
770 .unwrap_or(8) as u16
771 + 4; let popup_width = max_width.min(input_chunk.width / 2).max(8);
773 let popup_x = cursor_x
774 .saturating_sub(1)
775 .min(input_chunk.x + input_chunk.width.saturating_sub(popup_width));
776 let popup_rect = Rect {
777 x: popup_x,
778 y: popup_y,
779 width: popup_width,
780 height: popup_height,
781 };
782
783 f.render_widget(Clear, popup_rect);
784 let mention_list = List::new(mention_items).block(
785 Block::default()
786 .title(" @ ")
787 .borders(Borders::ALL)
788 .border_style(Style::default().fg(Color::Yellow)),
789 );
790 f.render_widget(mention_list, popup_rect);
791 }
792 })?;
793
794 if event::poll(std::time::Duration::from_millis(50))? {
795 match event::read()? {
796 Event::Key(key) => {
797 let online_users = &tabs[active_tab].online_users;
798 match handle_key(
799 key,
800 &mut input_state,
801 online_users,
802 msg_area_height,
803 input_content_width,
804 ) {
805 Some(Action::Send(payload)) => {
806 if let Err(e) = tabs[active_tab]
807 .write_half
808 .write_all(format!("{payload}\n").as_bytes())
809 .await
810 {
811 result = Err(e.into());
812 break 'main;
813 }
814 }
815 Some(Action::Quit) => break 'main,
816 Some(Action::NextTab) => {
817 if tabs.len() > 1 {
818 let next = (active_tab + 1) % tabs.len();
819 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, next);
820 }
821 }
822 Some(Action::PrevTab) => {
823 if tabs.len() > 1 {
824 let prev = if active_tab == 0 {
825 tabs.len() - 1
826 } else {
827 active_tab - 1
828 };
829 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, prev);
830 }
831 }
832 Some(Action::SwitchTab(idx)) => {
833 if idx < tabs.len() {
834 switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, idx);
835 }
836 }
837 Some(Action::DmRoom {
838 target_user,
839 content,
840 }) => {
841 let cfg = DmTabConfig {
842 socket_path: &socket_path,
843 username,
844 history_lines,
845 };
846 if let Err(e) = handle_dm_action(
847 &mut tabs,
848 &mut active_tab,
849 &mut input_state,
850 &cfg,
851 target_user,
852 content,
853 )
854 .await
855 {
856 result = Err(e);
857 break 'main;
858 }
859 }
860 None => {}
861 }
862 }
863 Event::Paste(text) => {
864 let clean = normalize_paste(&text);
865 input_state.input.insert_str(input_state.cursor_pos, &clean);
866 input_state.cursor_pos += clean.len();
867 input_state.mention.active = false;
868 }
869 Event::Resize(_, _) => {}
870 _ => {}
871 }
872 }
873
874 for (i, t) in tabs.iter_mut().enumerate() {
876 let is_active = i == active_tab;
877 if matches!(
878 t.drain_messages(&mut color_map, is_active),
879 DrainResult::Disconnected
880 ) && is_active
881 {
882 break 'main;
883 }
884 }
885
886 frame_count = frame_count.wrapping_add(1);
887 }
888
889 disable_raw_mode()?;
890 execute!(
891 terminal.backend_mut(),
892 DisableBracketedPaste,
893 LeaveAlternateScreen
894 )?;
895 terminal.show_cursor()?;
896
897 #[cfg(unix)]
899 restore_stderr(saved_stderr_fd);
900
901 result
902}
903
904#[cfg(unix)]
910fn redirect_stderr_to_log() -> Option<i32> {
911 let log_path = crate::paths::room_home().join("room.log");
912
913 let file = match std::fs::OpenOptions::new()
914 .create(true)
915 .append(true)
916 .open(&log_path)
917 {
918 Ok(f) => f,
919 Err(_) => return None,
920 };
921
922 let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
924 if saved < 0 {
925 return None;
926 }
927
928 let log_fd = file.as_raw_fd();
929 if unsafe { libc::dup2(log_fd, libc::STDERR_FILENO) } < 0 {
930 unsafe { libc::close(saved) };
931 return None;
932 }
933
934 Some(saved)
935}
936
937#[cfg(unix)]
939fn restore_stderr(saved: Option<i32>) {
940 if let Some(fd) = saved {
941 unsafe {
942 libc::dup2(fd, libc::STDERR_FILENO);
943 libc::close(fd);
944 }
945 }
946}
947
948#[cfg(test)]
951mod tests {
952 use super::*;
953 use chrono::Utc;
954
955 fn make_msg(user: &str, content: &str) -> Message {
956 Message::Message {
957 id: "test-id".into(),
958 room: "test-room".into(),
959 user: user.into(),
960 ts: Utc::now(),
961 content: content.into(),
962 seq: None,
963 }
964 }
965
966 fn make_join(user: &str) -> Message {
967 Message::Join {
968 id: "test-id".into(),
969 room: "test-room".into(),
970 user: user.into(),
971 ts: Utc::now(),
972 seq: None,
973 }
974 }
975
976 fn make_leave(user: &str) -> Message {
977 Message::Leave {
978 id: "test-id".into(),
979 room: "test-room".into(),
980 user: user.into(),
981 ts: Utc::now(),
982 seq: None,
983 }
984 }
985
986 fn make_system(content: &str) -> Message {
987 Message::System {
988 id: "test-id".into(),
989 room: "test-room".into(),
990 user: "broker".into(),
991 ts: Utc::now(),
992 content: content.into(),
993 seq: None,
994 }
995 }
996
997 #[tokio::test]
1000 async fn process_message_adds_user_on_join() {
1001 let (_, rx) = mpsc::unbounded_channel();
1002 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1003 let mut tab = RoomTab {
1004 room_id: "test".into(),
1005 messages: Vec::new(),
1006 online_users: Vec::new(),
1007 user_statuses: HashMap::new(),
1008 subscription_tiers: HashMap::new(),
1009 unread_count: 0,
1010 scroll_offset: 0,
1011 msg_rx: rx,
1012 write_half: wh,
1013 };
1014 let mut cm = ColorMap::new();
1015
1016 tab.process_message(make_join("alice"), &mut cm, true);
1017 assert_eq!(tab.online_users, vec!["alice"]);
1018 assert_eq!(tab.messages.len(), 1);
1019 }
1020
1021 #[tokio::test]
1022 async fn process_message_removes_user_on_leave() {
1023 let (_, rx) = mpsc::unbounded_channel();
1024 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1025 let mut tab = RoomTab {
1026 room_id: "test".into(),
1027 messages: Vec::new(),
1028 online_users: vec!["alice".into()],
1029 user_statuses: HashMap::new(),
1030 subscription_tiers: HashMap::new(),
1031 unread_count: 0,
1032 scroll_offset: 0,
1033 msg_rx: rx,
1034 write_half: wh,
1035 };
1036 let mut cm = ColorMap::new();
1037
1038 tab.process_message(make_leave("alice"), &mut cm, true);
1039 assert!(tab.online_users.is_empty());
1040 }
1041
1042 #[tokio::test]
1043 async fn process_message_increments_unread_when_inactive() {
1044 let (_, rx) = mpsc::unbounded_channel();
1045 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1046 let mut tab = RoomTab {
1047 room_id: "test".into(),
1048 messages: Vec::new(),
1049 online_users: Vec::new(),
1050 user_statuses: HashMap::new(),
1051 subscription_tiers: HashMap::new(),
1052 unread_count: 0,
1053 scroll_offset: 0,
1054 msg_rx: rx,
1055 write_half: wh,
1056 };
1057 let mut cm = ColorMap::new();
1058
1059 tab.process_message(make_msg("bob", "hello"), &mut cm, false);
1060 assert_eq!(tab.unread_count, 1);
1061
1062 tab.process_message(make_msg("bob", "world"), &mut cm, false);
1063 assert_eq!(tab.unread_count, 2);
1064 }
1065
1066 #[tokio::test]
1067 async fn process_message_no_unread_when_active() {
1068 let (_, rx) = mpsc::unbounded_channel();
1069 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1070 let mut tab = RoomTab {
1071 room_id: "test".into(),
1072 messages: Vec::new(),
1073 online_users: Vec::new(),
1074 user_statuses: HashMap::new(),
1075 subscription_tiers: HashMap::new(),
1076 unread_count: 0,
1077 scroll_offset: 0,
1078 msg_rx: rx,
1079 write_half: wh,
1080 };
1081 let mut cm = ColorMap::new();
1082
1083 tab.process_message(make_msg("bob", "hello"), &mut cm, true);
1084 assert_eq!(tab.unread_count, 0);
1085 }
1086
1087 #[tokio::test]
1088 async fn process_message_seeds_user_from_message_sender() {
1089 let (_, rx) = mpsc::unbounded_channel();
1090 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1091 let mut tab = RoomTab {
1092 room_id: "test".into(),
1093 messages: Vec::new(),
1094 online_users: Vec::new(),
1095 user_statuses: HashMap::new(),
1096 subscription_tiers: HashMap::new(),
1097 unread_count: 0,
1098 scroll_offset: 0,
1099 msg_rx: rx,
1100 write_half: wh,
1101 };
1102 let mut cm = ColorMap::new();
1103
1104 tab.process_message(make_msg("charlie", "hi"), &mut cm, true);
1105 assert_eq!(tab.online_users, vec!["charlie"]);
1106 assert!(cm.contains_key("charlie"));
1107 }
1108
1109 #[tokio::test]
1110 async fn process_message_does_not_duplicate_existing_user() {
1111 let (_, rx) = mpsc::unbounded_channel();
1112 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1113 let mut tab = RoomTab {
1114 room_id: "test".into(),
1115 messages: Vec::new(),
1116 online_users: vec!["alice".into()],
1117 user_statuses: HashMap::new(),
1118 subscription_tiers: HashMap::new(),
1119 unread_count: 0,
1120 scroll_offset: 0,
1121 msg_rx: rx,
1122 write_half: wh,
1123 };
1124 let mut cm = ColorMap::new();
1125
1126 tab.process_message(make_msg("alice", "hi"), &mut cm, true);
1127 assert_eq!(tab.online_users.len(), 1);
1128 }
1129
1130 #[tokio::test]
1133 async fn drain_messages_processes_pending() {
1134 let (tx, rx) = mpsc::unbounded_channel();
1135 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1136 let mut tab = RoomTab {
1137 room_id: "test".into(),
1138 messages: Vec::new(),
1139 online_users: Vec::new(),
1140 user_statuses: HashMap::new(),
1141 subscription_tiers: HashMap::new(),
1142 unread_count: 0,
1143 scroll_offset: 0,
1144 msg_rx: rx,
1145 write_half: wh,
1146 };
1147 let mut cm = ColorMap::new();
1148
1149 tx.send(make_msg("bob", "one")).unwrap();
1150 tx.send(make_msg("bob", "two")).unwrap();
1151
1152 let result = tab.drain_messages(&mut cm, true);
1153 assert!(matches!(result, DrainResult::Ok));
1154 assert_eq!(tab.messages.len(), 2);
1155 }
1156
1157 #[tokio::test]
1158 async fn drain_messages_detects_disconnect() {
1159 let (tx, rx) = mpsc::unbounded_channel();
1160 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1161 let mut tab = RoomTab {
1162 room_id: "test".into(),
1163 messages: Vec::new(),
1164 online_users: Vec::new(),
1165 user_statuses: HashMap::new(),
1166 subscription_tiers: HashMap::new(),
1167 unread_count: 0,
1168 scroll_offset: 0,
1169 msg_rx: rx,
1170 write_half: wh,
1171 };
1172 let mut cm = ColorMap::new();
1173
1174 drop(tx);
1175 let result = tab.drain_messages(&mut cm, true);
1176 assert!(matches!(result, DrainResult::Disconnected));
1177 }
1178
1179 #[tokio::test]
1180 async fn drain_messages_empty_returns_ok() {
1181 let (_tx, rx) = mpsc::unbounded_channel::<Message>();
1182 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1183 let mut tab = RoomTab {
1184 room_id: "test".into(),
1185 messages: Vec::new(),
1186 online_users: Vec::new(),
1187 user_statuses: HashMap::new(),
1188 subscription_tiers: HashMap::new(),
1189 unread_count: 0,
1190 scroll_offset: 0,
1191 msg_rx: rx,
1192 write_half: wh,
1193 };
1194 let mut cm = ColorMap::new();
1195
1196 let result = tab.drain_messages(&mut cm, true);
1197 assert!(matches!(result, DrainResult::Ok));
1198 assert!(tab.messages.is_empty());
1199 }
1200
1201 #[tokio::test]
1202 async fn process_system_message_parses_status() {
1203 let (_, rx) = mpsc::unbounded_channel();
1204 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1205 let mut tab = RoomTab {
1206 room_id: "test".into(),
1207 messages: Vec::new(),
1208 online_users: vec!["alice".into()],
1209 user_statuses: HashMap::new(),
1210 subscription_tiers: HashMap::new(),
1211 unread_count: 0,
1212 scroll_offset: 0,
1213 msg_rx: rx,
1214 write_half: wh,
1215 };
1216 let mut cm = ColorMap::new();
1217
1218 tab.process_message(make_system("alice set status: coding"), &mut cm, true);
1219 assert_eq!(tab.user_statuses.get("alice").unwrap(), "coding");
1220 }
1221
1222 #[tokio::test]
1223 async fn process_subscription_broadcast_sets_tier() {
1224 let (_, rx) = mpsc::unbounded_channel();
1225 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1226 let mut tab = RoomTab {
1227 room_id: "test".into(),
1228 messages: Vec::new(),
1229 online_users: vec!["alice".into()],
1230 user_statuses: HashMap::new(),
1231 subscription_tiers: HashMap::new(),
1232 unread_count: 0,
1233 scroll_offset: 0,
1234 msg_rx: rx,
1235 write_half: wh,
1236 };
1237 let mut cm = ColorMap::new();
1238
1239 tab.process_message(
1240 make_system("alice subscribed to test (tier: mentions_only)"),
1241 &mut cm,
1242 true,
1243 );
1244 assert_eq!(
1245 tab.subscription_tiers.get("alice").copied(),
1246 Some(SubscriptionTier::MentionsOnly),
1247 );
1248
1249 tab.process_message(
1251 make_system("alice subscribed to test (tier: full)"),
1252 &mut cm,
1253 true,
1254 );
1255 assert_eq!(
1256 tab.subscription_tiers.get("alice").copied(),
1257 Some(SubscriptionTier::Full),
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn process_leave_clears_subscription_tier() {
1263 let (_, rx) = mpsc::unbounded_channel();
1264 let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1265 let mut tab = RoomTab {
1266 room_id: "test".into(),
1267 messages: Vec::new(),
1268 online_users: vec!["alice".into()],
1269 user_statuses: HashMap::new(),
1270 subscription_tiers: HashMap::from([(
1271 "alice".to_owned(),
1272 SubscriptionTier::MentionsOnly,
1273 )]),
1274 unread_count: 0,
1275 scroll_offset: 0,
1276 msg_rx: rx,
1277 write_half: wh,
1278 };
1279 let mut cm = ColorMap::new();
1280
1281 tab.process_message(make_leave("alice"), &mut cm, true);
1282 assert!(tab.subscription_tiers.get("alice").is_none());
1283 }
1284}