feat(rooms): private room membership, operators and refusals

Private rooms were unreachable: the client could ask to join one but had
no way to invite, promote, resign or be told what happened, and a room
name already owned by someone else came back as silence rather than a
refusal (code 1003).

Adds the rosters the server pushes after login (133/148), the changes it
narrates to them (134/135, 143/144), our own standing (139/140, 145/146),
and the messages that drive them, with the rosters kept current in client
state. The daemon protocol and the TUI room transcript carry the new
events rather than dropping them.
This commit is contained in:
Michel de Graaf
2026-09-09 16:33:59 +00:00
parent 65c44cc66b
commit 19791d1699
12 changed files with 1466 additions and 1 deletions
+228
View File
@@ -729,6 +729,218 @@
"username"
],
"type": "object"
},
{
"description": "The ticker board of a room, sent on join.",
"properties": {
"event": {
"const": "tickers",
"type": "string"
},
"room": {
"type": "string"
},
"tickers": {
"items": {
"$ref": "#/components/schemas/RoomTickerDto"
},
"type": "array"
}
},
"required": [
"event",
"room",
"tickers"
],
"type": "object"
},
{
"properties": {
"event": {
"const": "ticker_added",
"type": "string"
},
"room": {
"type": "string"
},
"ticker": {
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"event",
"room",
"username",
"ticker"
],
"type": "object"
},
{
"properties": {
"event": {
"const": "ticker_removed",
"type": "string"
},
"room": {
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"event",
"room",
"username"
],
"type": "object"
},
{
"description": "A message from the global room feed; `room` names where it was said.",
"properties": {
"event": {
"const": "global_message",
"type": "string"
},
"message": {
"type": "string"
},
"room": {
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"event",
"room",
"username",
"message"
],
"type": "object"
},
{
"description": "Who may enter a private room.",
"properties": {
"event": {
"const": "private_members",
"type": "string"
},
"room": {
"type": "string"
},
"users": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"event",
"room",
"users"
],
"type": "object"
},
{
"description": "Who runs a private room.",
"properties": {
"event": {
"const": "private_operators",
"type": "string"
},
"room": {
"type": "string"
},
"users": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"event",
"room",
"users"
],
"type": "object"
},
{
"description": "A private room's member (`members` true) or operator roster changed.",
"properties": {
"added": {
"type": "boolean"
},
"event": {
"const": "private_roster_changed",
"type": "string"
},
"members": {
"type": "boolean"
},
"room": {
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"event",
"room",
"username",
"members",
"added"
],
"type": "object"
},
{
"description": "Our own membership or operatorship of a private room changed.",
"properties": {
"event": {
"const": "own_standing_changed",
"type": "string"
},
"granted": {
"type": "boolean"
},
"members": {
"type": "boolean"
},
"room": {
"type": "string"
}
},
"required": [
"event",
"room",
"members",
"granted"
],
"type": "object"
},
{
"description": "The room we asked to join could not be created.",
"properties": {
"event": {
"const": "cant_create",
"type": "string"
},
"room": {
"type": "string"
}
},
"required": [
"event",
"room"
],
"type": "object"
}
]
},
@@ -760,6 +972,22 @@
],
"type": "object"
},
"RoomTickerDto": {
"description": "One user's ticker in a room.",
"properties": {
"ticker": {
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"username",
"ticker"
],
"type": "object"
},
"RoomUserStatsDto": {
"description": "One member of a room, as the JoinRoom reply described them.",
"properties": {
+186
View File
@@ -373,6 +373,79 @@ pub enum RoomEventDto {
room: String,
username: String,
},
/// The ticker board of a room, sent on join.
Tickers {
room: String,
tickers: Vec<RoomTickerDto>,
},
TickerAdded {
room: String,
username: String,
ticker: String,
},
TickerRemoved {
room: String,
username: String,
},
/// A message from the global room feed; `room` names where it was said.
GlobalMessage {
room: String,
username: String,
message: String,
},
/// Who may enter a private room.
PrivateMembers {
room: String,
users: Vec<String>,
},
/// Who runs a private room.
PrivateOperators {
room: String,
users: Vec<String>,
},
/// A private room's member (`members` true) or operator roster changed.
PrivateRosterChanged {
room: String,
username: String,
members: bool,
added: bool,
},
/// Our own membership or operatorship of a private room changed.
OwnStandingChanged {
room: String,
members: bool,
granted: bool,
},
/// The room we asked to join could not be created.
CantCreate {
room: String,
},
}
/// One user's ticker in a room.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct RoomTickerDto {
pub username: String,
pub ticker: String,
}
impl From<&soulseek_rs::RoomTicker> for RoomTickerDto {
fn from(ticker: &soulseek_rs::RoomTicker) -> Self {
Self {
username: ticker.username.clone(),
ticker: ticker.ticker.clone(),
}
}
}
impl From<RoomTickerDto> for soulseek_rs::RoomTicker {
fn from(dto: RoomTickerDto) -> Self {
Self {
username: dto.username,
ticker: dto.ticker,
}
}
}
impl From<&RoomEvent> for RoomEventDto {
@@ -403,6 +476,67 @@ impl From<&RoomEvent> for RoomEventDto {
room: room.clone(),
username: username.clone(),
},
RoomEvent::Tickers { room, tickers } => Self::Tickers {
room: room.clone(),
tickers: tickers.iter().map(RoomTickerDto::from).collect(),
},
RoomEvent::TickerAdded {
room,
username,
ticker,
} => Self::TickerAdded {
room: room.clone(),
username: username.clone(),
ticker: ticker.clone(),
},
RoomEvent::TickerRemoved { room, username } => {
Self::TickerRemoved {
room: room.clone(),
username: username.clone(),
}
}
RoomEvent::GlobalMessage {
room,
username,
message,
} => Self::GlobalMessage {
room: room.clone(),
username: username.clone(),
message: message.clone(),
},
RoomEvent::PrivateMembers { room, users } => Self::PrivateMembers {
room: room.clone(),
users: users.clone(),
},
RoomEvent::PrivateOperators { room, users } => {
Self::PrivateOperators {
room: room.clone(),
users: users.clone(),
}
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => Self::PrivateRosterChanged {
room: room.clone(),
username: username.clone(),
members: *members,
added: *added,
},
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => Self::OwnStandingChanged {
room: room.clone(),
members: *members,
granted: *granted,
},
RoomEvent::CantCreate { room } => {
Self::CantCreate { room: room.clone() }
}
}
}
}
@@ -432,6 +566,58 @@ impl From<RoomEventDto> for RoomEvent {
RoomEventDto::UserLeft { room, username } => {
Self::UserLeft { room, username }
}
RoomEventDto::Tickers { room, tickers } => Self::Tickers {
room,
tickers: tickers.into_iter().map(Into::into).collect(),
},
RoomEventDto::TickerAdded {
room,
username,
ticker,
} => Self::TickerAdded {
room,
username,
ticker,
},
RoomEventDto::TickerRemoved { room, username } => {
Self::TickerRemoved { room, username }
}
RoomEventDto::GlobalMessage {
room,
username,
message,
} => Self::GlobalMessage {
room,
username,
message,
},
RoomEventDto::PrivateMembers { room, users } => {
Self::PrivateMembers { room, users }
}
RoomEventDto::PrivateOperators { room, users } => {
Self::PrivateOperators { room, users }
}
RoomEventDto::PrivateRosterChanged {
room,
username,
members,
added,
} => Self::PrivateRosterChanged {
room,
username,
members,
added,
},
RoomEventDto::OwnStandingChanged {
room,
members,
granted,
} => Self::OwnStandingChanged {
room,
members,
granted,
},
RoomEventDto::CantCreate { room } => Self::CantCreate { room },
}
}
}
+75
View File
@@ -336,6 +336,81 @@ impl RoomsState {
.push(RoomLine::system(format!("← {username}")));
}
}
// Tickers and private-room standing are narrated into the room's
// own transcript: they belong to a room the user has open, and a
// system line is where the rest of that room's news already goes.
RoomEvent::Tickers { room, tickers } => {
if let Some(idx) = self.open_index(&room) {
for ticker in tickers {
self.open[idx].lines.push(RoomLine::system(format!(
"≡ {}: {}",
ticker.username, ticker.ticker
)));
}
}
}
RoomEvent::TickerAdded {
room,
username,
ticker,
} => {
if let Some(idx) = self.open_index(&room) {
self.open[idx].lines.push(RoomLine::system(format!(
"≡ {username}: {ticker}"
)));
}
}
RoomEvent::TickerRemoved { room, username } => {
if let Some(idx) = self.open_index(&room) {
self.open[idx].lines.push(RoomLine::system(format!(
"≡ {username} cleared their ticker"
)));
}
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => {
if let Some(idx) = self.open_index(&room) {
let roster = if members { "member" } else { "operator" };
let verb = if added { "is now" } else { "is no longer" };
self.open[idx].lines.push(RoomLine::system(format!(
"{username} {verb} a {roster}"
)));
}
}
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => {
if let Some(idx) = self.open_index(&room) {
let what = if members {
"membership"
} else {
"operator status"
};
let verb = if granted { "granted" } else { "revoked" };
self.open[idx].lines.push(RoomLine::system(format!(
"— your {what} was {verb} —"
)));
}
}
RoomEvent::CantCreate { room } => {
if let Some(idx) = self.open_index(&room) {
self.open[idx].lines.push(RoomLine::system(
"— this room cannot be created —".to_string(),
));
}
}
// The global feed carries rooms the user has not opened, and the
// private rosters are read from the client rather than rendered
// into a transcript.
RoomEvent::GlobalMessage { .. }
| RoomEvent::PrivateMembers { .. }
| RoomEvent::PrivateOperators { .. } => {}
}
// 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