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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.8"
|
||||
version = "0.0.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user