diff --git a/desktop/app.js b/desktop/app.js
index a481e02..336b336 100644
--- a/desktop/app.js
+++ b/desktop/app.js
@@ -32,6 +32,7 @@ const state = {
chatWs: null,
lastMessageId: null,
onlineUsers: new Set(),
+ idleUsers: new Set(),
};
// --- Desktop Backend Configuration ---
@@ -410,7 +411,7 @@ function renderChannels() {
const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
- pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}`;
+ pRow.innerHTML = `${shortName(p.display_name)}
${escapeHtml(p.display_name)}${p.is_muted ? '' : ''}`;
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
@@ -424,12 +425,13 @@ function renderDMs() {
el.dmList.innerHTML = "";
for (const dm of state.dmConversations) {
const isOnline = state.onlineUsers.has(dm.user_id);
+ const isIdle = state.idleUsers.has(dm.user_id);
const row = document.createElement("button");
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
row.innerHTML = `
${escapeHtml(dm.display_name)}
`;
@@ -497,12 +499,13 @@ function renderMembers() {
el.memberList.innerHTML = "";
for (const m of state.members) {
const isOnline = state.onlineUsers.has(m.id);
+ const isIdle = state.idleUsers.has(m.id);
const row = document.createElement("div");
row.className = "member-row";
row.innerHTML = `
${shortName(m.display_name)}
-
+
${escapeHtml(m.display_name)}
`;
@@ -684,8 +687,14 @@ function initChatWs() {
} else if (msg.type === "user_presence") {
if (msg.online) {
state.onlineUsers.add(msg.user_id);
+ if (msg.idle) {
+ state.idleUsers.add(msg.user_id);
+ } else {
+ state.idleUsers.delete(msg.user_id);
+ }
} else {
state.onlineUsers.delete(msg.user_id);
+ state.idleUsers.delete(msg.user_id);
}
renderMembers();
renderDMs();
@@ -836,7 +845,7 @@ async function buildAudioPipeline(rawStream) {
const checkVolume = () => {
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
- try {
+ try {
if (supportsFloatTimeDomain) {
analyser.getFloatTimeDomainData(timeData);
} else {
@@ -945,7 +954,7 @@ async function createLocalVoiceStream() {
localStream = await buildAudioPipeline(rawStream);
} catch (err) {
console.warn('voice audio pipeline failed, using raw mic stream', err);
- }
+ }
state.voice.rawStream = rawStream;
state.voice.localStream = localStream;
if (state.voice.muted) {
@@ -1146,8 +1155,8 @@ async function joinVoice() {
return;
}
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
- state.voice.ws &&
- state.voice.ws.readyState === WebSocket.OPEN) {
+ state.voice.ws &&
+ state.voice.ws.readyState === WebSocket.OPEN) {
return;
}
@@ -1250,6 +1259,11 @@ async function joinVoice() {
if (videoEl) {
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
}
+ } else if (msg.type === "mute_status_changed") {
+ state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
+ if (p.user_id === msg.user_id) p.is_muted = msg.is_muted;
+ });
+ renderChannels();
} else if (msg.type === "play_sound") {
const audio = new Audio(msg.sound_url);
audio.play().catch(console.error);
@@ -1298,6 +1312,9 @@ function toggleMute() {
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = !state.voice.muted;
});
+ if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
+ state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted }));
+ }
el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : '';
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
lucide.createIcons();
@@ -1505,6 +1522,30 @@ function closeMobileMenus() {
// --- Initialization ---
+let inactivityTimer = null;
+let isCurrentlyIdle = false;
+
+function resetInactivityTimer() {
+ if (isCurrentlyIdle) {
+ isCurrentlyIdle = false;
+ if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
+ state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false }));
+ }
+ }
+
+ if (inactivityTimer) clearTimeout(inactivityTimer);
+ inactivityTimer = setTimeout(() => {
+ isCurrentlyIdle = true;
+ if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
+ state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true }));
+ }
+ }, 5 * 60 * 1000); // 5 minutes
+}
+
+document.addEventListener('mousemove', resetInactivityTimer);
+document.addEventListener('keydown', resetInactivityTimer);
+document.addEventListener('click', resetInactivityTimer);
+
async function init() {
lucide.createIcons();
diff --git a/desktop/styles.css b/desktop/styles.css
index 034d083..f2dbdbb 100644
--- a/desktop/styles.css
+++ b/desktop/styles.css
@@ -550,6 +550,19 @@ select {
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
+.status-dot.idle {
+ background: var(--yellow);
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
+}
+
+.voice-muted-icon {
+ width: 14px;
+ height: 14px;
+ color: var(--danger);
+ margin-left: 6px;
+ vertical-align: text-bottom;
+}
+
.user-info {
flex: 1;
min-width: 0;
diff --git a/src/chat.rs b/src/chat.rs
index 10beca6..8b59bf6 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -6,10 +6,22 @@ use std::collections::HashMap;
use tokio::sync::{RwLock, mpsc};
use uuid::Uuid;
+#[derive(Clone)]
+pub struct ChatClient {
+ tx: mpsc::UnboundedSender,
+ is_idle: bool,
+}
+
+#[derive(Serialize, Clone)]
+pub struct OnlineUser {
+ pub user_id: Uuid,
+ pub idle: bool,
+}
+
#[derive(Default)]
pub struct ChatHub {
- // user_id -> sender
- clients: RwLock>>,
+ // user_id -> client
+ clients: RwLock>,
}
#[derive(Serialize, Clone)]
@@ -26,6 +38,7 @@ pub enum ServerEvent {
UserPresence {
user_id: Uuid,
online: bool,
+ idle: bool,
},
}
@@ -34,12 +47,13 @@ pub enum ServerEvent {
enum ClientEvent {
// Currently no interactive client events for the general chat WS
Ping,
+ SetIdleStatus { is_idle: bool },
}
impl ChatHub {
pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender) {
let mut clients = self.clients.write().await;
- clients.insert(user_id, tx);
+ clients.insert(user_id, ChatClient { tx, is_idle: false });
}
pub async fn remove_client(&self, user_id: Uuid) {
@@ -47,33 +61,54 @@ impl ChatHub {
clients.remove(&user_id);
}
- pub async fn get_online_users(&self) -> Vec {
+ pub async fn get_online_users(&self) -> Vec {
let clients = self.clients.read().await;
- clients.keys().cloned().collect()
+ clients
+ .iter()
+ .map(|(id, client)| OnlineUser {
+ user_id: *id,
+ idle: client.is_idle,
+ })
+ .collect()
}
pub async fn broadcast_all(&self, event: ServerEvent) {
let clients = self.clients.read().await;
- for tx in clients.values() {
- let _ = tx.send(event.clone());
+ for client in clients.values() {
+ let _ = client.tx.send(event.clone());
}
}
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
let clients = self.clients.read().await;
- if let Some(tx) = clients.get(&user_id) {
- let _ = tx.send(event);
+ if let Some(client) = clients.get(&user_id) {
+ let _ = client.tx.send(event);
}
}
pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) {
let clients = self.clients.read().await;
for user_id in user_ids {
- if let Some(tx) = clients.get(&user_id) {
- let _ = tx.send(event.clone());
+ if let Some(client) = clients.get(&user_id) {
+ let _ = client.tx.send(event.clone());
}
}
}
+
+ pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) {
+ {
+ let mut clients = self.clients.write().await;
+ if let Some(client) = clients.get_mut(&user_id) {
+ client.is_idle = is_idle;
+ }
+ }
+ self.broadcast_all(ServerEvent::UserPresence {
+ user_id,
+ online: true,
+ idle: is_idle,
+ })
+ .await;
+ }
}
pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
@@ -86,6 +121,7 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
.broadcast_all(ServerEvent::UserPresence {
user_id,
online: true,
+ idle: false,
})
.await;
@@ -101,8 +137,16 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
});
while let Some(Ok(msg)) = ws_receiver.next().await {
- if let Message::Close(_) = msg {
- break;
+ match msg {
+ Message::Close(_) => break,
+ Message::Text(text) => {
+ if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
+ serde_json::from_str::(&text)
+ {
+ state.chat.set_idle_status(user_id, is_idle).await;
+ }
+ }
+ _ => {}
}
}
@@ -113,6 +157,7 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
.broadcast_all(ServerEvent::UserPresence {
user_id,
online: false,
+ idle: false,
})
.await;
}
diff --git a/src/voice.rs b/src/voice.rs
index a0a34f0..551fffd 100644
--- a/src/voice.rs
+++ b/src/voice.rs
@@ -19,6 +19,7 @@ struct ClientHandle {
is_sharing_video: bool,
is_sharing_screen: bool,
is_speaking: bool,
+ is_muted: bool,
tx: mpsc::UnboundedSender,
}
@@ -29,6 +30,7 @@ pub struct VoiceParticipant {
pub is_sharing_video: bool,
pub is_sharing_screen: bool,
pub is_speaking: bool,
+ pub is_muted: bool,
}
#[derive(Serialize, Clone)]
@@ -56,6 +58,10 @@ enum ServerEvent {
user_id: Uuid,
is_speaking: bool,
},
+ MuteStatusChanged {
+ user_id: Uuid,
+ is_muted: bool,
+ },
Signal {
from_user_id: Uuid,
kind: String,
@@ -87,6 +93,9 @@ enum ClientEvent {
SetSpeakingStatus {
is_speaking: bool,
},
+ SetMuteStatus {
+ is_muted: bool,
+ },
PlaySound {
sound_url: String,
},
@@ -106,6 +115,7 @@ impl VoiceHub {
is_sharing_video: handle.is_sharing_video,
is_sharing_screen: handle.is_sharing_screen,
is_speaking: handle.is_speaking,
+ is_muted: handle.is_muted,
})
.collect()
}
@@ -128,6 +138,7 @@ impl VoiceHub {
is_sharing_video: peer.is_sharing_video,
is_sharing_screen: peer.is_sharing_screen,
is_speaking: peer.is_speaking,
+ is_muted: peer.is_muted,
})
.collect::>();
@@ -138,6 +149,7 @@ impl VoiceHub {
is_sharing_video: false,
is_sharing_screen: false,
is_speaking: false,
+ is_muted: false,
tx,
},
);
@@ -253,6 +265,25 @@ impl VoiceHub {
}
}
+ pub async fn set_mute_status(&self, room_id: Uuid, user_id: Uuid, is_muted: bool) {
+ let mut rooms = self.rooms.write().await;
+ let Some(room) = rooms.get_mut(&room_id) else {
+ return;
+ };
+
+ if let Some(handle) = room.get_mut(&user_id) {
+ handle.is_muted = is_muted;
+
+ for (peer_id, peer) in room.iter() {
+ if *peer_id != user_id {
+ let _ = peer
+ .tx
+ .send(ServerEvent::MuteStatusChanged { user_id, is_muted });
+ }
+ }
+ }
+ }
+
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) {
let rooms = self.rooms.read().await;
let Some(room) = rooms.get(&room_id) else {
@@ -329,6 +360,12 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
.set_speaking_status(room_id, user_id, is_speaking)
.await;
}
+ Ok(ClientEvent::SetMuteStatus { is_muted }) => {
+ state
+ .voice
+ .set_mute_status(room_id, user_id, is_muted)
+ .await;
+ }
Ok(ClientEvent::PlaySound { sound_url }) => {
state.voice.play_sound(room_id, user_id, sound_url).await;
}
diff --git a/static/app.js b/static/app.js
index b7a0a26..c226014 100644
--- a/static/app.js
+++ b/static/app.js
@@ -32,6 +32,7 @@ const state = {
chatWs: null,
lastMessageId: null,
onlineUsers: new Set(),
+ idleUsers: new Set(),
};
@@ -363,7 +364,7 @@ function renderChannels() {
const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
- pRow.innerHTML = `${shortName(p.display_name)}
${escapeHtml(p.display_name)}`;
+ pRow.innerHTML = `${shortName(p.display_name)}
${escapeHtml(p.display_name)}${p.is_muted ? '' : ''}`;
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
@@ -377,12 +378,13 @@ function renderDMs() {
el.dmList.innerHTML = "";
for (const dm of state.dmConversations) {
const isOnline = state.onlineUsers.has(dm.user_id);
+ const isIdle = state.idleUsers.has(dm.user_id);
const row = document.createElement("button");
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
row.innerHTML = `
${escapeHtml(dm.display_name)}
`;
@@ -450,12 +452,13 @@ function renderMembers() {
el.memberList.innerHTML = "";
for (const m of state.members) {
const isOnline = state.onlineUsers.has(m.id);
+ const isIdle = state.idleUsers.has(m.id);
const row = document.createElement("div");
row.className = "member-row";
row.innerHTML = `
${shortName(m.display_name)}
-
+
${escapeHtml(m.display_name)}
`;
@@ -626,8 +629,14 @@ function initChatWs() {
} else if (msg.type === "user_presence") {
if (msg.online) {
state.onlineUsers.add(msg.user_id);
+ if (msg.idle) {
+ state.idleUsers.add(msg.user_id);
+ } else {
+ state.idleUsers.delete(msg.user_id);
+ }
} else {
state.onlineUsers.delete(msg.user_id);
+ state.idleUsers.delete(msg.user_id);
}
renderMembers();
renderDMs();
@@ -1086,8 +1095,8 @@ async function joinVoice() {
return;
}
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
- state.voice.ws &&
- state.voice.ws.readyState === WebSocket.OPEN) {
+ state.voice.ws &&
+ state.voice.ws.readyState === WebSocket.OPEN) {
return;
}
@@ -1190,6 +1199,11 @@ async function joinVoice() {
if (videoEl) {
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
}
+ } else if (msg.type === "mute_status_changed") {
+ state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
+ if (p.user_id === msg.user_id) p.is_muted = msg.is_muted;
+ });
+ renderChannels();
} else if (msg.type === "play_sound") {
const audio = new Audio(msg.sound_url);
audio.play().catch(console.error);
@@ -1238,6 +1252,9 @@ function toggleMute() {
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = !state.voice.muted;
});
+ if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
+ state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted }));
+ }
el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : '';
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
lucide.createIcons();
@@ -1443,6 +1460,30 @@ function closeMobileMenus() {
// --- Initialization ---
+let inactivityTimer = null;
+let isCurrentlyIdle = false;
+
+function resetInactivityTimer() {
+ if (isCurrentlyIdle) {
+ isCurrentlyIdle = false;
+ if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
+ state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false }));
+ }
+ }
+
+ if (inactivityTimer) clearTimeout(inactivityTimer);
+ inactivityTimer = setTimeout(() => {
+ isCurrentlyIdle = true;
+ if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
+ state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true }));
+ }
+ }, 5 * 60 * 1000); // 5 minutes
+}
+
+document.addEventListener('mousemove', resetInactivityTimer);
+document.addEventListener('keydown', resetInactivityTimer);
+document.addEventListener('click', resetInactivityTimer);
+
async function init() {
lucide.createIcons();
diff --git a/static/styles.css b/static/styles.css
index 688d912..f12cd12 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -571,6 +571,19 @@ select {
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
+.status-dot.idle {
+ background: var(--yellow);
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
+}
+
+.voice-muted-icon {
+ width: 14px;
+ height: 14px;
+ color: var(--danger);
+ margin-left: 6px;
+ vertical-align: text-bottom;
+}
+
.user-info {
flex: 1;
min-width: 0;
@@ -1547,4 +1560,4 @@ select {
.chat-header {
padding: 0 8px;
}
-}
+}
\ No newline at end of file