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