feat(lib): per-member room statistics from the JoinRoom reply

The JoinRoom (code 14) reply carries, after the membership list, four
parallel vectors describing each member: status, share statistics, free
upload slots and country. The handler stopped after the usernames, so all of
it was discarded. It is now parsed and exposed as Client::room_member_stats.

Issue #48 asked for RoomStats (57) and RoomListStats (55). Those codes are
UserInterests and MyRecommendations in the Soulseek protocol; there is no
RoomStats message on the wire. Per-room user counts already arrive with
RoomList (64), which the client handles, and the per-member statistics are
part of the JoinRoom reply -- so that is where this adds them.

- join_room.rs: parse the stat vectors, each bounded by the payload so a
  hostile count cannot hang or overallocate
- types::RoomUserStats; every field looked up positionally so a server
  sending short vectors (or omitting countries) does not shift the roster
- ServerMessage/ClientOperation::RoomMemberStats and the context snapshot
  behind Client::room_member_stats, replaced on each rejoin
This commit is contained in:
Michel de Graaf
2026-08-28 12:44:37 +00:00
parent 0f91ffdcf3
commit fa49ebd2eb
7 changed files with 391 additions and 16 deletions
+34 -1
View File
@@ -5,7 +5,8 @@ use crate::actor::server_actor::{
use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{
ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo,
SessionLoss, SessionWatch, UserInfo, UserPresence, UserStats, UserStatus,
RoomUserStats, SessionLoss, SessionWatch, UserInfo, UserPresence,
UserStats, UserStatus,
};
use crate::utils::logger;
use crate::{
@@ -278,6 +279,11 @@ pub enum ClientOperation {
/// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent),
/// Per-member statistics from a `JoinRoom` reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
/// The server announced how many seconds must pass between wishlist
/// searches.
WishlistInterval(u32),
@@ -325,6 +331,9 @@ pub struct ClientContext {
/// Who is in each room we have joined, kept current from the membership
/// the server sends on join plus the later join/leave events.
room_members: HashMap<String, Vec<String>>,
/// Per-member statistics for each joined room, from the stat vectors the
/// server sends alongside the membership list.
room_member_stats: HashMap<String, Vec<RoomUserStats>>,
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
@@ -415,6 +424,7 @@ impl ClientContext {
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
user_info: HashMap::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
@@ -515,6 +525,29 @@ impl ClientContext {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
+10
View File
@@ -479,6 +479,16 @@ impl Client {
Err(e) => error!("[client] RoomEvent write: {}", e),
}
}
ClientOperation::RoomMemberStats { room, stats } => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_room_member_stats(room, stats);
}
Err(e) => {
error!("[client] RoomMemberStats write: {}", e);
}
}
}
ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.wishlist_interval = Some(seconds);
+19 -1
View File
@@ -2,7 +2,7 @@ use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error,
};
use crate::types::UserInfo;
use crate::types::{RoomUserStats, UserInfo};
impl Client {
/// Send a private message to another user via the server.
@@ -156,6 +156,24 @@ impl Client {
}
}
/// What the server reported about the members of `room` when we joined:
/// their status, share statistics, free upload slots and country.
///
/// Populated from the same reply that fills [`Client::room_members`], so
/// it lands shortly after joining. Unlike the roster it is not updated by
/// later join and leave events -- it is the snapshot taken at join time.
/// Returns an empty list for a room this client has not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_member_stats(room),
Err(e) => {
error!("[client] room_member_stats: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> {
+50
View File
@@ -622,3 +622,53 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
"a replayed TransferResponse must not dial the peer again"
);
}
#[test]
fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_free: 1,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats(
"jazz".to_string(),
vec![stat("carol"), stat("alice")],
);
let names: Vec<String> = context
.room_member_stats("jazz")
.into_iter()
.map(|s| s.username)
.collect();
assert_eq!(names, vec!["alice", "carol"]);
assert!(context.room_member_stats("unjoined").is_empty());
}
#[test]
fn rejoining_replaces_the_previous_member_stats() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_free: 0,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]);
context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]);
let stats = context.room_member_stats("jazz");
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}