This commit is contained in:
Tyler Murphy 2023-08-21 23:19:53 -04:00
parent 2adbe0d999
commit c4c26f42b6
12 changed files with 598 additions and 38 deletions

View file

@ -17,6 +17,7 @@
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css"> <link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/people.css">
<link rel="stylesheet" href="/css/chat.css"> <link rel="stylesheet" href="/css/chat.css">
<script type="module" src="/js/chat.js"></script> <script type="module" src="/js/chat.js"></script>

172
public/css/chat.css Normal file
View file

@ -0,0 +1,172 @@
.spacer {
margin-bottom: 3.5em !important;
}
#cent {
display: flex;
width: 100%;
height: calc(100vh - 3.5em);
flex-direction: row;
}
#sidebar {
height: 100%;
width: 20%;
min-width: 25em;
display: flex;
flex-direction: column;
}
#sidebar>span {
display: block;
width: 100%;
text-align: center;
font-size: 1.5em;
margin: .5em 0;
}
#center {
height: 100%;
width: calc(100vw - 20%);
max-width: calc(100vw - 25em);
}
.room {
width: calc (100% - 1rem);
height: 3rem;
display: flex;
position: relative;
flex-direction: row;
padding: .5rem 1rem;
}
.room:hover, .current {
background-color: var(--hover);
}
.room-icon {
border-radius: 3rem;
height: 3rem;
width: 3rem;
display: flex;
justify-content: center;
align-items: center;
font-weight: 1000;
font-size: 1.5rem;
}
.room-name {
display: flex;
height: 3rem;
margin-left: 1rem;
align-items: center;
}
.roomDisplay {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
position: relative;
}
.roomDisplayCenter {
display: flex;
width: calc(100% - 25em);
height: 100%;
flex-direction: column;
}
.roomDisplayPeople {
width: 25em;
height: 100%;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;
}
.roomDisplayPeople>span {
display: block;
width: 100%;
text-align: center;
font-size: 1.5em;
margin: .5em 0em;
}
.person {
width: 20em;
}
.person img, .person .profile {
height: 7em;
width: 7em;
}
.person .info {
margin: 5px;
}
.person .ltext {
font-size: 18px;
}
.person .gtext {
font-size: 12px;
}
.roomDisplay .messages {
flex: 1;
margin-left: 1rem;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
}
.roomDisplay .messageContent {
flex-grow: 0;
width: auto;
margin-bottom: .5rem;
display: block;
height: fit-content;
overflow: none;
}
.roomDisplay .messageContent[contenteditable]:empty::before {
content: "Send an unencrypted message";
color: gray;
}
.addUser[contenteditable]:empty::before {
content: "Type email to add user";
color: gray;
}
.addRoom[contenteditable]:empty::before {
content: "Type name to create room";
color: gray;
}
.message {
display: flex;
flex-direction: row;
height: fit-content;
padding: .5rem;
}
.message-pfp {
flex-grow: 0;
height: 3rem;
width: 3rem;
margin-right: 1rem;
}
.message-pfp img {
width: 100%;
height: 100%;
border-radius: 3rem;
}
.message-content {
flex-grow: 0;
}

View file

@ -14,6 +14,10 @@ body {
--popup: #ffffffcc; --popup: #ffffffcc;
} }
textarea {
resize: none
}
body { body {
background-color: var(--primary); background-color: var(--primary);
width: 100%; width: 100%;
@ -130,7 +134,7 @@ footer {
background-color: var(--primary); background-color: var(--primary);
} }
input { input, .input {
flex: 1; flex: 1;
font-family: sfpro; font-family: sfpro;
background-color: var(--primary); background-color: var(--primary);

View file

@ -142,3 +142,27 @@ export const updateavatar = async (file) => {
export const updatebanner = async (file) => { export const updatebanner = async (file) => {
return await fileRequest('/users/banner', file, 'PUT') return await fileRequest('/users/banner', file, 'PUT')
} }
export const chatlist = async () => {
return await request('/chat/list', {})
}
export const chatcreate = async (name) => {
return await request('/chat/create', {name})
}
export const chatadd = async (email, room_id) => {
return await request('/chat/add', {email, room_id}, 'PATCH')
}
export const chatleave = async (room_id) => {
return await request('/chat/leave', {room_id}, 'DELETE')
}
export const chatsend = async (content, room_id) => {
return await request('/chat/send', {content, room_id})
}
export const chatload = async (newest_msg, page, room_id) => {
return await request('/chat/load', {newest_msg, page, room_id})
}

View file

@ -1,21 +1,202 @@
import { div, pfp, p, parse, button, body, a, textarea, span, crawl } from './main.js' import { body, div, span, p, parse } from './main.js'
import { loadself, loadpostspage, createpost, loadusers } from './api.js' import { loadself, chatlist, chatload, loadusers, chatcreate } from './api.js'
import { parsePost, header } from './components.js' import { createRoomDisplay, header, parseMessage, parseRoom, parseUser, createSingleLineInput } from './components.js'
function render() { async function getUser(user_id) {
if (data.users[user_id]) {
return data.users[user_id]
} else {
let request = (await loadusers([user_id]))
if (request.status != 200) {
location.href = 'login'
} else {
data.users[user_id] = request.json[0]
return request.json[0]
}
}
}
async function parseMessageImpl(message) {
let user = await getUser(message.user_id)
return parseMessage(message, user)
}
async function onRoomClick(room) {
for (const room of Object.values(data.rooms)) {
room.display.style.display = 'none'
room.button.classList.remove('current')
}
room.display.style.display = ''
room.button.classList.add('current')
}
async function render() {
let new_body = let new_body =
body({}, body({},
...header(false, false, true, data.self.user_id), ...header(false, false, true, data.self.user_id),
div({id: 'cent'},
div({id: 'sidebar'},
span({class: 'ltext'},
parse("Rooms")
),
createSingleLineInput(
{
type: 'text',
name: 'addRoom',
class: 'addRoom input',
style: 'flex-grow: 0; width: 80%; margin-left: auto; margin-right: auto;'
},
async (text) => {
let result = (await chatcreate(text))
if (result.status != 201) {
alert(result.msg)
return false
} else {
return true
}
}
),
),
div({id: 'center'})
)
) )
document.body.replaceWith(new_body) document.body.replaceWith(new_body)
} }
const data = { const data = {
self: {}, self: {},
users: [] users: [],
rooms: {},
}
async function loadRoomPage(room_id) {
let room = data.rooms[room_id]
let request = (await chatload (
room.newest_msg,
room.page,
room_id
))
if (request.json == undefined) {
alert(request.msg)
return
}
for (const msg of request.json) {
room.messages.push(msg)
}
room.page++
}
async function loadRoom(room_id) {
let room = data.rooms[room_id]
let request = (await loadusers(room.users))
if (request.status != 200) {
location.href = '/login'
} else {
for (const user of request.json) {
data.users[user.user_id] = user
}
}
room.page = 0
room.messages = []
if (room.newest_msg == undefined || room.newest_msg < 0)
room.newest_msg = Number.MAX_SAFE_INTEGER
await loadRoomPage(room_id)
room.newest_msg = Math.max(
...room.messages.map(m => m.message_id)
)
room.page = 0
room.display = createRoomDisplay(room)
let displays = document.getElementById("center")
displays.appendChild(room.display)
let button = parseRoom(room, onRoomClick)
if (displays.children.length > 1) {
room.display.style.display = 'none'
} else {
button.classList.add('current')
}
let sidebar = document.getElementById("sidebar")
sidebar.appendChild(button)
room.button = button
let messages = room.display.getElementsByClassName('messages')[0]
for (const message of room.messages) {
messages.appendChild(await parseMessageImpl(message))
}
if (!room.people) room.people = room.people = {}
let people = room.display.getElementsByClassName("roomDisplayPeople")[0]
for (const user_id of room.users) {
if (room.people[user_id]) continue
let user = await getUser(user_id)
let el = parseUser(user)
people.appendChild(el)
room.people[user_id] = el
}
}
async function onMessage(message) {
let event = JSON.parse(message.data)
switch (event.type) {
case "message": {
let room = data.rooms[event.room_id]
if (!room) return
let display = room.display
let messages = display.getElementsByClassName('messages')[0]
messages.prepend(await parseMessageImpl(event))
break;
}
case "add": {
let room = data.rooms[event.room.room_id]
if (!room) {
// we are the user being added
data.rooms[event.room.room_id] = event.room
loadRoom(event.room.room_id)
} else {
let display = room.display
let people = display.getElementsByClassName('roomDisplayPeople')[0]
if (!room.people[event.user_id]) {
let user = await getUser(event.user_id)
let el = parseUser(user)
people.appendChild(el)
room.people[event.user_id] = el
}
}
break;
}
case "leave": {
let room = data.rooms[event.room_id]
if (!room) return
if (room.people[event.user_id]) {
room.people[event.user_id].remove()
delete room.people[event.user_id]
}
if (event.user_id == data.self.user_id) {
room.display.remove()
room.button.remove()
}
break;
}
case "typing": {
break;
}
default: {
console.warn("unhandled event: " + message.data)
break;
}
}
} }
async function init() { async function init() {
@ -31,6 +212,20 @@ async function init() {
data.users[data.self.user_id] = data.self data.users[data.self.user_id] = data.self
render() render()
let rooms = (await chatlist());
if (rooms.json === undefined) {
alert(rooms.msg)
} else {
for (const room of rooms.json) {
data.rooms[room.room_id] = room
loadRoom(room.room_id)
}
}
let socket = new WebSocket(window.location.protocol.replace("http", "ws") + "//" + location.host + "/api/chat/connect")
socket.addEventListener("message", onMessage);
} }

View file

@ -1,5 +1,5 @@
import { div, a, pfp, span, i, parse, parseDate, p, form, input, svg, path, parseMonth, g } from './main.js' import { div, a, pfp, span, i, parse, parseDate, p, form, input, svg, path, parseMonth, g } from './main.js'
import { postlike, postcomment, loadcommentspage } from './api.js'; import { postlike, postcomment, loadcommentspage, chatsend, chatadd, chatleave } from './api.js';
window.parse = parse; window.parse = parse;
@ -268,3 +268,164 @@ export function parseUser(user) {
) )
) )
} }
const stringToColor = (str) => {
let hash = 0;
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
color += value.toString(16).padStart(2, '0')
}
return color
}
export function parseRoom(room, callback) {
let dspName = room.name[0].toUpperCase()
let color = stringToColor(room.room_id + room.name + room.room_id)
return (
div({class: 'room', onclick: () => {
callback(room)
}},
div({class: 'room-icon ltext', style: `background-color: ${color}`},
span({}, parse(dspName))
),
div({class: 'room-name ltext'},
span({}, parse(room.name))
),
div({
class: 'close',
onclick: async () => {
let request = (await chatleave(room.room_id))
if (request.status != 200) {
alert(request.msg)
}
}
})
)
)
}
export function parseRooms(rooms, callback) {
let ret = []
for (const room of rooms) {
ret.push(parseRoom(room, callback))
}
return ret
}
export function createMultiLineInput(attributes, onSubmit) {
let area = span({
...attributes,
role: 'textbox',
contenteditable: '',
onkeydown: async (event) => {
if (event.keyCode == 13 && !event.shiftKey) {
event.preventDefault()
let text = area.innerHTML.trim()
text = text.replaceAll("\n", "<br>")
if (text.length < 1) return
if (await onSubmit(text)) {
area.textContent = ''
}
}
},
})
return area
}
export function createSingleLineInput(attributes, onSubmit) {
let area = span({
...attributes,
role: 'textbox',
contenteditable: '',
onkeydown: async (event) => {
if (event.keyCode == 13 && !event.shiftKey) {
event.preventDefault()
let text = area.innerHTML.trim()
text = text.replaceAll("\n", "<br>")
if (text.length < 1) return
if (await onSubmit(text)) {
area.textContent = ''
}
} else if (event.keyCode == 13) {
event.preventDefault()
}
},
})
return area
}
export function createRoomDisplay(room) {
return (
div({class: 'roomDisplay'},
div({class: 'roomDisplayCenter'},
div({class: 'messages'}),
createMultiLineInput(
{
type: 'text',
name: 'messageContent',
class: 'messageContent input',
},
async (text) => {
let result = (await chatsend(text, room.room_id))
if (result.status != 201) {
alert(result.msg)
return false
} else {
return true
}
}
),
),
div({class: 'roomDisplayPeople'},
span({class: 'ltext'},
parse("People"),
),
createSingleLineInput(
{
type: 'text',
name: 'addUser',
class: 'addUser input',
style: 'flex-grow: 0; width: 80%'
},
async (text) => {
let result = (await chatadd(text, room.room_id))
if (result.status != 200) {
alert(result.msg)
return false
} else {
return true
}
}
)
)
)
)
}
export function parseMessage(message, user) {
return (
div({class: 'message'},
a({class: 'message-pfp', href: `/profile?id=${message.user_id}`},
pfp(message.user_id)
),
div({class: 'message-content'},
span({class: 'message-name ltext'},
parse(user.firstname + ' ' + user.lastname)
),
p({class: 'message-text ltext'},
parse(message.content)
)
)
)
)
}

View file

@ -103,10 +103,6 @@ export function parse(html) {
return document.createRange().createContextualFragment(html); return document.createRange().createContextualFragment(html);
} }
export function pfpl(id) {
}
export function pfp(id) { export function pfp(id) {
return img('pfp', {src: `/image/avatar?user_id=${id}`}) return img('pfp', {src: `/image/avatar?user_id=${id}`})
} }
@ -133,7 +129,7 @@ export function parseMonth(month) {
} }
export function parseDate(date) { export function parseDate(date) {
return parseMonth(date.getUTCMonth()) + ' ' + (date.getUTCDate()-1) + ', ' + date.getUTCFullYear() + ' ' + date.toLocaleTimeString(); return parseMonth(date.getUTCMonth()) + ' ' + date.getUTCDate() + ', ' + date.getUTCFullYear() + ' ' + date.toLocaleTimeString();
} }
export function crawl(key, object) { export function crawl(key, object) {

View file

@ -6,7 +6,7 @@ use tokio::sync::{Mutex, mpsc::{Sender, self}};
use crate::{ use crate::{
public::docs::{EndpointDocumentation, EndpointMethod}, public::docs::{EndpointDocumentation, EndpointMethod},
types::{ types::{
extract::{AuthorizedUser, Check, CheckResult, Database, Json}, extract::{AuthorizedUser, Check, CheckResult, Database, Json, Log},
http::ResponseCode, http::ResponseCode,
chat::{ChatRoom, ChatEvent}, user::User, chat::{ChatRoom, ChatEvent}, user::User,
}, },
@ -75,7 +75,8 @@ pub const CHAT_LIST: EndpointDocumentation = EndpointDocumentation {
async fn list ( async fn list (
AuthorizedUser(user): AuthorizedUser, AuthorizedUser(user): AuthorizedUser,
Database(db): Database Database(db): Database,
_: Log
) -> Response { ) -> Response {
let Ok(rooms) = ChatRoom::from_user_id(&db, user.user_id) else { let Ok(rooms) = ChatRoom::from_user_id(&db, user.user_id) else {
return ResponseCode::InternalServerError.text("Failed to retrieve rooms") return ResponseCode::InternalServerError.text("Failed to retrieve rooms")
@ -137,7 +138,7 @@ async fn create (
for user in &room.users { for user in &room.users {
send_event(ChatEvent::Add { send_event(ChatEvent::Add {
user_id: *user, user_id: *user,
room_id: room.room_id room: room.clone()
}, &room).await; }, &room).await;
} }
@ -191,10 +192,14 @@ async fn add (
return ResponseCode::BadRequest.text("User does not exist") return ResponseCode::BadRequest.text("User does not exist")
}; };
let Ok(room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else { let Ok(mut room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it") return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
}; };
if room.users.contains(&to_add.user_id) {
return ResponseCode::BadRequest.text("User is already in the room")
}
let Ok(success) = room.add_user(&db, to_add.user_id) else { let Ok(success) = room.add_user(&db, to_add.user_id) else {
return ResponseCode::InternalServerError.text("Failed to add user") return ResponseCode::InternalServerError.text("Failed to add user")
}; };
@ -203,9 +208,11 @@ async fn add (
return ResponseCode::BadRequest.text("User is already in the room") return ResponseCode::BadRequest.text("User is already in the room")
} }
room.users.push(to_add.user_id);
send_event(ChatEvent::Add { send_event(ChatEvent::Add {
user_id: to_add.user_id, user_id: to_add.user_id,
room_id: room.room_id room: room.clone()
}, &room).await; }, &room).await;
ResponseCode::Success.text("Successfully added user") ResponseCode::Success.text("Successfully added user")

View file

@ -61,9 +61,10 @@ impl Database {
); );
", ",
)?; )?;
let row = stmt.query_map([user_id], |row| { let row = stmt.query_map([user_id], |row| {
let room_id = row.get(0)?; let room_id: u64 = row.get(0)?;
let name = row.get(1)?; let name: String = row.get(1)?;
let mut stmt2 = self.0.prepare( let mut stmt2 = self.0.prepare(
" "
@ -72,20 +73,19 @@ impl Database {
" "
)?; )?;
let mut users = Vec::new(); let users = stmt2.query_map([room_id], |row2| {
let _ = stmt2.query_map([room_id], |row2| { Ok(row2.get(0)?)
let user_id = row2.get(0)?; })?.into_iter().flatten().collect();
users.push(user_id);
Ok(())
})?;
let room = ChatRoom { let room = ChatRoom {
room_id, room_id,
users, users,
name name
}; };
Ok(room) Ok(room)
})?; })?;
Ok(row.into_iter().flatten().collect()) Ok(row.into_iter().flatten().collect())
} }
@ -158,8 +158,8 @@ impl Database {
let msg = stmt.query_row((user_id, room_id, date, content), |row| { let msg = stmt.query_row((user_id, room_id, date, content), |row| {
let message_id = row.get(0)?; let message_id = row.get(0)?;
let room_id = row.get(1)?; let user_id = row.get(1)?;
let user_id = row.get(2)?; let room_id = row.get(2)?;
let date = row.get(3)?; let date = row.get(3)?;
let content = row.get(4)?; let content = row.get(4)?;
@ -182,17 +182,17 @@ impl Database {
" "
SELECT * FROM chat_messages SELECT * FROM chat_messages
WHERE room_id = ? WHERE room_id = ?
AND message_id < newest_message AND message_id < ?
ORDER BY message_id ASC ORDER BY message_id DESC
LIMIT ? LIMIT ?
OFFSET ? OFFSET ?
" "
)?; )?;
let messages = stmt.query_map((room_id, 20, 20 * page), |row| { let messages = stmt.query_map((room_id, newest_message, 20, 20 * page), |row| {
let message_id = row.get(0)?; let message_id = row.get(0)?;
let room_id = row.get(1)?; let user_id = row.get(1)?;
let user_id = row.get(2)?; let room_id = row.get(2)?;
let date = row.get(3)?; let date = row.get(3)?;
let content = row.get(4)?; let content = row.get(4)?;

View file

@ -46,7 +46,7 @@ async fn main() {
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
fmt_layer fmt_layer
.with_filter(LevelFilter::TRACE) .with_filter(LevelFilter::INFO)
.with_filter(filter_fn(|metadata| { .with_filter(filter_fn(|metadata| {
metadata.target().starts_with("xssbook") metadata.target().starts_with("xssbook")
})), })),

View file

@ -2,7 +2,7 @@ use serde::{Serialize, Deserialize};
use tracing::instrument; use tracing::instrument;
use crate::{types::http::{ResponseCode, Result}, database::Database}; use crate::{types::http::{ResponseCode, Result}, database::Database};
#[derive(Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChatRoom { pub struct ChatRoom {
pub room_id: u64, pub room_id: u64,
pub users: Vec<u64>, pub users: Vec<u64>,
@ -33,7 +33,7 @@ pub enum ChatEvent {
#[serde(rename = "add")] #[serde(rename = "add")]
Add { Add {
user_id: u64, user_id: u64,
room_id: u64 room: ChatRoom
}, },
#[serde(rename = "leave")] #[serde(rename = "leave")]