The sideways keys the results had (h/l, arrows, 0 and $) now scroll a transfer's file name and a query too, each list on its own. The browse tree and the room list take Home/End, PgUp/PgDn and the vim jumps like the main lists, and a chat log, room or private, pages back through its history and holds its place while new messages arrive; what arrives meanwhile counts as unread until the reader returns to the end.
628 lines
20 KiB
Rust
628 lines
20 KiB
Rust
use crate::models::LogView;
|
|
use chrono::{DateTime, Local};
|
|
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
|
|
|
/// One line in a room's chat log. A `None` username marks a system line
|
|
/// (joins/leaves), rendered dimmed.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RoomLine {
|
|
pub username: Option<String>,
|
|
pub text: String,
|
|
/// Local wall-clock time the line was appended.
|
|
pub at: DateTime<Local>,
|
|
}
|
|
|
|
impl RoomLine {
|
|
fn chat(username: String, text: String) -> Self {
|
|
Self {
|
|
username: Some(username),
|
|
text,
|
|
at: Local::now(),
|
|
}
|
|
}
|
|
fn system(text: String) -> Self {
|
|
Self {
|
|
username: None,
|
|
text,
|
|
at: Local::now(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A chat room the user has open (a tab).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct OpenRoom {
|
|
pub name: String,
|
|
pub users: Vec<String>,
|
|
pub lines: Vec<RoomLine>,
|
|
/// Unread messages received while this room was not being viewed.
|
|
pub unread: usize,
|
|
/// Per-room compose buffer.
|
|
pub input: String,
|
|
/// Where the log is being read.
|
|
pub view: LogView,
|
|
}
|
|
|
|
impl OpenRoom {
|
|
fn new(name: String) -> Self {
|
|
Self {
|
|
name,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which sub-view of the rooms popup is showing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RoomsView {
|
|
/// The browsable/filterable list of public rooms.
|
|
List,
|
|
/// The open-room tabs and their chat logs.
|
|
Chat,
|
|
}
|
|
|
|
/// All chat-room UI state: the discoverable room list plus the set of open
|
|
/// rooms (tabs).
|
|
pub struct RoomsState {
|
|
/// Latest public-room snapshot from the server.
|
|
pub available: Vec<RoomInfo>,
|
|
pub list_filter: String,
|
|
pub list_is_filtering: bool,
|
|
/// Selected index into the *filtered* room list.
|
|
pub list_selected: usize,
|
|
/// Rooms the user has joined (tabs).
|
|
pub open: Vec<OpenRoom>,
|
|
/// Index into `open` of the active tab.
|
|
pub active: usize,
|
|
pub view: RoomsView,
|
|
/// Whether the compose input for the active room is capturing keys.
|
|
pub composing: bool,
|
|
/// Selected index into the active room's member list (for browse/PM).
|
|
pub user_selected: usize,
|
|
}
|
|
|
|
impl Default for RoomsState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl RoomsState {
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
available: Vec::new(),
|
|
list_filter: String::new(),
|
|
list_is_filtering: false,
|
|
list_selected: 0,
|
|
open: Vec::new(),
|
|
active: 0,
|
|
view: RoomsView::List,
|
|
composing: false,
|
|
user_selected: 0,
|
|
}
|
|
}
|
|
|
|
/// The public rooms matching the current filter, busiest first then by name.
|
|
#[must_use]
|
|
pub fn filtered_rooms(&self) -> Vec<RoomInfo> {
|
|
let needle = self.list_filter.to_lowercase();
|
|
let mut rooms: Vec<RoomInfo> = self
|
|
.available
|
|
.iter()
|
|
.filter(|r| {
|
|
needle.is_empty() || r.name.to_lowercase().contains(&needle)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
rooms.sort_by(|a, b| {
|
|
b.user_count
|
|
.cmp(&a.user_count)
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
});
|
|
rooms
|
|
}
|
|
|
|
/// The room name currently highlighted in the list view, if any.
|
|
#[must_use]
|
|
pub fn selected_room_name(&self) -> Option<String> {
|
|
let rooms = self.filtered_rooms();
|
|
rooms.get(self.list_selected).map(|r| r.name.clone())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn open_index(&self, name: &str) -> Option<usize> {
|
|
self.open.iter().position(|r| r.name == name)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn active_room(&self) -> Option<&OpenRoom> {
|
|
self.open.get(self.active)
|
|
}
|
|
|
|
/// Open (or focus) a room by name, switching to the Chat view. Returns
|
|
/// `true` if the room was newly opened (so the caller should `join_room`).
|
|
pub fn focus_or_open(&mut self, name: &str) -> bool {
|
|
self.view = RoomsView::Chat;
|
|
self.user_selected = 0;
|
|
if let Some(idx) = self.open_index(name) {
|
|
self.active = idx;
|
|
self.mark_active_read();
|
|
false
|
|
} else {
|
|
self.open.push(OpenRoom::new(name.to_string()));
|
|
self.active = self.open.len() - 1;
|
|
true
|
|
}
|
|
}
|
|
|
|
/// The username highlighted in the active room's member list, if any.
|
|
#[must_use]
|
|
pub fn selected_user(&self) -> Option<String> {
|
|
self.active_room()
|
|
.and_then(|r| r.users.get(self.user_selected).cloned())
|
|
}
|
|
|
|
/// Where the active room's log is being read, to move it.
|
|
pub fn active_view_mut(&mut self) -> Option<&mut LogView> {
|
|
self.open.get_mut(self.active).map(|room| &mut room.view)
|
|
}
|
|
|
|
pub const fn select_user_up(&mut self) {
|
|
self.user_selected = self.user_selected.saturating_sub(1);
|
|
}
|
|
|
|
pub fn select_user_down(&mut self) {
|
|
if let Some(room) = self.active_room()
|
|
&& !room.users.is_empty()
|
|
{
|
|
self.user_selected =
|
|
(self.user_selected + 1).min(room.users.len() - 1);
|
|
}
|
|
}
|
|
|
|
/// Close the active tab, returning its room name (for `leave_room`).
|
|
pub fn close_active(&mut self) -> Option<String> {
|
|
if self.active >= self.open.len() {
|
|
return None;
|
|
}
|
|
let room = self.open.remove(self.active);
|
|
if self.active >= self.open.len() {
|
|
self.active = self.open.len().saturating_sub(1);
|
|
}
|
|
self.user_selected = 0;
|
|
if self.open.is_empty() {
|
|
self.view = RoomsView::List;
|
|
} else {
|
|
// The tab we land on is now being viewed; clear its stale badge.
|
|
self.mark_active_read();
|
|
}
|
|
Some(room.name)
|
|
}
|
|
|
|
pub fn next_tab(&mut self) {
|
|
if !self.open.is_empty() {
|
|
self.active = (self.active + 1) % self.open.len();
|
|
self.user_selected = 0;
|
|
self.mark_active_read();
|
|
}
|
|
}
|
|
|
|
pub fn prev_tab(&mut self) {
|
|
if !self.open.is_empty() {
|
|
self.active = (self.active + self.open.len() - 1) % self.open.len();
|
|
self.user_selected = 0;
|
|
self.mark_active_read();
|
|
}
|
|
}
|
|
|
|
/// Clear the unread badge on the active room.
|
|
pub fn mark_active_read(&mut self) {
|
|
if let Some(room) = self.open.get_mut(self.active) {
|
|
room.unread = 0;
|
|
}
|
|
}
|
|
|
|
/// Total unread messages across all open rooms (for the closed-popup badge).
|
|
#[must_use]
|
|
pub fn total_unread(&self) -> usize {
|
|
self.open.iter().map(|r| r.unread).sum()
|
|
}
|
|
|
|
/// Apply a room event. `viewing` is the room the user is actively looking
|
|
/// at (Chat view open on that tab), whose incoming messages should not
|
|
/// accrue an unread badge.
|
|
pub fn apply_event(&mut self, event: RoomEvent, viewing: Option<&str>) {
|
|
match event {
|
|
RoomEvent::List(rooms) => {
|
|
// Keep the highlight on the same room across a re-sort, since
|
|
// list_selected is a positional index into the sorted list.
|
|
let selected_name = self.selected_room_name();
|
|
self.available = rooms;
|
|
let filtered = self.filtered_rooms();
|
|
self.list_selected = selected_name
|
|
.and_then(|name| {
|
|
filtered.iter().position(|r| r.name == name)
|
|
})
|
|
.unwrap_or_else(|| {
|
|
self.list_selected.min(filtered.len().saturating_sub(1))
|
|
});
|
|
}
|
|
RoomEvent::Joined { room, users } => {
|
|
let idx = self.ensure_open(&room);
|
|
self.open[idx].users = users;
|
|
self.open[idx]
|
|
.lines
|
|
.push(RoomLine::system("— joined —".to_string()));
|
|
}
|
|
RoomEvent::Left { room } => {
|
|
if let Some(idx) = self.open_index(&room) {
|
|
self.open[idx]
|
|
.lines
|
|
.push(RoomLine::system("— left —".to_string()));
|
|
}
|
|
}
|
|
RoomEvent::Message {
|
|
room,
|
|
username,
|
|
message,
|
|
} => {
|
|
// Only record messages for rooms we currently have open; a
|
|
// message for a room we just left must not resurrect its tab.
|
|
if let Some(idx) = self.open_index(&room) {
|
|
self.open[idx]
|
|
.lines
|
|
.push(RoomLine::chat(username, message));
|
|
if viewing != Some(room.as_str()) {
|
|
self.open[idx].unread += 1;
|
|
}
|
|
}
|
|
}
|
|
RoomEvent::UserJoined { room, username } => {
|
|
if let Some(idx) = self.open_index(&room) {
|
|
if !self.open[idx].users.contains(&username) {
|
|
self.open[idx].users.push(username.clone());
|
|
}
|
|
self.open[idx]
|
|
.lines
|
|
.push(RoomLine::system(format!("→ {username}")));
|
|
}
|
|
}
|
|
RoomEvent::UserLeft { room, username } => {
|
|
if let Some(idx) = self.open_index(&room) {
|
|
self.open[idx].users.retain(|u| u != &username);
|
|
self.open[idx]
|
|
.lines
|
|
.push(RoomLine::system(format!("← {username}")));
|
|
}
|
|
}
|
|
}
|
|
// The active room's member list may have grown/shrunk (join/leave or a
|
|
// wholesale replace on Joined); keep the selection highlight in range so
|
|
// 'b'/'m' never act on the wrong member or a phantom one.
|
|
self.clamp_user_selected();
|
|
}
|
|
|
|
/// Keep `user_selected` within the active room's member list.
|
|
fn clamp_user_selected(&mut self) {
|
|
let len = self.active_room().map_or(0, |r| r.users.len());
|
|
self.user_selected = self.user_selected.min(len.saturating_sub(1));
|
|
}
|
|
|
|
/// Index of the open room `name`, creating an empty tab if necessary.
|
|
fn ensure_open(&mut self, name: &str) -> usize {
|
|
if let Some(idx) = self.open_index(name) {
|
|
idx
|
|
} else {
|
|
self.open.push(OpenRoom::new(name.to_string()));
|
|
self.open.len() - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn room(name: &str, users: u32) -> RoomInfo {
|
|
RoomInfo {
|
|
name: name.to_string(),
|
|
user_count: users,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_rooms_sorts_busiest_first_then_name() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::List(vec![
|
|
room("blues", 3),
|
|
room("jazz", 10),
|
|
room("ambient", 10),
|
|
]),
|
|
None,
|
|
);
|
|
let names: Vec<String> =
|
|
state.filtered_rooms().into_iter().map(|r| r.name).collect();
|
|
assert_eq!(names, vec!["ambient", "jazz", "blues"]);
|
|
}
|
|
|
|
#[test]
|
|
fn filter_matches_case_insensitively() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::List(vec![room("Jazz", 1), room("Blues", 1)]),
|
|
None,
|
|
);
|
|
state.list_filter = "JAZ".to_string();
|
|
let names: Vec<String> =
|
|
state.filtered_rooms().into_iter().map(|r| r.name).collect();
|
|
assert_eq!(names, vec!["Jazz"]);
|
|
}
|
|
|
|
#[test]
|
|
fn joining_creates_a_tab_with_members() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::Joined {
|
|
room: "jazz".to_string(),
|
|
users: vec!["alice".to_string(), "bob".to_string()],
|
|
},
|
|
None,
|
|
);
|
|
assert_eq!(state.open.len(), 1);
|
|
assert_eq!(state.open[0].name, "jazz");
|
|
assert_eq!(state.open[0].users, vec!["alice", "bob"]);
|
|
}
|
|
|
|
#[test]
|
|
fn message_to_unviewed_room_increments_unread() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("jazz");
|
|
state.apply_event(
|
|
RoomEvent::Message {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
message: "hi".to_string(),
|
|
},
|
|
None, // not viewing anything
|
|
);
|
|
assert_eq!(state.open[0].unread, 1);
|
|
assert_eq!(state.total_unread(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn message_to_viewed_room_does_not_increment_unread() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("jazz");
|
|
state.apply_event(
|
|
RoomEvent::Message {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
message: "hi".to_string(),
|
|
},
|
|
Some("jazz"),
|
|
);
|
|
assert_eq!(state.open[0].unread, 0);
|
|
assert_eq!(state.open[0].lines.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_or_open_is_idempotent_and_marks_read() {
|
|
let mut state = RoomsState::new();
|
|
assert!(state.focus_or_open("jazz")); // newly opened
|
|
state.open[0].unread = 4;
|
|
assert!(!state.focus_or_open("jazz")); // already open
|
|
assert_eq!(state.open.len(), 1);
|
|
assert_eq!(state.open[0].unread, 0, "focusing clears unread");
|
|
}
|
|
|
|
#[test]
|
|
fn closing_active_tab_returns_name_and_adjusts_index() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("a");
|
|
state.focus_or_open("b");
|
|
assert_eq!(state.active, 1);
|
|
assert_eq!(state.close_active().as_deref(), Some("b"));
|
|
assert_eq!(state.active, 0);
|
|
assert_eq!(state.open.len(), 1);
|
|
assert_eq!(state.close_active().as_deref(), Some("a"));
|
|
assert!(state.open.is_empty());
|
|
assert_eq!(state.view, RoomsView::List);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_cycling_wraps_and_marks_read() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("a");
|
|
state.focus_or_open("b");
|
|
state.open[0].unread = 2;
|
|
state.next_tab(); // 1 -> 0
|
|
assert_eq!(state.active, 0);
|
|
assert_eq!(state.open[0].unread, 0);
|
|
state.prev_tab(); // 0 -> 1
|
|
assert_eq!(state.active, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn message_for_unopened_room_is_dropped() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::Message {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
message: "hi".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert!(
|
|
state.open.is_empty(),
|
|
"a message must not fabricate a tab for an unjoined room"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn message_does_not_resurrect_a_closed_room() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("jazz");
|
|
assert_eq!(state.close_active().as_deref(), Some("jazz"));
|
|
// A straggler message arrives after we left.
|
|
state.apply_event(
|
|
RoomEvent::Message {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
message: "bye".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert!(state.open.is_empty(), "left room must stay closed");
|
|
}
|
|
|
|
#[test]
|
|
fn list_refresh_keeps_highlight_on_the_same_room() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::List(vec![
|
|
room("jazz", 5),
|
|
room("blues", 3),
|
|
room("ambient", 1),
|
|
]),
|
|
None,
|
|
);
|
|
// Highlight "ambient" (index 2 in busiest-first order).
|
|
state.list_selected = 2;
|
|
assert_eq!(state.selected_room_name().as_deref(), Some("ambient"));
|
|
// A refresh reorders ambient to the top; the highlight should follow.
|
|
state.apply_event(
|
|
RoomEvent::List(vec![
|
|
room("jazz", 5),
|
|
room("blues", 3),
|
|
room("ambient", 50),
|
|
]),
|
|
None,
|
|
);
|
|
assert_eq!(state.selected_room_name().as_deref(), Some("ambient"));
|
|
}
|
|
|
|
#[test]
|
|
fn closing_a_tab_clears_unread_on_the_newly_active_room() {
|
|
let mut state = RoomsState::new();
|
|
state.focus_or_open("a");
|
|
state.focus_or_open("b"); // active = 1
|
|
state.open[0].unread = 3; // unread accrued on "a" while viewing "b"
|
|
assert_eq!(state.close_active().as_deref(), Some("b"));
|
|
assert_eq!(state.active, 0);
|
|
assert_eq!(
|
|
state.open[0].unread, 0,
|
|
"landing on room a should clear its badge"
|
|
);
|
|
assert_eq!(state.total_unread(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn user_selection_navigates_and_resets_across_tabs() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::Joined {
|
|
room: "jazz".to_string(),
|
|
users: vec![
|
|
"alice".to_string(),
|
|
"bob".to_string(),
|
|
"carol".to_string(),
|
|
],
|
|
},
|
|
None,
|
|
);
|
|
state.focus_or_open("jazz");
|
|
assert_eq!(state.selected_user().as_deref(), Some("alice"));
|
|
state.select_user_down();
|
|
state.select_user_down();
|
|
assert_eq!(state.selected_user().as_deref(), Some("carol"));
|
|
state.select_user_down(); // clamps at the last user
|
|
assert_eq!(state.selected_user().as_deref(), Some("carol"));
|
|
state.select_user_up();
|
|
assert_eq!(state.selected_user().as_deref(), Some("bob"));
|
|
|
|
// Opening another room resets the user highlight.
|
|
state.focus_or_open("blues");
|
|
assert_eq!(state.user_selected, 0);
|
|
assert_eq!(state.selected_user(), None); // blues has no members yet
|
|
}
|
|
|
|
#[test]
|
|
fn user_selection_stays_in_range_when_members_leave() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::Joined {
|
|
room: "jazz".to_string(),
|
|
users: vec![
|
|
"alice".to_string(),
|
|
"bob".to_string(),
|
|
"carol".to_string(),
|
|
],
|
|
},
|
|
None,
|
|
);
|
|
state.focus_or_open("jazz");
|
|
state.user_selected = 2; // carol highlighted (last)
|
|
|
|
// carol leaves: selection must clamp to the new last index, not point
|
|
// past the end (which would make 'b'/'m' silent no-ops).
|
|
state.apply_event(
|
|
RoomEvent::UserLeft {
|
|
room: "jazz".to_string(),
|
|
username: "carol".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert_eq!(state.user_selected, 1);
|
|
assert_eq!(state.selected_user().as_deref(), Some("bob"));
|
|
|
|
// Everyone leaves: selection collapses to 0 and yields no user.
|
|
state.apply_event(
|
|
RoomEvent::UserLeft {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
state.apply_event(
|
|
RoomEvent::UserLeft {
|
|
room: "jazz".to_string(),
|
|
username: "alice".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert_eq!(state.user_selected, 0);
|
|
assert_eq!(state.selected_user(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn user_joined_and_left_update_membership() {
|
|
let mut state = RoomsState::new();
|
|
state.apply_event(
|
|
RoomEvent::Joined {
|
|
room: "jazz".to_string(),
|
|
users: vec!["alice".to_string()],
|
|
},
|
|
None,
|
|
);
|
|
state.apply_event(
|
|
RoomEvent::UserJoined {
|
|
room: "jazz".to_string(),
|
|
username: "bob".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert_eq!(state.open[0].users, vec!["alice", "bob"]);
|
|
state.apply_event(
|
|
RoomEvent::UserLeft {
|
|
room: "jazz".to_string(),
|
|
username: "alice".to_string(),
|
|
},
|
|
None,
|
|
);
|
|
assert_eq!(state.open[0].users, vec!["bob"]);
|
|
}
|
|
}
|