ui stuff
This commit is contained in:
parent
0d79b42072
commit
d1a68c635c
5 changed files with 384 additions and 94 deletions
88
src/chat.rs
Normal file
88
src/chat.rs
Normal file
|
|
@ -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<HashMap<Uuid, mpsc::UnboundedSender<ServerEvent>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ServerEvent>) {
|
||||||
|
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<Uuid>, 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::<ServerEvent>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
118
src/db.rs
118
src/db.rs
|
|
@ -1,15 +1,18 @@
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection,
|
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection,
|
||||||
Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
TransactionTrait, sea_query::OnConflict,
|
TransactionTrait, sea_query::OnConflict,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
entity::{channels, direct_messages, guild_members, guilds, invites, messages, users},
|
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";
|
pub const CHANNEL_KIND_TEXT: &str = "text";
|
||||||
|
|
@ -42,7 +45,10 @@ pub async fn upsert_user_from_oidc(
|
||||||
users::Column::DisplayName,
|
users::Column::DisplayName,
|
||||||
users::Column::AvatarUrl,
|
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(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.exec_with_returning(db)
|
.exec_with_returning(db)
|
||||||
|
|
@ -75,6 +81,15 @@ pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Resu
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_guild_member_ids(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Uuid>> {
|
||||||
|
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<Vec<Guild>> {
|
pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<Guild>> {
|
||||||
let rows = guild_members::Entity::find()
|
let rows = guild_members::Entity::find()
|
||||||
.filter(guild_members::Column::UserId.eq(user_id))
|
.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())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result<Guild> {
|
pub async fn create_guild(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
owner_user_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Guild> {
|
||||||
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
name: Set(name.to_string()),
|
name: Set(name.to_string()),
|
||||||
|
|
@ -104,7 +123,10 @@ pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &s
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.on_conflict(
|
.on_conflict(
|
||||||
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
OnConflict::columns([
|
||||||
|
guild_members::Column::GuildId,
|
||||||
|
guild_members::Column::UserId,
|
||||||
|
])
|
||||||
.do_nothing()
|
.do_nothing()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
|
|
@ -176,7 +198,10 @@ pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) ->
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.on_conflict(
|
.on_conflict(
|
||||||
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
OnConflict::columns([
|
||||||
|
guild_members::Column::GuildId,
|
||||||
|
guild_members::Column::UserId,
|
||||||
|
])
|
||||||
.do_nothing()
|
.do_nothing()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
|
|
@ -215,12 +240,18 @@ pub async fn create_channel(
|
||||||
Ok(map_channel(channel))
|
Ok(map_channel(channel))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Channel>> {
|
pub async fn get_channel_by_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Option<Channel>> {
|
||||||
let row = channels::Entity::find_by_id(channel_id).one(db).await?;
|
let row = channels::Entity::find_by_id(channel_id).one(db).await?;
|
||||||
Ok(row.map(map_channel))
|
Ok(row.map(map_channel))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
pub async fn list_channels_for_guild(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
guild_id: Uuid,
|
||||||
|
) -> Result<Vec<Channel>> {
|
||||||
let channels = channels::Entity::find()
|
let channels = channels::Entity::find()
|
||||||
.filter(channels::Column::GuildId.eq(guild_id))
|
.filter(channels::Column::GuildId.eq(guild_id))
|
||||||
.order_by_asc(channels::Column::CreatedAt)
|
.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())
|
Ok(channels.into_iter().map(map_channel).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
pub async fn list_voice_channels_for_guild(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
guild_id: Uuid,
|
||||||
|
) -> Result<Vec<Channel>> {
|
||||||
let channels = channels::Entity::find()
|
let channels = channels::Entity::find()
|
||||||
.filter(channels::Column::GuildId.eq(guild_id))
|
.filter(channels::Column::GuildId.eq(guild_id))
|
||||||
.filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE))
|
.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())
|
Ok(channels.into_iter().map(map_channel).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result<bool> {
|
pub async fn is_member_of_guild(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
guild_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<bool> {
|
||||||
let count = guild_members::Entity::find()
|
let count = guild_members::Entity::find()
|
||||||
.filter(guild_members::Column::GuildId.eq(guild_id))
|
.filter(guild_members::Column::GuildId.eq(guild_id))
|
||||||
.filter(guild_members::Column::UserId.eq(user_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)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Uuid>> {
|
pub async fn guild_id_for_channel(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Option<Uuid>> {
|
||||||
let guild_id = channels::Entity::find_by_id(channel_id)
|
let guild_id = channels::Entity::find_by_id(channel_id)
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(channels::Column::GuildId)
|
.column(channels::Column::GuildId)
|
||||||
|
|
@ -267,18 +308,30 @@ pub async fn create_message(
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
author_user_id: Uuid,
|
author_user_id: Uuid,
|
||||||
body: &str,
|
body: &str,
|
||||||
) -> Result<()> {
|
) -> Result<MessageWithAuthor> {
|
||||||
messages::Entity::insert(messages::ActiveModel {
|
let model = messages::Entity::insert(messages::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
channel_id: Set(channel_id),
|
channel_id: Set(channel_id),
|
||||||
author_user_id: Set(author_user_id),
|
author_user_id: Set(author_user_id),
|
||||||
body: Set(body.to_string()),
|
body: Set(body.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec(db)
|
.exec_with_returning(db)
|
||||||
.await?;
|
.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(
|
pub async fn list_messages(
|
||||||
|
|
@ -317,18 +370,30 @@ pub async fn create_direct_message(
|
||||||
sender_user_id: Uuid,
|
sender_user_id: Uuid,
|
||||||
recipient_user_id: Uuid,
|
recipient_user_id: Uuid,
|
||||||
body: &str,
|
body: &str,
|
||||||
) -> Result<()> {
|
) -> Result<DmMessageWithAuthor> {
|
||||||
direct_messages::Entity::insert(direct_messages::ActiveModel {
|
let model = direct_messages::Entity::insert(direct_messages::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
sender_user_id: Set(sender_user_id),
|
sender_user_id: Set(sender_user_id),
|
||||||
recipient_user_id: Set(recipient_user_id),
|
recipient_user_id: Set(recipient_user_id),
|
||||||
body: Set(body.to_string()),
|
body: Set(body.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.exec(db)
|
.exec_with_returning(db)
|
||||||
.await?;
|
.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(
|
pub async fn list_direct_messages(
|
||||||
|
|
@ -385,7 +450,10 @@ pub async fn list_direct_messages(
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result<Vec<DmConversation>> {
|
pub async fn list_dm_conversations(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
current_user_id: Uuid,
|
||||||
|
) -> Result<Vec<DmConversation>> {
|
||||||
let rows = direct_messages::Entity::find()
|
let rows = direct_messages::Entity::find()
|
||||||
.filter(
|
.filter(
|
||||||
Condition::any()
|
Condition::any()
|
||||||
|
|
@ -396,7 +464,8 @@ pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uui
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut latest_by_peer = std::collections::HashMap::<Uuid, chrono::DateTime<chrono::FixedOffset>>::new();
|
let mut latest_by_peer =
|
||||||
|
std::collections::HashMap::<Uuid, chrono::DateTime<chrono::FixedOffset>>::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let peer_id = if row.sender_user_id == current_user_id {
|
let peer_id = if row.sender_user_id == current_user_id {
|
||||||
row.recipient_user_id
|
row.recipient_user_id
|
||||||
|
|
@ -496,7 +565,10 @@ fn validate_invite(invite: &invites::Model) -> Result<()> {
|
||||||
Ok(())
|
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 next_count = invite.use_count + 1;
|
||||||
let mut active: invites::ActiveModel = invite.into();
|
let mut active: invites::ActiveModel = invite.into();
|
||||||
active.use_count = Set(next_count);
|
active.use_count = Set(next_count);
|
||||||
|
|
|
||||||
123
src/handlers.rs
123
src/handlers.rs
|
|
@ -11,7 +11,7 @@ use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{self, ApiError, AuthUser},
|
auth::{self, ApiError, AuthUser},
|
||||||
db, voice,
|
chat, db, voice,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
|
|
@ -22,17 +22,27 @@ pub fn routes() -> Router<AppState> {
|
||||||
.route("/auth/logout", post(auth_logout))
|
.route("/auth/logout", post(auth_logout))
|
||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
.route("/dms", get(list_dm_conversations))
|
.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("/rtc-config", get(rtc_config))
|
||||||
.route("/guilds", get(list_guilds).post(create_guild))
|
.route("/guilds", get(list_guilds).post(create_guild))
|
||||||
.route("/guilds/{guild_id}/members", get(list_guild_members))
|
.route("/guilds/{guild_id}/members", get(list_guild_members))
|
||||||
.route("/guilds/{guild_id}/channels", get(list_channels))
|
.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("/guilds/{guild_id}/invites", post(create_invite))
|
||||||
.route("/invites/{code}/join", post(join_invite))
|
.route("/invites/{code}/join", post(join_invite))
|
||||||
.route("/channels", post(create_channel))
|
.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("/channels/{channel_id}/voice/ws", get(voice_ws))
|
||||||
|
.route("/ws", get(chat_ws))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn health() -> &'static str {
|
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}")))?;
|
.map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?;
|
||||||
|
|
||||||
if !token_res.status().is_success() {
|
if !token_res.status().is_success() {
|
||||||
let text = token_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
let text = token_res
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
return Err(ApiError::bad_request(&format!(
|
return Err(ApiError::bad_request(&format!(
|
||||||
"token exchange returned non-success: {text}"
|
"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}")))?;
|
.map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?;
|
||||||
|
|
||||||
if !userinfo_res.status().is_success() {
|
if !userinfo_res.status().is_success() {
|
||||||
let text = userinfo_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
let text = userinfo_res
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
return Err(ApiError::bad_request(&format!(
|
return Err(ApiError::bad_request(&format!(
|
||||||
"userinfo returned non-success: {text}"
|
"userinfo returned non-success: {text}"
|
||||||
)));
|
)));
|
||||||
|
|
@ -185,8 +201,11 @@ async fn auth_callback(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||||
|
|
||||||
let session_cookie =
|
let session_cookie = auth::new_session_cookie(
|
||||||
auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure)
|
user.id,
|
||||||
|
&state.settings.session_secret,
|
||||||
|
state.settings.cookie_secure,
|
||||||
|
)
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|
@ -232,7 +251,10 @@ struct RtcIceServer {
|
||||||
credential: Option<String>,
|
credential: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rtc_config(State(state): State<AppState>, _user: AuthUser) -> Result<impl IntoResponse, ApiError> {
|
async fn rtc_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let mut ice_servers = Vec::new();
|
let mut ice_servers = Vec::new();
|
||||||
|
|
||||||
if !state.settings.stun_urls.is_empty() {
|
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"));
|
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 {
|
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)
|
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"));
|
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
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?;
|
.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 })))
|
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"));
|
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
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?;
|
.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 })))
|
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,15 +650,27 @@ async fn voice_ws(
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if channel.kind != db::CHANNEL_KIND_VOICE {
|
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| {
|
Ok(ws.on_upgrade(move |socket| voice::handle_socket(state, socket, channel_id, user.id)))
|
||||||
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<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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)
|
let is_member = db::is_member_of_guild(&state.db, guild_id, user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?;
|
.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(())
|
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)
|
let guild_id = db::guild_id_for_channel(&state.db, channel_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))?
|
.map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))?
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod chat;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod entity;
|
mod entity;
|
||||||
|
|
@ -24,6 +25,7 @@ pub struct AppState {
|
||||||
pub settings: Arc<Settings>,
|
pub settings: Arc<Settings>,
|
||||||
pub http: reqwest::Client,
|
pub http: reqwest::Client,
|
||||||
pub voice: Arc<VoiceHub>,
|
pub voice: Arc<VoiceHub>,
|
||||||
|
pub chat: Arc<chat::ChatHub>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -46,6 +48,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
settings,
|
settings,
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
voice: Arc::new(VoiceHub::default()),
|
voice: Arc::new(VoiceHub::default()),
|
||||||
|
chat: Arc::new(chat::ChatHub::default()),
|
||||||
};
|
};
|
||||||
let port = state.settings.port;
|
let port = state.settings.port;
|
||||||
|
|
||||||
|
|
|
||||||
134
static/app.js
134
static/app.js
|
|
@ -16,13 +16,13 @@ const state = {
|
||||||
localStream: null,
|
localStream: null,
|
||||||
rawStream: null,
|
rawStream: null,
|
||||||
audioContext: null,
|
audioContext: null,
|
||||||
denoiserNode: null,
|
|
||||||
deepFilterCore: null,
|
|
||||||
peerConnections: new Map(),
|
peerConnections: new Map(),
|
||||||
muted: false,
|
muted: false,
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
|
chatWs: null,
|
||||||
|
lastMessageId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
|
|
@ -140,10 +140,47 @@ function formatDate(isoString) {
|
||||||
return d.toLocaleDateString();
|
return d.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
|
let audioCtx = null;
|
||||||
const NSNET2_COMPAT_SUPPRESSION = 56;
|
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";
|
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
||||||
|
|
||||||
// --- Renderers ---
|
// --- Renderers ---
|
||||||
|
|
@ -291,6 +328,14 @@ function renderMessages(messages) {
|
||||||
lastTime = mDate;
|
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;
|
el.messageList.scrollTop = el.messageList.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -422,6 +467,39 @@ function startVoicePresencePolling() {
|
||||||
}, 3000);
|
}, 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 ---
|
// --- Voice ---
|
||||||
|
|
||||||
function getVoiceWsUrl(channelId) {
|
function getVoiceWsUrl(channelId) {
|
||||||
|
|
@ -434,40 +512,7 @@ function shouldInitiateOffer(peerId) {
|
||||||
return state.me.id > 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() {
|
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) {
|
if (state.voice.localStream) {
|
||||||
for (const track of state.voice.localStream.getTracks()) track.stop();
|
for (const track of state.voice.localStream.getTracks()) track.stop();
|
||||||
}
|
}
|
||||||
|
|
@ -480,8 +525,6 @@ function stopAndClearAudioPipeline() {
|
||||||
state.voice.localStream = null;
|
state.voice.localStream = null;
|
||||||
state.voice.rawStream = null;
|
state.voice.rawStream = null;
|
||||||
state.voice.audioContext = null;
|
state.voice.audioContext = null;
|
||||||
state.voice.denoiserNode = null;
|
|
||||||
state.voice.deepFilterCore = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAudioPipeline(rawStream) {
|
async function buildAudioPipeline(rawStream) {
|
||||||
|
|
@ -492,14 +535,8 @@ async function buildAudioPipeline(rawStream) {
|
||||||
|
|
||||||
let head = source;
|
let head = source;
|
||||||
|
|
||||||
const denoiserNode = await buildDenoiserNode(audioContext);
|
|
||||||
if (denoiserNode) {
|
|
||||||
head.connect(denoiserNode);
|
|
||||||
head = denoiserNode;
|
|
||||||
}
|
|
||||||
head.connect(destination);
|
head.connect(destination);
|
||||||
|
|
||||||
state.voice.denoiserNode = denoiserNode;
|
|
||||||
return destination.stream;
|
return destination.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,6 +658,7 @@ async function joinVoice() {
|
||||||
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
||||||
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
||||||
el.voiceConnection.classList.remove("hidden");
|
el.voiceConnection.classList.remove("hidden");
|
||||||
|
playSound('join');
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -631,8 +669,10 @@ async function joinVoice() {
|
||||||
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
|
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
|
||||||
}
|
}
|
||||||
} else if (msg.type === "peer_joined") {
|
} else if (msg.type === "peer_joined") {
|
||||||
|
playSound('peer-join');
|
||||||
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
|
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
|
||||||
} else if (msg.type === "peer_left") {
|
} else if (msg.type === "peer_left") {
|
||||||
|
playSound('peer-leave');
|
||||||
const pc = state.voice.peerConnections.get(msg.user_id);
|
const pc = state.voice.peerConnections.get(msg.user_id);
|
||||||
if (pc) {
|
if (pc) {
|
||||||
pc.close();
|
pc.close();
|
||||||
|
|
@ -652,6 +692,7 @@ async function joinVoice() {
|
||||||
stopAndClearAudioPipeline();
|
stopAndClearAudioPipeline();
|
||||||
state.voice.joinedChannelId = null;
|
state.voice.joinedChannelId = null;
|
||||||
state.voice.ws = null;
|
state.voice.ws = null;
|
||||||
|
playSound('leave');
|
||||||
refreshVoicePresence().catch(() => { });
|
refreshVoicePresence().catch(() => { });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -893,6 +934,7 @@ async function init() {
|
||||||
updateHeaderLabels();
|
updateHeaderLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initChatWs();
|
||||||
startVoicePresencePolling();
|
startVoicePresencePolling();
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue