v0.0.9: Group management — leave, kick, members

Server:
- POST /groups/:name/leave — remove self from group
- POST /groups/:name/kick — creator can kick members
- GET /groups/:name/members — list with aliases + creator badge

CLI TUI:
- /gleave — leave current group
- /gkick <fp_or_alias> — kick (creator only)
- /gmembers — show member list with aliases and ★ for creator

Web client:
- Same commands: /gleave, /gkick, /gmembers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 12:04:28 +04:00
parent 2599ce956a
commit 4fb3973403
5 changed files with 224 additions and 9 deletions

10
warzone/Cargo.lock generated
View File

@@ -2647,7 +2647,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"anyhow",
"argon2",
@@ -2680,7 +2680,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"anyhow",
"clap",
@@ -2689,7 +2689,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"base64",
"bincode",
@@ -2712,7 +2712,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"anyhow",
"axum",
@@ -2739,7 +2739,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"base64",
"bincode",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.8"
version = "0.0.9"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -98,7 +98,7 @@ impl App {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Commands: /alias <name>, /peer <fp|@alias>, /g <group>, /file <path>, /info, /quit".into(),
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
is_system: true,
is_self: false,
message_id: None,
@@ -325,6 +325,41 @@ impl App {
self.group_list(client).await;
return;
}
if text == "/gleave" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_leave(&name, client).await;
self.peer_fp = None;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None });
}
}
return;
}
if text.starts_with("/gkick ") {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
let target = text[7..].trim().to_string();
self.group_kick(&name, &target, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None });
}
}
return;
}
if text == "/gmembers" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_members(&name, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None });
}
}
return;
}
if text.starts_with("/file ") {
let path_str = text[6..].trim();
self.handle_file_send(path_str, identity, db, client).await;
@@ -762,6 +797,69 @@ impl App {
}
}
async fn group_leave(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/leave", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }),
}
}
async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/kick", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None });
} else {
let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }),
}
}
async fn group_members(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/members", client.base_url, name);
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(members) = data.get("members").and_then(|v| v.as_array()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None });
for m in members {
let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = m.get("alias").and_then(|v| v.as_str());
let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false);
let label = match alias {
Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { "" } else { "" }),
None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { "" } else { "" }),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None });
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }),
}
}
async fn group_send(
&self,
group_name: &str,

View File

@@ -15,6 +15,9 @@ pub fn routes() -> Router<AppState> {
.route("/groups/:name", get(get_group))
.route("/groups/:name/join", post(join_group))
.route("/groups/:name/send", post(send_to_group))
.route("/groups/:name/leave", post(leave_group))
.route("/groups/:name/kick", post(kick_member))
.route("/groups/:name/members", get(get_members))
}
#[derive(Serialize, Deserialize, Clone)]
@@ -205,3 +208,90 @@ async fn send_to_group(
Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered })))
}
async fn leave_group(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<JoinRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = normalize_fp(&req.fingerprint);
let mut group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
group.members.retain(|m| m != &fp);
save_group(&state.db.groups, &group)?;
tracing::info!("{} left group '{}' ({} remaining)", fp, name, group.members.len());
Ok(Json(serde_json::json!({ "ok": true, "remaining": group.members.len() })))
}
#[derive(Deserialize)]
struct KickRequest {
fingerprint: String, // who is doing the kicking (must be creator)
target: String, // who to kick
}
async fn kick_member(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<KickRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = normalize_fp(&req.fingerprint);
let target = normalize_fp(&req.target);
let mut group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
if group.creator != fp {
return Ok(Json(serde_json::json!({ "error": "only the creator can kick members" })));
}
if target == fp {
return Ok(Json(serde_json::json!({ "error": "cannot kick yourself" })));
}
let before = group.members.len();
group.members.retain(|m| m != &target);
if group.members.len() == before {
return Ok(Json(serde_json::json!({ "error": "target is not a member" })));
}
save_group(&state.db.groups, &group)?;
tracing::info!("{} kicked {} from group '{}'", fp, target, name);
Ok(Json(serde_json::json!({ "ok": true, "kicked": target, "remaining": group.members.len() })))
}
async fn get_members(
State(state): State<AppState>,
Path(name): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
// Resolve aliases for each member
let mut members_info: Vec<serde_json::Value> = Vec::new();
for fp in &group.members {
let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes())
.ok().flatten()
.map(|v| String::from_utf8_lossy(&v).to_string());
members_info.push(serde_json::json!({
"fingerprint": fp,
"alias": alias,
"is_creator": *fp == group.creator,
}));
}
Ok(Json(serde_json::json!({
"name": group.name,
"members": members_info,
"count": members_info.len(),
})))
}

View File

@@ -160,7 +160,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.6';
const VERSION = '0.0.9';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
@@ -568,7 +568,7 @@ async function enterChat() {
addSys('Identity loaded: ' + myFingerprint);
addSys('Key registered with server');
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
addSys('/alias · /g · /glist · /info · /selftest · /reset · /debug');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info');
const savedPeer = localStorage.getItem('wz-peer');
if (savedPeer) $peerInput.value = savedPeer;
@@ -733,6 +733,33 @@ async function doSend() {
}
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
if (text === '/gleave') {
if (!currentGroup) { addSys('Not in a group'); return; }
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/leave',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint)})});
const d = await r.json();
if (d.error) addSys('Error: '+d.error); else { addSys('Left group "'+currentGroup+'"'); currentGroup=null; $peerInput.value=''; }
return;
}
if (text.startsWith('/gkick ')) {
if (!currentGroup) { addSys('Not in a group'); return; }
const target = text.slice(7).trim();
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/kick',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fingerprint:normFP(myFingerprint),target:target})});
const d = await r.json();
if (d.error) addSys('Error: '+d.error); else addSys('Kicked '+d.kicked);
return;
}
if (text === '/gmembers') {
if (!currentGroup) { addSys('Not in a group'); return; }
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members');
const d = await r.json();
if (d.error) { addSys('Error: '+d.error); return; }
addSys('Members of #'+currentGroup+':');
for (const m of d.members) {
const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...';
addSys(' '+a+(m.is_creator?' ★':''));
}
return;
}
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
// Send to group or DM