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;
|
||||
}
|
||||
126
src/db.rs
126
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<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>> {
|
||||
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<Guild> {
|
||||
pub async fn create_guild(
|
||||
db: &DatabaseConnection,
|
||||
owner_user_id: Uuid,
|
||||
name: &str,
|
||||
) -> Result<Guild> {
|
||||
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<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?;
|
||||
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()
|
||||
.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<Vec<Channel>> {
|
||||
pub async fn list_voice_channels_for_guild(
|
||||
db: &DatabaseConnection,
|
||||
guild_id: Uuid,
|
||||
) -> Result<Vec<Channel>> {
|
||||
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<bool> {
|
||||
pub async fn is_member_of_guild(
|
||||
db: &DatabaseConnection,
|
||||
guild_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool> {
|
||||
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<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)
|
||||
.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<MessageWithAuthor> {
|
||||
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<DmMessageWithAuthor> {
|
||||
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<Vec<DmConversation>> {
|
||||
pub async fn list_dm_conversations(
|
||||
db: &DatabaseConnection,
|
||||
current_user_id: Uuid,
|
||||
) -> Result<Vec<DmConversation>> {
|
||||
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::<Uuid, chrono::DateTime<chrono::FixedOffset>>::new();
|
||||
let mut latest_by_peer =
|
||||
std::collections::HashMap::<Uuid, chrono::DateTime<chrono::FixedOffset>>::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);
|
||||
|
|
|
|||
125
src/handlers.rs
125
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<AppState> {
|
||||
|
|
@ -22,17 +22,27 @@ pub fn routes() -> Router<AppState> {
|
|||
.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(|_| "<no body>".to_string());
|
||||
let text = token_res
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<no body>".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(|_| "<no body>".to_string());
|
||||
let text = userinfo_res
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<no body>".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<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();
|
||||
|
||||
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<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)
|
||||
.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}")))?
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod auth;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod db;
|
||||
mod entity;
|
||||
|
|
@ -24,6 +25,7 @@ pub struct AppState {
|
|||
pub settings: Arc<Settings>,
|
||||
pub http: reqwest::Client,
|
||||
pub voice: Arc<VoiceHub>,
|
||||
pub chat: Arc<chat::ChatHub>,
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
|
|
|
|||
136
static/app.js
136
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue