indicators
This commit is contained in:
parent
4358d49b48
commit
af46da77a7
6 changed files with 216 additions and 26 deletions
|
|
@ -32,6 +32,7 @@ const state = {
|
||||||
chatWs: null,
|
chatWs: null,
|
||||||
lastMessageId: null,
|
lastMessageId: null,
|
||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
|
idleUsers: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Desktop Backend Configuration ---
|
// --- Desktop Backend Configuration ---
|
||||||
|
|
@ -410,7 +411,7 @@ function renderChannels() {
|
||||||
const pRow = document.createElement("div");
|
const pRow = document.createElement("div");
|
||||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||||
pRow.style.padding = "2px 8px";
|
pRow.style.padding = "2px 8px";
|
||||||
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
|
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}`;
|
||||||
pList.appendChild(pRow);
|
pList.appendChild(pRow);
|
||||||
}
|
}
|
||||||
el.voiceChannelList.appendChild(pList);
|
el.voiceChannelList.appendChild(pList);
|
||||||
|
|
@ -424,12 +425,13 @@ function renderDMs() {
|
||||||
el.dmList.innerHTML = "";
|
el.dmList.innerHTML = "";
|
||||||
for (const dm of state.dmConversations) {
|
for (const dm of state.dmConversations) {
|
||||||
const isOnline = state.onlineUsers.has(dm.user_id);
|
const isOnline = state.onlineUsers.has(dm.user_id);
|
||||||
|
const isIdle = state.idleUsers.has(dm.user_id);
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<i data-lucide="message-circle"></i>
|
<i data-lucide="message-circle"></i>
|
||||||
<div class="status-dot ${isOnline ? "online" : ""}"></div>
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(dm.display_name)}</span>
|
<span>${escapeHtml(dm.display_name)}</span>
|
||||||
`;
|
`;
|
||||||
|
|
@ -497,12 +499,13 @@ function renderMembers() {
|
||||||
el.memberList.innerHTML = "";
|
el.memberList.innerHTML = "";
|
||||||
for (const m of state.members) {
|
for (const m of state.members) {
|
||||||
const isOnline = state.onlineUsers.has(m.id);
|
const isOnline = state.onlineUsers.has(m.id);
|
||||||
|
const isIdle = state.idleUsers.has(m.id);
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "member-row";
|
row.className = "member-row";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="member-avatar">
|
<div class="member-avatar">
|
||||||
${shortName(m.display_name)}
|
${shortName(m.display_name)}
|
||||||
<div class="status-dot ${isOnline ? "online" : ""}"></div>
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -684,8 +687,14 @@ function initChatWs() {
|
||||||
} else if (msg.type === "user_presence") {
|
} else if (msg.type === "user_presence") {
|
||||||
if (msg.online) {
|
if (msg.online) {
|
||||||
state.onlineUsers.add(msg.user_id);
|
state.onlineUsers.add(msg.user_id);
|
||||||
|
if (msg.idle) {
|
||||||
|
state.idleUsers.add(msg.user_id);
|
||||||
|
} else {
|
||||||
|
state.idleUsers.delete(msg.user_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.onlineUsers.delete(msg.user_id);
|
state.onlineUsers.delete(msg.user_id);
|
||||||
|
state.idleUsers.delete(msg.user_id);
|
||||||
}
|
}
|
||||||
renderMembers();
|
renderMembers();
|
||||||
renderDMs();
|
renderDMs();
|
||||||
|
|
@ -1250,6 +1259,11 @@ async function joinVoice() {
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
|
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") {
|
} else if (msg.type === "play_sound") {
|
||||||
const audio = new Audio(msg.sound_url);
|
const audio = new Audio(msg.sound_url);
|
||||||
audio.play().catch(console.error);
|
audio.play().catch(console.error);
|
||||||
|
|
@ -1298,6 +1312,9 @@ function toggleMute() {
|
||||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||||
t.enabled = !state.voice.muted;
|
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 ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
||||||
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
@ -1505,6 +1522,30 @@ function closeMobileMenus() {
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- 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() {
|
async function init() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,19 @@ select {
|
||||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
|
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 {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
71
src/chat.rs
71
src/chat.rs
|
|
@ -6,10 +6,22 @@ use std::collections::HashMap;
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChatClient {
|
||||||
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
|
is_idle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct OnlineUser {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub idle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ChatHub {
|
pub struct ChatHub {
|
||||||
// user_id -> sender
|
// user_id -> client
|
||||||
clients: RwLock<HashMap<Uuid, mpsc::UnboundedSender<ServerEvent>>>,
|
clients: RwLock<HashMap<Uuid, ChatClient>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -26,6 +38,7 @@ pub enum ServerEvent {
|
||||||
UserPresence {
|
UserPresence {
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
online: bool,
|
online: bool,
|
||||||
|
idle: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,12 +47,13 @@ pub enum ServerEvent {
|
||||||
enum ClientEvent {
|
enum ClientEvent {
|
||||||
// Currently no interactive client events for the general chat WS
|
// Currently no interactive client events for the general chat WS
|
||||||
Ping,
|
Ping,
|
||||||
|
SetIdleStatus { is_idle: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatHub {
|
impl ChatHub {
|
||||||
pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender<ServerEvent>) {
|
pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender<ServerEvent>) {
|
||||||
let mut clients = self.clients.write().await;
|
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) {
|
pub async fn remove_client(&self, user_id: Uuid) {
|
||||||
|
|
@ -47,33 +61,54 @@ impl ChatHub {
|
||||||
clients.remove(&user_id);
|
clients.remove(&user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_online_users(&self) -> Vec<Uuid> {
|
pub async fn get_online_users(&self) -> Vec<OnlineUser> {
|
||||||
let clients = self.clients.read().await;
|
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) {
|
pub async fn broadcast_all(&self, event: ServerEvent) {
|
||||||
let clients = self.clients.read().await;
|
let clients = self.clients.read().await;
|
||||||
for tx in clients.values() {
|
for client in clients.values() {
|
||||||
let _ = tx.send(event.clone());
|
let _ = client.tx.send(event.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
|
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
|
||||||
let clients = self.clients.read().await;
|
let clients = self.clients.read().await;
|
||||||
if let Some(tx) = clients.get(&user_id) {
|
if let Some(client) = clients.get(&user_id) {
|
||||||
let _ = tx.send(event);
|
let _ = client.tx.send(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
|
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
|
||||||
let clients = self.clients.read().await;
|
let clients = self.clients.read().await;
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
if let Some(tx) = clients.get(&user_id) {
|
if let Some(client) = clients.get(&user_id) {
|
||||||
let _ = tx.send(event.clone());
|
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) {
|
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 {
|
.broadcast_all(ServerEvent::UserPresence {
|
||||||
user_id,
|
user_id,
|
||||||
online: true,
|
online: true,
|
||||||
|
idle: false,
|
||||||
})
|
})
|
||||||
.await;
|
.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 {
|
while let Some(Ok(msg)) = ws_receiver.next().await {
|
||||||
if let Message::Close(_) = msg {
|
match msg {
|
||||||
break;
|
Message::Close(_) => break,
|
||||||
|
Message::Text(text) => {
|
||||||
|
if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
|
||||||
|
serde_json::from_str::<ClientEvent>(&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 {
|
.broadcast_all(ServerEvent::UserPresence {
|
||||||
user_id,
|
user_id,
|
||||||
online: false,
|
online: false,
|
||||||
|
idle: false,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/voice.rs
37
src/voice.rs
|
|
@ -19,6 +19,7 @@ struct ClientHandle {
|
||||||
is_sharing_video: bool,
|
is_sharing_video: bool,
|
||||||
is_sharing_screen: bool,
|
is_sharing_screen: bool,
|
||||||
is_speaking: bool,
|
is_speaking: bool,
|
||||||
|
is_muted: bool,
|
||||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ pub struct VoiceParticipant {
|
||||||
pub is_sharing_video: bool,
|
pub is_sharing_video: bool,
|
||||||
pub is_sharing_screen: bool,
|
pub is_sharing_screen: bool,
|
||||||
pub is_speaking: bool,
|
pub is_speaking: bool,
|
||||||
|
pub is_muted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
@ -56,6 +58,10 @@ enum ServerEvent {
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
is_speaking: bool,
|
is_speaking: bool,
|
||||||
},
|
},
|
||||||
|
MuteStatusChanged {
|
||||||
|
user_id: Uuid,
|
||||||
|
is_muted: bool,
|
||||||
|
},
|
||||||
Signal {
|
Signal {
|
||||||
from_user_id: Uuid,
|
from_user_id: Uuid,
|
||||||
kind: String,
|
kind: String,
|
||||||
|
|
@ -87,6 +93,9 @@ enum ClientEvent {
|
||||||
SetSpeakingStatus {
|
SetSpeakingStatus {
|
||||||
is_speaking: bool,
|
is_speaking: bool,
|
||||||
},
|
},
|
||||||
|
SetMuteStatus {
|
||||||
|
is_muted: bool,
|
||||||
|
},
|
||||||
PlaySound {
|
PlaySound {
|
||||||
sound_url: String,
|
sound_url: String,
|
||||||
},
|
},
|
||||||
|
|
@ -106,6 +115,7 @@ impl VoiceHub {
|
||||||
is_sharing_video: handle.is_sharing_video,
|
is_sharing_video: handle.is_sharing_video,
|
||||||
is_sharing_screen: handle.is_sharing_screen,
|
is_sharing_screen: handle.is_sharing_screen,
|
||||||
is_speaking: handle.is_speaking,
|
is_speaking: handle.is_speaking,
|
||||||
|
is_muted: handle.is_muted,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +138,7 @@ impl VoiceHub {
|
||||||
is_sharing_video: peer.is_sharing_video,
|
is_sharing_video: peer.is_sharing_video,
|
||||||
is_sharing_screen: peer.is_sharing_screen,
|
is_sharing_screen: peer.is_sharing_screen,
|
||||||
is_speaking: peer.is_speaking,
|
is_speaking: peer.is_speaking,
|
||||||
|
is_muted: peer.is_muted,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
@ -138,6 +149,7 @@ impl VoiceHub {
|
||||||
is_sharing_video: false,
|
is_sharing_video: false,
|
||||||
is_sharing_screen: false,
|
is_sharing_screen: false,
|
||||||
is_speaking: false,
|
is_speaking: false,
|
||||||
|
is_muted: false,
|
||||||
tx,
|
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) {
|
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, sound_url: String) {
|
||||||
let rooms = self.rooms.read().await;
|
let rooms = self.rooms.read().await;
|
||||||
let Some(room) = rooms.get(&room_id) else {
|
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)
|
.set_speaking_status(room_id, user_id, is_speaking)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
|
||||||
|
state
|
||||||
|
.voice
|
||||||
|
.set_mute_status(room_id, user_id, is_muted)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(ClientEvent::PlaySound { sound_url }) => {
|
Ok(ClientEvent::PlaySound { sound_url }) => {
|
||||||
state.voice.play_sound(room_id, user_id, sound_url).await;
|
state.voice.play_sound(room_id, user_id, sound_url).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const state = {
|
||||||
chatWs: null,
|
chatWs: null,
|
||||||
lastMessageId: null,
|
lastMessageId: null,
|
||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
|
idleUsers: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -363,7 +364,7 @@ function renderChannels() {
|
||||||
const pRow = document.createElement("div");
|
const pRow = document.createElement("div");
|
||||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||||
pRow.style.padding = "2px 8px";
|
pRow.style.padding = "2px 8px";
|
||||||
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
|
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}`;
|
||||||
pList.appendChild(pRow);
|
pList.appendChild(pRow);
|
||||||
}
|
}
|
||||||
el.voiceChannelList.appendChild(pList);
|
el.voiceChannelList.appendChild(pList);
|
||||||
|
|
@ -377,12 +378,13 @@ function renderDMs() {
|
||||||
el.dmList.innerHTML = "";
|
el.dmList.innerHTML = "";
|
||||||
for (const dm of state.dmConversations) {
|
for (const dm of state.dmConversations) {
|
||||||
const isOnline = state.onlineUsers.has(dm.user_id);
|
const isOnline = state.onlineUsers.has(dm.user_id);
|
||||||
|
const isIdle = state.idleUsers.has(dm.user_id);
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<i data-lucide="message-circle"></i>
|
<i data-lucide="message-circle"></i>
|
||||||
<div class="status-dot ${isOnline ? "online" : ""}"></div>
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
||||||
</div>
|
</div>
|
||||||
<span>${escapeHtml(dm.display_name)}</span>
|
<span>${escapeHtml(dm.display_name)}</span>
|
||||||
`;
|
`;
|
||||||
|
|
@ -450,12 +452,13 @@ function renderMembers() {
|
||||||
el.memberList.innerHTML = "";
|
el.memberList.innerHTML = "";
|
||||||
for (const m of state.members) {
|
for (const m of state.members) {
|
||||||
const isOnline = state.onlineUsers.has(m.id);
|
const isOnline = state.onlineUsers.has(m.id);
|
||||||
|
const isIdle = state.idleUsers.has(m.id);
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "member-row";
|
row.className = "member-row";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="member-avatar">
|
<div class="member-avatar">
|
||||||
${shortName(m.display_name)}
|
${shortName(m.display_name)}
|
||||||
<div class="status-dot ${isOnline ? "online" : ""}"></div>
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -626,8 +629,14 @@ function initChatWs() {
|
||||||
} else if (msg.type === "user_presence") {
|
} else if (msg.type === "user_presence") {
|
||||||
if (msg.online) {
|
if (msg.online) {
|
||||||
state.onlineUsers.add(msg.user_id);
|
state.onlineUsers.add(msg.user_id);
|
||||||
|
if (msg.idle) {
|
||||||
|
state.idleUsers.add(msg.user_id);
|
||||||
|
} else {
|
||||||
|
state.idleUsers.delete(msg.user_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.onlineUsers.delete(msg.user_id);
|
state.onlineUsers.delete(msg.user_id);
|
||||||
|
state.idleUsers.delete(msg.user_id);
|
||||||
}
|
}
|
||||||
renderMembers();
|
renderMembers();
|
||||||
renderDMs();
|
renderDMs();
|
||||||
|
|
@ -1190,6 +1199,11 @@ async function joinVoice() {
|
||||||
if (videoEl) {
|
if (videoEl) {
|
||||||
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
|
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") {
|
} else if (msg.type === "play_sound") {
|
||||||
const audio = new Audio(msg.sound_url);
|
const audio = new Audio(msg.sound_url);
|
||||||
audio.play().catch(console.error);
|
audio.play().catch(console.error);
|
||||||
|
|
@ -1238,6 +1252,9 @@ function toggleMute() {
|
||||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||||
t.enabled = !state.voice.muted;
|
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 ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
||||||
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
@ -1443,6 +1460,30 @@ function closeMobileMenus() {
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- 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() {
|
async function init() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -571,6 +571,19 @@ select {
|
||||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
|
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 {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue