code cleanup & multi-channel support

This commit is contained in:
2025-10-20 03:45:53 +01:00
parent 07857f1d0a
commit 91ff2e00c4
10 changed files with 304 additions and 82 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ pub async fn signup(
token_id.use_token(&mut db).await?; token_id.use_token(&mut db).await?;
println!("phase 5"); println!("phase 5");
return Ok(Redirect::to("/chat")); Ok(Redirect::to("/chat"))
} }
#[get("/login")] #[get("/login")]
+3 -3
View File
@@ -3,7 +3,7 @@ use rocket::{
http::Status, http::Status,
outcome::{Outcome, try_outcome}, outcome::{Outcome, try_outcome},
request::{self, FromRequest}, request::{self, FromRequest},
response::status::{self, BadRequest}, response::status::{self},
serde::json::Json, serde::json::Json,
}; };
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
@@ -50,7 +50,7 @@ pub async fn confirm_totp(
println!("valid"); println!("valid");
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes()).unwrap(); let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes()).unwrap();
if !totp.check_current(&format!("{}", form.code)).unwrap() { if !totp.check_current(&form.code.to_string()).unwrap() {
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code")); return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
} }
@@ -72,7 +72,7 @@ pub async fn confirm_totp(
println!("enabled"); println!("enabled");
return Ok(()); Ok(())
} }
#[get("/totp.jpg")] #[get("/totp.jpg")]
+1 -1
View File
@@ -50,7 +50,7 @@ pub struct UploadResponse {
#[post("/profile/<user_id>/upload", data = "<file>")] #[post("/profile/<user_id>/upload", data = "<file>")]
pub async fn upload_profile_pic( pub async fn upload_profile_pic(
user_id: usize, user_id: usize,
mut file: Form<TempFile<'_>>, file: Form<TempFile<'_>>,
) -> Result<Json<UploadResponse>, Status> { ) -> Result<Json<UploadResponse>, Status> {
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024; const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024;
if file.len() > MAX_FILE_SIZE { if file.len() > MAX_FILE_SIZE {
+1 -1
View File
@@ -32,7 +32,7 @@ impl LlmWorker {
model: "gpt-oss-20b".into(), // whatever model you run locally model: "gpt-oss-20b".into(), // whatever model you run locally
messages: vec![Message { messages: vec![Message {
role: "user".into(), role: "user".into(),
content: message.text.clone().into(), content: message.text.clone(),
}], }],
}; };
+1 -5
View File
@@ -2,18 +2,15 @@
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
use redis::cmd;
use rocket::fs::{FileServer, NamedFile}; use rocket::fs::{FileServer, NamedFile};
use rocket::http::Method; use rocket::http::Method;
use rocket::serde::json::Json;
use rocket::{Build, Rocket}; use rocket::{Build, Rocket};
use rocket_cors::{AllowedOrigins, CorsOptions}; use rocket_cors::{AllowedOrigins, CorsOptions};
use rocket_db_pools::{Connection, Database}; use rocket_db_pools::Database;
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use std::env; use std::env;
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use crate::auth::Session;
use crate::db::{Postgres, Redis}; use crate::db::{Postgres, Redis};
pub mod auth; pub mod auth;
@@ -67,7 +64,6 @@ fn rocket() -> Rocket<Build> {
"/api", "/api",
routes![ routes![
cdn::upload_profile_pic, cdn::upload_profile_pic,
messenger::get_messages,
messenger::post_message, messenger::post_message,
messenger::event_stream, messenger::event_stream,
user::users, user::users,
+4 -6
View File
@@ -51,8 +51,8 @@ pub async fn initialise(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let key = format!("messages:{}", channel_id); let key = format!("messages:{}", channel_id);
let length: usize = cache.llen(&key).await?; // less than 100 messages in cache?
if length < 100 { if cache.llen::<_, i32>(&key).await? < 100 {
// Fetch from Postgres // Fetch from Postgres
let messages = sqlx::query!( let messages = sqlx::query!(
"SELECT u.username, u.display_name, u.id, m.content, m.created_at "SELECT u.username, u.display_name, u.id, m.content, m.created_at
@@ -68,14 +68,12 @@ pub async fn initialise(
// Populate cache (in reverse order so oldest is at the end) // Populate cache (in reverse order so oldest is at the end)
for msg in messages.into_iter().rev() { for msg in messages.into_iter().rev() {
let chat_msg = ChatMsg { let msg_json = serde_json::to_string(&ChatMsg {
display_name: Some(msg.display_name.unwrap_or(msg.username)), display_name: Some(msg.display_name.unwrap_or(msg.username)),
user_id: msg.id as usize, user_id: msg.id as usize,
text: msg.content, text: msg.content,
timestamp: (msg.created_at.unwrap().unix_timestamp_nanos() / 1_000_000) as usize, timestamp: (msg.created_at.unwrap().unix_timestamp_nanos() / 1_000_000) as usize,
}; })?;
let msg_json = serde_json::to_string(&chat_msg)?;
cache.lpush::<_, _, ()>(&key, &msg_json).await?; cache.lpush::<_, _, ()>(&key, &msg_json).await?;
} }
+4 -7
View File
@@ -16,6 +16,7 @@ use crate::{
auth::Session, auth::Session,
db::{Postgres, Redis}, db::{Postgres, Redis},
llm::LlmWorker, llm::LlmWorker,
messenger,
}; };
/// ---------- shared broadcaster ---------- /// ---------- shared broadcaster ----------
@@ -133,15 +134,11 @@ pub async fn post_message(
Ok(()) Ok(())
} }
#[get("/messages")]
pub async fn get_messages( pub async fn get_messages(
mut db: Connection<Postgres>, mut db: Connection<Postgres>,
mut redis: Connection<Redis>, mut redis: Connection<Redis>,
_session: Session, channel_id: i32,
) -> Json<Vec<ChatMsg>> { ) -> Json<Vec<ChatMsg>> {
const CHANNEL_ID: i32 = 1;
let channel_id = CHANNEL_ID;
if let Ok(messages) = messenger::cache::get(&mut redis, channel_id).await if let Ok(messages) = messenger::cache::get(&mut redis, channel_id).await
&& !messages.is_empty() && !messages.is_empty()
{ {
@@ -186,7 +183,7 @@ pub async fn event_stream(
chat: &rocket::State<Arc<ChatBroadcaster>>, chat: &rocket::State<Arc<ChatBroadcaster>>,
postgres: Connection<Postgres>, postgres: Connection<Postgres>,
cache: Connection<Redis>, cache: Connection<Redis>,
ag: Session, _session: Session,
mut shutdown: Shutdown, mut shutdown: Shutdown,
channel_id: i32, channel_id: i32,
) -> EventStream![] { ) -> EventStream![] {
@@ -194,7 +191,7 @@ pub async fn event_stream(
EventStream! { EventStream! {
// Initialize the stream with the last 100 messages // Initialize the stream with the last 100 messages
for msg in get_messages(postgres, cache, ag).await.0 { for msg in get_messages(postgres, cache, channel_id).await.0 {
yield Event::json(&msg); yield Event::json(&msg);
} }
+4 -15
View File
@@ -1,4 +1,4 @@
use redis::cmd; use redis::AsyncCommands;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
@@ -37,11 +37,7 @@ impl UserCache {
redis_conn: &mut Connection<Redis>, redis_conn: &mut Connection<Redis>,
pgsql_conn: &mut Connection<Postgres>, pgsql_conn: &mut Connection<Postgres>,
) -> String { ) -> String {
if let Ok(val) = cmd("GET") if let Ok(val) = redis_conn.get(format!("users:{id}")).await {
.arg(&[format!("users:{id}")])
.query_async(&mut **redis_conn)
.await
{
return val; return val;
} }
@@ -58,15 +54,8 @@ impl UserCache {
} }
pub async fn insert(id: usize, username: &str, conn: &mut Connection<Redis>) { pub async fn insert(id: usize, username: &str, conn: &mut Connection<Redis>) {
cmd("SET") conn.set_ex::<_, _, ()>(format!("users:{id}"), username.to_string(), 1800)
.arg(&[
format!("users:{id}"),
username.to_string(),
"EX".to_string(),
"1800".to_string(),
])
.query_async(&mut **conn)
.await .await
.expect("failed to insert key") .expect("failed to insert key");
} }
} }
+162 -2
View File
@@ -30,12 +30,18 @@ body {
justify-content: center; justify-content: center;
} }
.app-container {
display: flex;
width: 100%;
height: 100vh;
}
/* Chat Container */
.chat-container { .chat-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
height: 100dvh; height: 100dvh;
min-width: 100vw;
margin: 0 0;
background: #121212; background: #121212;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -46,12 +52,16 @@ body {
padding: 10px; padding: 10px;
background: #1a1a1a; background: #1a1a1a;
border-bottom: 1px solid #252525; border-bottom: 1px solid #252525;
display: flex;
align-items: center;
gap: 10px;
} }
.chat-title { .chat-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex: 1;
} }
.status-dot { .status-dot {
@@ -773,3 +783,153 @@ body {
.checkbox-group a:hover { .checkbox-group a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Sidebar Styles */
.sidebar {
width: 240px;
min-width: 180px;
max-width: 400px;
background: #0f0f0f;
display: flex;
flex-direction: column;
border-right: 1px solid #252525;
position: relative;
transition:
margin-left 0.3s ease,
opacity 0.3s ease;
}
.sidebar.hidden {
margin-left: -240px;
opacity: 0;
pointer-events: none;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
background: transparent;
transition: background 0.2s ease;
z-index: 10;
}
.resize-handle:hover {
background: rgba(106, 90, 205, 0.5);
}
.resize-handle:active {
background: #6a5acd;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #252525;
background: #1a1a1a;
color: #ffffff;
font-weight: 600;
font-size: 15px;
letter-spacing: 0.3px;
}
.channels-list {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
}
.channel-item {
padding: 10px 12px;
margin: 3px 0;
border-radius: 8px;
color: #b0b0b0;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid transparent;
background: rgba(30, 30, 30, 0.3);
}
.channel-item:hover {
background: rgba(30, 30, 30, 0.6);
border-color: #2a2a2a;
color: #e0e0e0;
transform: translateX(2px);
}
.channel-item.active {
background: rgba(106, 90, 205, 0.15);
border-color: rgba(106, 90, 205, 0.3);
color: #ffffff;
box-shadow: 0 2px 8px rgba(106, 90, 205, 0.2);
}
.channel-icon {
font-size: 18px;
font-weight: 600;
color: #666;
flex-shrink: 0;
}
.channel-item:hover .channel-icon {
color: #888;
}
.channel-item.active .channel-icon {
color: #6a5acd;
}
.channel-name {
font-size: 14px;
font-weight: 500;
}
/* Scrollbar styling for sidebar */
.channels-list::-webkit-scrollbar {
width: 6px;
}
.channels-list::-webkit-scrollbar-track {
background: #0f0f0f;
}
.channels-list::-webkit-scrollbar-thumb {
background: #252525;
border-radius: 3px;
}
.channels-list::-webkit-scrollbar-thumb:hover {
background: #333;
}
.sidebar-toggle {
width: 32px;
height: 32px;
background: rgba(30, 30, 30, 0.6);
border: 1px solid #2a2a2a;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
color: #b0b0b0;
font-size: 18px;
}
.sidebar-toggle:hover {
background: rgba(106, 90, 205, 0.2);
border-color: rgba(106, 90, 205, 0.3);
color: #e0e0e0;
transform: scale(1.05);
}
.sidebar-toggle:active {
transform: scale(0.95);
}
+123 -41
View File
@@ -5,53 +5,83 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Clone - Group Chat</title> <title>Discord Clone - Group Chat</title>
<link rel="stylesheet" href="static/css/index.css"/> <link rel="stylesheet" href="static/css/index.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
</head> </head>
<body> <body>
<div class="chat-container"> <div class="app-container">
<!--<div class="chat-container" style="background-image: url('cdn/background.png'); backdrop-filter: blur(10px); background-size: cover; background-position: center; background-repeat: no-repeat;">--> <!-- Sidebar -->
<!-- Chat Header --> <div class="sidebar">
<div class="chat-header"> <div class="resize-handle"></div>
<div class="chat-title"> <div class="sidebar-header">
<img class="user-avatar" src="cdn/profile/0"></img> Channels
<h1>Wish.com Discord frfr</h1> </div>
<div class="channels-list">
<div class="channel-item active" data-channel-id="1">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 1</span>
</div>
<div class="channel-item" data-channel-id="2">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 2</span>
</div>
<div class="channel-item" data-channel-id="3">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 3</span>
</div>
<div class="channel-item" data-channel-id="4">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 4</span>
</div>
<div class="channel-item" data-channel-id="5">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 5</span>
</div>
<div class="channel-item" data-channel-id="6">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 6</span>
</div>
<div class="channel-item" data-channel-id="7">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 7</span>
</div>
<div class="channel-item" data-channel-id="8">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 8</span>
</div>
<div class="channel-item" data-channel-id="9">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 9</span>
</div>
<div class="channel-item" data-channel-id="10">
<span class="channel-icon">#</span>
<span class="channel-name">Channel 10</span>
</div>
</div> </div>
</div> </div>
<!-- Live Location Notification Bubble --> <!-- Chat Container -->
<div class="notification-container"> <div class="chat-container">
<!--<div class="live-location-bubble" id="locationBubble"> <!-- Chat Header -->
<div class="map-container"> <div class="chat-header">
<img src="cdn/map.png" alt="Map" /> <button class="sidebar-toggle" id="sidebarToggle">☰</button>
<div class="chat-title">
<img class="user-avatar" src="cdn/profile/0"></img>
<h1>Wish.com Discord frfr</h1>
</div> </div>
<div class="location-content"> </div>
<div class="location-icon">
<img src="cdn/icons/location.svg" alt="Location"></img> <!-- Messages Container -->
</div> <div class="messages-container"></div>
<button class="join-button" id="joinButton">
Join <!-- Input Container -->
<div class="input-container">
<div class="input-wrapper">
<input type="text" placeholder="Start Typing..." />
<button class="send-button">
<img src="cdn/icons/send.svg" alt="Send" />
</button> </button>
<div class="location-text">Live Location</div>
<div class="location-users" id="locationUsers">
</div>
</div> </div>
</div>-->
</div>
<!-- Messages Container -->
<!--<div class="messages-container" style="background-image: url('cdn/background.png'); backdrop-filter: blur(10px); background-size: cover; background-position: center; background-repeat: no-repeat;">-->
<div class="messages-container"></div>
<!-- Input Container -->
<div class="input-container">
<div class="input-wrapper">
<input type="text" placeholder="Start Typing..." />
<button class="send-button">
<img src="cdn/icons/send.svg" alt="Send" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -69,11 +99,12 @@
} catch (__) {} } catch (__) {}
} }
return ''; // use external default escaping return '';
} }
}) })
const user_id = {{ user_id }}; let channel_id = 1;
const user_id = 1; // {{ user_id }}
var users = {}; var users = {};
// Handle message sending // Handle message sending
@@ -110,7 +141,7 @@
function sendMessage() { function sendMessage() {
const message = input.value.trim(); const message = input.value.trim();
if (message) { if (message) {
fetch("/api/chat", { fetch(`/api/chat/${channel_id}`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
user_id: user_id, user_id: user_id,
@@ -132,6 +163,8 @@
} }
}); });
let messageSource;
async function loadData() { async function loadData() {
try { try {
const userIds = await fetch("/api/users/") const userIds = await fetch("/api/users/")
@@ -151,7 +184,11 @@
console.log('Users loaded:', users); console.log('Users loaded:', users);
const messageSource = new EventSource("/api/events"); if (messageSource) {
messageSource.close();
}
messageSource = new EventSource(`/api/events/${channel_id}`);
messageSource.onopen = () => messagesContainer.innerHTML = ''; messageSource.onopen = () => messagesContainer.innerHTML = '';
messageSource.onmessage = (event) => insertMessage(JSON.parse(event.data)); messageSource.onmessage = (event) => insertMessage(JSON.parse(event.data));
messageSource.onerror = (error) => { messageSource.onerror = (error) => {
@@ -163,6 +200,51 @@
} }
} }
// Handle channel switching
document.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', function() {
document.querySelectorAll('.channel-item').forEach(i => i.classList.remove('active'));
this.classList.add('active');
channel_id = parseInt(this.dataset.channelId);
loadData();
});
});
// Sidebar toggle
const sidebar = document.querySelector('.sidebar');
const sidebarToggle = document.getElementById('sidebarToggle');
sidebarToggle.addEventListener('click', () => {
sidebar.classList.toggle('hidden');
});
// Sidebar resize
const resizeHandle = document.querySelector('.resize-handle');
let isResizing = false;
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth >= 180 && newWidth <= 400) {
sidebar.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
loadData(); loadData();
</script> </script>