From d1a68c635c7427611109afb474e0c17da3292d4e Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 13 Feb 2026 19:52:42 +0100 Subject: [PATCH] ui stuff --- src/chat.rs | 88 +++++++++++++++++++++++++++++++ src/db.rs | 126 ++++++++++++++++++++++++++++++++++---------- src/handlers.rs | 125 +++++++++++++++++++++++++++++++++++++------- src/main.rs | 3 ++ static/app.js | 136 +++++++++++++++++++++++++++++++----------------- 5 files changed, 384 insertions(+), 94 deletions(-) create mode 100644 src/chat.rs diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..47c18b3 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,88 @@ +use crate::AppState; +use axum::extract::ws::{Message, WebSocket}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +#[derive(Default)] +pub struct ChatHub { + // user_id -> sender + clients: RwLock>>, +} + +#[derive(Serialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerEvent { + MessageCreated { + channel_id: Uuid, + message: serde_json::Value, + }, + DmCreated { + other_user_id: Uuid, + message: serde_json::Value, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientEvent { + // Currently no interactive client events for the general chat WS + Ping, +} + +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); + } + + pub async fn remove_client(&self, user_id: Uuid) { + let mut clients = self.clients.write().await; + clients.remove(&user_id); + } + + 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); + } + } + + 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()); + } + } + } +} + +pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { + let (mut ws_sender, mut ws_receiver) = socket.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + state.chat.add_client(user_id, tx).await; + + let send_task = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let Ok(payload) = serde_json::to_string(&event) else { + continue; + }; + if ws_sender.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + }); + + while let Some(Ok(msg)) = ws_receiver.next().await { + if let Message::Close(_) = msg { + break; + } + } + + send_task.abort(); + state.chat.remove_client(user_id).await; +} diff --git a/src/db.rs b/src/db.rs index df877ed..cd6a234 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,18 @@ use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, - Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, + DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{channels, direct_messages, guild_members, guilds, invites, messages, users}, - models::{BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, User}, + models::{ + BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, + User, + }, }; pub const CHANNEL_KIND_TEXT: &str = "text"; @@ -42,7 +45,10 @@ pub async fn upsert_user_from_oidc( users::Column::DisplayName, users::Column::AvatarUrl, ]) - .value(users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp()) + .value( + users::Column::UpdatedAt, + sea_orm::sea_query::Expr::current_timestamp(), + ) .to_owned(), ) .exec_with_returning(db) @@ -75,6 +81,15 @@ pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Resu .collect()) } +pub async fn list_guild_member_ids(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let rows = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.eq(guild_id)) + .all(db) + .await?; + + Ok(rows.into_iter().map(|m| m.user_id).collect()) +} + pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result> { let rows = guild_members::Entity::find() .filter(guild_members::Column::UserId.eq(user_id)) @@ -88,7 +103,11 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res .collect()) } -pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result { +pub async fn create_guild( + db: &DatabaseConnection, + owner_user_id: Uuid, + name: &str, +) -> Result { let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), @@ -104,9 +123,12 @@ pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &s ..Default::default() }) .on_conflict( - OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) - .do_nothing() - .to_owned(), + OnConflict::columns([ + guild_members::Column::GuildId, + guild_members::Column::UserId, + ]) + .do_nothing() + .to_owned(), ) .exec(db) .await?; @@ -176,9 +198,12 @@ pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> ..Default::default() }) .on_conflict( - OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) - .do_nothing() - .to_owned(), + OnConflict::columns([ + guild_members::Column::GuildId, + guild_members::Column::UserId, + ]) + .do_nothing() + .to_owned(), ) .exec(&txn) .await?; @@ -215,12 +240,18 @@ pub async fn create_channel( Ok(map_channel(channel)) } -pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result> { +pub async fn get_channel_by_id( + db: &DatabaseConnection, + channel_id: Uuid, +) -> Result> { let row = channels::Entity::find_by_id(channel_id).one(db).await?; Ok(row.map(map_channel)) } -pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { +pub async fn list_channels_for_guild( + db: &DatabaseConnection, + guild_id: Uuid, +) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .order_by_asc(channels::Column::CreatedAt) @@ -230,7 +261,10 @@ pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Ok(channels.into_iter().map(map_channel).collect()) } -pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { +pub async fn list_voice_channels_for_guild( + db: &DatabaseConnection, + guild_id: Uuid, +) -> Result> { let channels = channels::Entity::find() .filter(channels::Column::GuildId.eq(guild_id)) .filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE)) @@ -241,7 +275,11 @@ pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uu Ok(channels.into_iter().map(map_channel).collect()) } -pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result { +pub async fn is_member_of_guild( + db: &DatabaseConnection, + guild_id: Uuid, + user_id: Uuid, +) -> Result { let count = guild_members::Entity::find() .filter(guild_members::Column::GuildId.eq(guild_id)) .filter(guild_members::Column::UserId.eq(user_id)) @@ -251,7 +289,10 @@ pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id Ok(count > 0) } -pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result> { +pub async fn guild_id_for_channel( + db: &DatabaseConnection, + channel_id: Uuid, +) -> Result> { let guild_id = channels::Entity::find_by_id(channel_id) .select_only() .column(channels::Column::GuildId) @@ -267,18 +308,30 @@ pub async fn create_message( channel_id: Uuid, author_user_id: Uuid, body: &str, -) -> Result<()> { - messages::Entity::insert(messages::ActiveModel { +) -> Result { + let model = messages::Entity::insert(messages::ActiveModel { id: Set(Uuid::new_v4()), channel_id: Set(channel_id), author_user_id: Set(author_user_id), body: Set(body.to_string()), ..Default::default() }) - .exec(db) + .exec_with_returning(db) .await?; - Ok(()) + let user = users::Entity::find_by_id(author_user_id) + .one(db) + .await? + .ok_or_else(|| anyhow!("author not found"))?; + + Ok(MessageWithAuthor { + id: model.id, + channel_id: model.channel_id, + author_user_id: model.author_user_id, + author_display_name: user.display_name, + body: model.body, + created_at: model.created_at, + }) } pub async fn list_messages( @@ -317,18 +370,30 @@ pub async fn create_direct_message( sender_user_id: Uuid, recipient_user_id: Uuid, body: &str, -) -> Result<()> { - direct_messages::Entity::insert(direct_messages::ActiveModel { +) -> Result { + let model = direct_messages::Entity::insert(direct_messages::ActiveModel { id: Set(Uuid::new_v4()), sender_user_id: Set(sender_user_id), recipient_user_id: Set(recipient_user_id), body: Set(body.to_string()), ..Default::default() }) - .exec(db) + .exec_with_returning(db) .await?; - Ok(()) + let user = users::Entity::find_by_id(sender_user_id) + .one(db) + .await? + .ok_or_else(|| anyhow!("sender not found"))?; + + Ok(DmMessageWithAuthor { + id: model.id, + author_user_id: model.sender_user_id, + recipient_user_id: model.recipient_user_id, + author_display_name: user.display_name, + body: model.body, + created_at: model.created_at, + }) } pub async fn list_direct_messages( @@ -385,7 +450,10 @@ pub async fn list_direct_messages( .collect()) } -pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result> { +pub async fn list_dm_conversations( + db: &DatabaseConnection, + current_user_id: Uuid, +) -> Result> { let rows = direct_messages::Entity::find() .filter( Condition::any() @@ -396,7 +464,8 @@ pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uui .all(db) .await?; - let mut latest_by_peer = std::collections::HashMap::>::new(); + let mut latest_by_peer = + std::collections::HashMap::>::new(); for row in rows { let peer_id = if row.sender_user_id == current_user_id { row.recipient_user_id @@ -496,7 +565,10 @@ fn validate_invite(invite: &invites::Model) -> Result<()> { Ok(()) } -async fn increment_invite_use_count(txn: &DatabaseTransaction, invite: invites::Model) -> Result<()> { +async fn increment_invite_use_count( + txn: &DatabaseTransaction, + invite: invites::Model, +) -> Result<()> { let next_count = invite.use_count + 1; let mut active: invites::ActiveModel = invite.into(); active.use_count = Set(next_count); diff --git a/src/handlers.rs b/src/handlers.rs index d86deec..fa9dc93 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::{ AppState, auth::{self, ApiError, AuthUser}, - db, voice, + chat, db, voice, }; pub fn routes() -> Router { @@ -22,17 +22,27 @@ pub fn routes() -> Router { .route("/auth/logout", post(auth_logout)) .route("/me", get(me)) .route("/dms", get(list_dm_conversations)) - .route("/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message)) + .route( + "/dms/{other_user_id}/messages", + get(list_dm_messages).post(send_dm_message), + ) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) .route("/guilds/{guild_id}/members", get(list_guild_members)) .route("/guilds/{guild_id}/channels", get(list_channels)) - .route("/guilds/{guild_id}/voice-presence", get(guild_voice_presence)) + .route( + "/guilds/{guild_id}/voice-presence", + get(guild_voice_presence), + ) .route("/guilds/{guild_id}/invites", post(create_invite)) .route("/invites/{code}/join", post(join_invite)) .route("/channels", post(create_channel)) - .route("/channels/{channel_id}/messages", get(list_messages).post(send_message)) + .route( + "/channels/{channel_id}/messages", + get(list_messages).post(send_message), + ) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) + .route("/ws", get(chat_ws)) } pub async fn health() -> &'static str { @@ -142,7 +152,10 @@ async fn auth_callback( .map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?; if !token_res.status().is_success() { - let text = token_res.text().await.unwrap_or_else(|_| "".to_string()); + let text = token_res + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(ApiError::bad_request(&format!( "token exchange returned non-success: {text}" ))); @@ -162,7 +175,10 @@ async fn auth_callback( .map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?; if !userinfo_res.status().is_success() { - let text = userinfo_res.text().await.unwrap_or_else(|_| "".to_string()); + let text = userinfo_res + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(ApiError::bad_request(&format!( "userinfo returned non-success: {text}" ))); @@ -185,9 +201,12 @@ async fn auth_callback( .await .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; - let session_cookie = - auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure) - .map_err(|e| ApiError::internal(&e.to_string()))?; + let session_cookie = auth::new_session_cookie( + user.id, + &state.settings.session_secret, + state.settings.cookie_secure, + ) + .map_err(|e| ApiError::internal(&e.to_string()))?; let mut headers = HeaderMap::new(); headers.append( @@ -232,7 +251,10 @@ struct RtcIceServer { credential: Option, } -async fn rtc_config(State(state): State, _user: AuthUser) -> Result { +async fn rtc_config( + State(state): State, + _user: AuthUser, +) -> Result { let mut ice_servers = Vec::new(); if !state.settings.stun_urls.is_empty() { @@ -441,9 +463,13 @@ async fn create_channel( return Err(ApiError::bad_request("channel name must be 1..64 chars")); } - let kind = body.kind.unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string()); + let kind = body + .kind + .unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string()); if kind != db::CHANNEL_KIND_TEXT && kind != db::CHANNEL_KIND_VOICE { - return Err(ApiError::bad_request("channel kind must be 'text' or 'voice'")); + return Err(ApiError::bad_request( + "channel kind must be 'text' or 'voice'", + )); } let channel = db::create_channel(&state.db, body.guild_id, trimmed, &kind) @@ -497,10 +523,31 @@ async fn send_message( return Err(ApiError::bad_request("message body must be 1..4000 chars")); } - db::create_message(&state.db, channel_id, user.id, content) + let message = db::create_message(&state.db, channel_id, user.id, content) .await .map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?; + // Broadcast to all guild members + let guild_id = db::guild_id_for_channel(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? + .ok_or_else(|| ApiError::internal("channel not found after creation"))?; + + let members = db::list_guild_member_ids(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?; + + state + .chat + .broadcast_to_many( + members, + chat::ServerEvent::MessageCreated { + channel_id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } @@ -557,10 +604,32 @@ async fn send_dm_message( return Err(ApiError::bad_request("message body must be 1..4000 chars")); } - db::create_direct_message(&state.db, user.id, other_user_id, content) + let message = db::create_direct_message(&state.db, user.id, other_user_id, content) .await .map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?; + // Broadcast to both users + state + .chat + .broadcast_to_user( + user.id, + chat::ServerEvent::DmCreated { + other_user_id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + state + .chat + .broadcast_to_user( + other_user_id, + chat::ServerEvent::DmCreated { + other_user_id: user.id, + message: serde_json::to_value(&message).unwrap_or_default(), + }, + ) + .await; + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } @@ -581,15 +650,27 @@ async fn voice_ws( })?; if channel.kind != db::CHANNEL_KIND_VOICE { - return Err(ApiError::bad_request("voice websocket requires a voice channel")); + return Err(ApiError::bad_request( + "voice websocket requires a voice channel", + )); } - Ok(ws.on_upgrade(move |socket| { - voice::handle_socket(state, socket, channel_id, user.id) - })) + Ok(ws.on_upgrade(move |socket| voice::handle_socket(state, socket, channel_id, user.id))) } -async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { +async fn chat_ws( + ws: WebSocketUpgrade, + State(state): State, + user: AuthUser, +) -> Result { + Ok(ws.on_upgrade(move |socket| chat::handle_socket(state, socket, user.id))) +} + +async fn ensure_guild_member( + state: &AppState, + guild_id: Uuid, + user_id: Uuid, +) -> Result<(), ApiError> { let is_member = db::is_member_of_guild(&state.db, guild_id, user_id) .await .map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?; @@ -604,7 +685,11 @@ async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Ok(()) } -async fn ensure_channel_member(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { +async fn ensure_channel_member( + state: &AppState, + channel_id: Uuid, + user_id: Uuid, +) -> Result<(), ApiError> { let guild_id = db::guild_id_for_channel(&state.db, channel_id) .await .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? diff --git a/src/main.rs b/src/main.rs index fdeaeb9..53ea6f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod auth; +mod chat; mod config; mod db; mod entity; @@ -24,6 +25,7 @@ pub struct AppState { pub settings: Arc, pub http: reqwest::Client, pub voice: Arc, + pub chat: Arc, } #[tokio::main] @@ -46,6 +48,7 @@ async fn main() -> anyhow::Result<()> { settings, http: reqwest::Client::new(), voice: Arc::new(VoiceHub::default()), + chat: Arc::new(chat::ChatHub::default()), }; let port = state.settings.port; diff --git a/static/app.js b/static/app.js index 6bbe240..62ecc6a 100644 --- a/static/app.js +++ b/static/app.js @@ -16,13 +16,13 @@ const state = { localStream: null, rawStream: null, audioContext: null, - denoiserNode: null, - deepFilterCore: null, peerConnections: new Map(), muted: false, iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }, voicePresencePollId: null, + chatWs: null, + lastMessageId: null, }; const el = { @@ -140,10 +140,47 @@ function formatDate(isoString) { return d.toLocaleDateString(); } -const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js"; -const NSNET2_COMPAT_SUPPRESSION = 56; +let audioCtx = null; +function playSound(type) { + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); -let deepFilterLibPromise = null; + const sounds = { + message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 }, + dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 }, + join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 }, + leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 }, + 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 }, + 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 }, + }; + + const s = sounds[type]; + if (!s) return; + + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + + osc.type = s.type; + if (Array.isArray(s.freq)) { + osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration); + } else { + osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime); + } + + gain.gain.setValueAtTime(s.volume, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration); + + osc.connect(gain); + gain.connect(audioCtx.destination); + + osc.start(); + osc.stop(audioCtx.currentTime + s.duration); + } catch (err) { + console.warn("playSound failed", err); + } +} const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; // --- Renderers --- @@ -183,7 +220,7 @@ function renderChannels() { for (const channel of state.channels) { const row = document.createElement("button"); row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) || - (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" + (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" }`; const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; @@ -291,6 +328,14 @@ function renderMessages(messages) { lastTime = mDate; } + if (messages.length > 0) { + const latest = messages[0]; + if (state.lastMessageId && latest.id !== state.lastMessageId && latest.author_user_id !== state.me?.id) { + playSound(state.selectedDmUserId ? 'dm' : 'message'); + } + state.lastMessageId = latest.id; + } + el.messageList.scrollTop = el.messageList.scrollHeight; } @@ -422,6 +467,39 @@ function startVoicePresencePolling() { }, 3000); } +function initChatWs() { + if (state.chatWs) state.chatWs.close(); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${location.host}/ws`; + const ws = new WebSocket(wsUrl); + state.chatWs = ws; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "message_created") { + if (state.selectedTextChannelId === msg.channel_id) { + // Optimization: we could just push the message, but refresh for now + api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); + } else { + // Even if not active, play sound + playSound('message'); + } + } else if (msg.type === "dm_created") { + if (state.selectedDmUserId === msg.other_user_id) { + api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); + } else { + playSound('dm'); + loadDMConversations().then(renderDMs); + } + } + }; + + ws.onclose = () => { + console.log("Chat WS closed, reconnecting..."); + setTimeout(initChatWs, 3000); + }; +} + // --- Voice --- function getVoiceWsUrl(channelId) { @@ -434,40 +512,7 @@ function shouldInitiateOffer(peerId) { return state.me.id > peerId; } -async function loadDeepFilterLib() { - if (!deepFilterLibPromise) { - deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH); - } - return deepFilterLibPromise; -} - -async function buildDenoiserNode(audioContext) { - if (!audioContext.audioWorklet) { - throw new Error("AudioWorklet is not supported in this browser"); - } - - const df = await loadDeepFilterLib(); - const core = new df.DeepFilterNet3Core({ - sampleRate: 48000, - noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION, - assetConfig: { - cdnUrl: "/static/vendor/deepfilternet3", - }, - }); - await core.initialize(); - const workletNode = await core.createAudioWorkletNode(audioContext); - state.voice.deepFilterCore = core; - return workletNode; -} - function stopAndClearAudioPipeline() { - if (state.voice.deepFilterCore) { - state.voice.deepFilterCore.destroy(); - state.voice.deepFilterCore = null; - } - if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") { - state.voice.denoiserNode.destroy(); - } if (state.voice.localStream) { for (const track of state.voice.localStream.getTracks()) track.stop(); } @@ -480,8 +525,6 @@ function stopAndClearAudioPipeline() { state.voice.localStream = null; state.voice.rawStream = null; state.voice.audioContext = null; - state.voice.denoiserNode = null; - state.voice.deepFilterCore = null; } async function buildAudioPipeline(rawStream) { @@ -492,14 +535,8 @@ async function buildAudioPipeline(rawStream) { let head = source; - const denoiserNode = await buildDenoiserNode(audioContext); - if (denoiserNode) { - head.connect(denoiserNode); - head = denoiserNode; - } head.connect(destination); - state.voice.denoiserNode = denoiserNode; return destination.stream; } @@ -621,6 +658,7 @@ async function joinVoice() { const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); el.vcChannelName.textContent = channel ? channel.name : "Voice"; el.voiceConnection.classList.remove("hidden"); + playSound('join'); refreshVoicePresence().catch(() => { }); }; @@ -631,8 +669,10 @@ async function joinVoice() { if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id); } } else if (msg.type === "peer_joined") { + playSound('peer-join'); if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id); } else if (msg.type === "peer_left") { + playSound('peer-leave'); const pc = state.voice.peerConnections.get(msg.user_id); if (pc) { pc.close(); @@ -652,6 +692,7 @@ async function joinVoice() { stopAndClearAudioPipeline(); state.voice.joinedChannelId = null; state.voice.ws = null; + playSound('leave'); refreshVoicePresence().catch(() => { }); }; } @@ -893,6 +934,7 @@ async function init() { updateHeaderLabels(); } + initChatWs(); startVoicePresencePolling(); lucide.createIcons(); } catch (err) {