Update live type.

This commit is contained in:
retoor 2025-05-15 13:18:53 +02:00
parent af1cf4f5ae
commit db6d6c0106
12 changed files with 665 additions and 52 deletions

123
src/snek/balancer.py Normal file
View File

@ -0,0 +1,123 @@
import asyncio
import sys
class LoadBalancer:
def __init__(self, backend_ports):
self.backend_ports = backend_ports
self.backend_processes = []
self.client_counts = [0] * len(backend_ports)
self.lock = asyncio.Lock()
async def start_backend_servers(self,port,workers):
for x in range(workers):
port += 1
process = await asyncio.create_subprocess_exec(
sys.executable,
sys.argv[0],
'backend',
str(port),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
port += 1
self.backend_processes.append(process)
print(f"Started backend server on port {(port-1)/port} with PID {process.pid}")
async def handle_client(self, reader, writer):
async with self.lock:
min_clients = min(self.client_counts)
server_index = self.client_counts.index(min_clients)
self.client_counts[server_index] += 1
backend = ('127.0.0.1', self.backend_ports[server_index])
try:
backend_reader, backend_writer = await asyncio.open_connection(*backend)
async def forward(r, w):
try:
while True:
data = await r.read(1024)
if not data:
break
w.write(data)
await w.drain()
except asyncio.CancelledError:
pass
finally:
w.close()
task1 = asyncio.create_task(forward(reader, backend_writer))
task2 = asyncio.create_task(forward(backend_reader, writer))
await asyncio.gather(task1, task2)
except Exception as e:
print(f"Error: {e}")
finally:
writer.close()
async with self.lock:
self.client_counts[server_index] -= 1
async def monitor(self):
while True:
await asyncio.sleep(5)
print("Connected clients per server:")
for i, count in enumerate(self.client_counts):
print(f"Server {self.backend_ports[i]}: {count} clients")
async def start(self, host='0.0.0.0', port=8081,workers=5):
await self.start_backend_servers(port,workers)
server = await asyncio.start_server(self.handle_client, host, port)
monitor_task = asyncio.create_task(self.monitor())
# Handle shutdown gracefully
try:
async with server:
await server.serve_forever()
except asyncio.CancelledError:
pass
finally:
# Terminate backend processes
for process in self.backend_processes:
process.terminate()
await asyncio.gather(*(p.wait() for p in self.backend_processes))
print("Backend processes terminated.")
async def backend_echo_server(port):
async def handle_echo(reader, writer):
try:
while True:
data = await reader.read(1024)
if not data:
break
writer.write(data)
await writer.drain()
except Exception:
pass
finally:
writer.close()
server = await asyncio.start_server(handle_echo, '127.0.0.1', port)
print(f"Backend echo server running on port {port}")
await server.serve_forever()
async def main():
backend_ports = [8001, 8003, 8005, 8006]
# Launch backend echo servers
# Wait a moment for servers to start
lb = LoadBalancer(backend_ports)
await lb.start()
if __name__ == "__main__":
if len(sys.argv) > 1:
if sys.argv[1] == 'backend':
port = int(sys.argv[2])
from snek.app import Application
snek = Application(port=port)
web.run_app(snek, port=port, host='127.0.0.1')
elif sys.argv[1] == 'sync':
from snek.sync import app
web.run_app(snek, port=port, host='127.0.0.1')
else:
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Shutting down...")

View File

@ -30,7 +30,7 @@ class ChannelMessageService(BaseService):
except Exception as ex:
print(ex, flush=True)
if await self.save(model):
if await super().save(model):
return model
raise Exception(f"Failed to create channel message: {model.errors}.")
@ -50,6 +50,12 @@ class ChannelMessageService(BaseService):
"username": user["username"],
}
async def save(self, model):
context = model.record
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
results = []
offset = page * page_size

View File

@ -36,4 +36,4 @@ class ChatService(BaseService):
self.services.notification.create_channel_message(channel_message_uid)
)
return True
return channel_message

View File

@ -5,8 +5,64 @@
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
import {app} from '../app.js'
class MessageList extends HTMLElement {
constructor() {
super();
app.ws.addEventListener("update_message_text",(data)=>{
this.updateMessageText(data.data.message_uid,data.data.text)
})
app.ws.addEventListener("set_typing",(data)=>{
this.triggerGlow(data.data.user_uid)
class MessageListElement extends HTMLElement {
})
this.items = [];
}
updateMessageText(uid,text){
const messageDiv = this.querySelector("div[data-uid=\""+uid+"\"]")
if(!messageDiv){
return
}
const textElement = messageDiv.querySelector(".text")
textElement.innerText = text
textElement.style.display = text == '' ? 'none' : 'block'
}
triggerGlow(uid) {
let lastElement = null;
this.querySelectorAll(".avatar").forEach((el)=>{
const div = el.closest('a');
if(el.href.indexOf(uid)!=-1){
lastElement = el
}
})
if(lastElement){
lastElement.classList.add("glow")
setTimeout(()=>{
lastElement.classList.remove("glow")
},1000)
}
}
set data(items) {
this.items = items;
this.render();
}
render() {
this.innerHTML = '';
//this.insertAdjacentHTML("beforeend", html);
}
}
customElements.define('message-list', MessageList);
class MessageListElementOLD extends HTMLElement {
static get observedAttributes() {
return ["messages"];
}
@ -167,4 +223,4 @@ class MessageListElement extends HTMLElement {
}
}
customElements.define('message-list', MessageListElement);
//customElements.define('message-list', MessageListElement);

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,28 @@
.user-list__item {
display: flex;
margin-bottom: 1em;
border: 1px solid #ccc;
padding: 10px;
border-radius: 8px;
}
.user-list__item-avatar {
margin-right: 10px;
border-radius: 50%;
overflow: hidden;
width: 40px;
height: 40px;
display: block;
}
.user-list__item-content {
flex: 1;
}
.user-list__item-name {
font-weight: bold;
}
.user-list__item-text {
margin: 5px 0;
}
.user-list__item-time {
font-size: 0.8em;
color: gray;
}

View File

@ -0,0 +1,59 @@
class UserList extends HTMLElement {
constructor() {
super();
this.users = [];
}
set data(userArray) {
this.users = userArray;
this.render();
}
formatRelativeTime(timestamp) {
const now = new Date();
const msgTime = new Date(timestamp);
const diffMs = now - msgTime;
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;
} else if (hours > 0) {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;
} else {
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;
}
}
render() {
this.innerHTML = '';
this.users.forEach(user => {
const html = `
<div class="user-list__item"
data-uid="${user.uid}"
data-color="${user.color}"
data-user_nick="${user.nick}"
data-created_at="${user.created_at}"
data-user_uid="${user.user_uid}">
<a class="user-list__item-avatar" style="background-color: ${user.color}; color: black;" href="/user/${user.uid}.html">
<img width="40px" height="40px" src="/avatar/${user.uid}.svg" alt="${user.nick}">
</a>
<div class="user-list__item-content">
<div class="user-list__item-name" style="color: ${user.color};">${user.nick}</div>
<div class="user-list__item-time" data-created_at="${user.last_ping}">
<a href="/user/${user.uid}.html">profile</a>
<a href="/channel/${user.uid}.html">dm</a>
</div>
</div>
</div>
`;
this.insertAdjacentHTML("beforeend", html);
});
}
}
customElements.define('user-list', UserList);

135
src/snek/sync.py Normal file
View File

@ -0,0 +1,135 @@
class DatasetWebSocketView:
def __init__(self):
self.ws = None
self.db = dataset.connect('sqlite:///snek.db')
self.setattr(self, "db", self.get)
self.setattr(self, "db", self.set)
)
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
call_uid = data.get("call_uid")
method = data.get("method")
table_name = data.get("table")
args = data.get("args", {})
kwargs = data.get("kwargs", {})
function = getattr(self.db, method, None)
if table_name:
function = getattr(self.db[table_name], method, None)
print(method, table_name, args, kwargs,flush=True)
if function:
response = {}
try:
result = function(*args, **kwargs)
print(result)
response['result'] = self.format_result(result)
response["call_uid"] = call_uid
response["success"] = True
except Exception as e:
response["call_uid"] = call_uid
response["success"] = False
response["error"] = str(e)
response["traceback"] = traceback.format_exc()
if call_uid:
await self.send_str(json.dumps(response,default=str))
else:
await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid}))
except Exception as e:
await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str))
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
return ws
class BroadCastSocketView:
def __init__(self):
self.ws = None
super()
def format_result(self, result):
try:
return dict(result)
except:
pass
try:
return [dict(row) for row in result]
except:
pass
return result
async def send_str(self, msg):
return await self.ws.send_str(msg)
def get(self, key):
returnl loads(dict(self.db['_kv'].get(key=key)['value']))
def set(self, key, value):
return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])
async def handle(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
self.ws = ws
app = request.app
app['broadcast_clients'].append(ws)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
print(msg.data)
for client in app['broadcast_clients'] if not client == ws:
await client.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' % ws.exception())
app['broadcast_clients'].remove(ws)
return ws
app = web.Application()
view = DatasetWebSocketView()
app['broadcast_clients'] = []
app.router.add_get('/db', view.handle)
app.router.add_get('/broadcast', sync_view.handle)

View File

@ -16,6 +16,10 @@
<script src="/html-frame.js" type="module"></script>
<script src="/app.js" type="module"></script>
<script src="/file-manager.js" type="module"></script>
<script src="/user-list.js"></script>
<script src="/message-list.js" type="module"></script>
<link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/base.css">
<link
rel="stylesheet"

View File

@ -0,0 +1,117 @@
<style>
/* Ensure the dialog appears centered when open as modal */
#online-users {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 12px;
padding: 24px;
background-color: #111; /* Deep black */
color: #f1f1f1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);
width: 90%;
max-width: 400px;
animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out;
z-index: 1000;
}
/* Backdrop styling */
#online-users::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Title and content */
#online-users .dialog-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 16px;
color: #fff;
}
#online-users .dialog-content {
font-size: 1rem;
color: #ccc;
margin-bottom: 20px;
}
/* Button layout */
#online-users .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Buttons */
#online-users .dialog-button {
padding: 8px 16px;
font-size: 0.95rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background 0.2s ease;
}
#online-users .dialog-button.primary {
background-color: #4f46e5;
color: white;
}
#online-users .dialog-button.primary:hover {
background-color: #4338ca;
}
#online-users .dialog-button.secondary {
background-color: #333;
color: #eee;
}
#online-users .dialog-button.secondary:hover {
background-color: #444;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }
to { transform: scale(1) translate(-50%, -50%); opacity: 1; }
}
</style>
<dialog id="online-users">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Currently online</h2></div>
<div class="dialog-content"><user-list></user-list></div>
<div class="dialog-actions">
<button class="dialog-button primary">Close</button>
</div>
</div>
</div>
</dialog>
<script>
const onlineDialog = document.getElementById("online-users");
const dialogButton = onlineDialog.querySelector('.dialog-button.primary');
dialogButton.addEventListener('click', () => {
onlineDialog.close();
});
async function showOnlineUsers() {
const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');
onlineDialog.querySelector('user-list').data = users;
onlineDialog.showModal();
}
</script>

View File

@ -9,19 +9,19 @@
<section class="chat-area">
<div class="chat-messages">
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
</div>
<message-list class="chat-messages">
{% for message in messages %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
</message-list>
<div class="chat-input">
<textarea placeholder="Type a message..." rows="2"></textarea>
<textarea list="chat-input-autocomplete-items" placeholder="Type a message..." rows="2" autocomplete="on"></textarea>
<upload-button channel="{{ channel.uid.value }}"></upload-button>
</div>
</section>
{% include "online.html" %}
<script type="module">
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
@ -30,18 +30,95 @@
function getInputField(){
return document.querySelector("textarea")
}
getInputField().autoComplete = {
"/online": () =>{
showOnlineUsers();
},
"/clear": () => {
document.querySelector(".chat-messages").innerHTML = '';
},
"/live": () =>{
getInputField().liveType = !getInputField().liveType
}
}
function initInputField(textBox) {
textBox.addEventListener('keydown', (e) => {
if(textBox.liveType == undefined){
textBox.liveType = false
}
textBox.addEventListener('keydown',async (e) => {
if(e.key === "ArrowUp"){
const value = findDivAboveText(e.target.value).querySelector('.text')
e.target.value = value.textContent
console.info("HIERR")
return
}
if (e.key === "Tab") {
const message = e.target.value.trim();
if (!message) {
return
}
let autoCompleteHandler = null;
Object.keys(e.target.autoComplete).forEach((key)=>{
if(key.startsWith(message)){
if(autoCompleteHandler){
return
}
autoCompleteHandler = key
}
})
if(autoCompleteHandler){
e.preventDefault();
e.target.value = autoCompleteHandler;
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const message = e.target.value.trim();
if (message) {
app.rpc.sendMessage(channelUid, message);
e.target.value = '';
if (!message) {
return
}
let autoCompleteHandler = e.target.autoComplete[message]
if(autoCompleteHandler){
const value = message;
e.target.value = '';
autoCompleteHandler(value)
return
}
e.target.value = '';
if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){
app.rpc.updateMessageText(textBox.lastMessageUid, message)
textBox.lastMessageUid = null
return
}
const messageResponse = await app.rpc.sendMessage(channelUid, message);
}else{
app.rpc.set_typing(channelUid)
if(textBox.liveType){
if(e.target.value[0] == "/"){
return
}
if(!textBox.lastMessageUid){
textBox.lastMessageUid = '?'
app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{
textBox.lastMessageUid = messageResponse
})
}
if(textBox.lastMessageUid == '?'){
return;
}
app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)
}
app.rpc.set_typing(channelUid)
}
});
document.querySelector("upload-button").addEventListener("upload",function(e){
@ -81,29 +158,7 @@
}
});
function triggerGlow(uid) {
document.querySelectorAll(".avatar").forEach((el)=>{
const div = el.closest('a');
if(el.href.indexOf(uid)!=-1){
el.classList.add('glow')
let originalColor = el.style.backgroundColor
//console.error(originalColor)
//el.style.backgroundColor = 'black'
setTimeout(()=>{
// el.style.backgroundColor = originalColor
// console.error(el.style.backgroundColor)
el.classList.remove('glow')
},1200)
}
})
}
app.ws.addEventListener("set_typing",(data)=>{
triggerGlow(data.data.user_uid)
})
const chatInput = document.querySelector(".chat-area")
chatInput.addEventListener("drop", async (e) => {

View File

@ -16,6 +16,9 @@ from snek.system.model import now
from snek.system.profiler import Profiler
from snek.system.view import BaseView
import logging
logger = logging.getLogger(__name__)
class RPCView(BaseView):
@ -170,10 +173,33 @@ class RPCView(BaseView):
)
return channels
async def update_message_text(self,message_uid, text):
self._require_login()
message = await self.services.channel_message.get(message_uid)
if message["user_uid"] != self.user_uid:
raise Exception("Not allowed")
await self.services.socket.broadcast(message["channel_uid"], {
"channel_uid": message["channel_uid"],
"event": "update_message_text",
"data": {
"message_uid": message_uid,
"text": text
}
})
message["message"] = text
if not text:
message['deleted_at'] = now()
else:
message['deleted_at'] = None
await self.services.channel_message.save(message)
return True
async def send_message(self, channel_uid, message):
self._require_login()
await self.services.chat.send(self.user_uid, channel_uid, message)
return True
message = await self.services.chat.send(self.user_uid, channel_uid, message)
return message["uid"]
async def echo(self, *args):
self._require_login()
@ -243,12 +269,14 @@ class RPCView(BaseView):
except Exception as ex:
result = {"exception": str(ex), "traceback": traceback.format_exc()}
success = False
logger.exception(ex)
if result != "noresponse":
await self._send_json(
{"callId": call_id, "success": success, "data": result}
)
except Exception as ex:
print(str(ex), flush=True)
logger.exception(ex)
await self._send_json(
{"callId": call_id, "success": False, "data": str(ex)}
)
@ -259,15 +287,15 @@ class RPCView(BaseView):
async def get_online_users(self, channel_uid):
self._require_login()
return [
{
"uid": record["uid"],
"username": record["username"],
"nick": record["nick"],
"last_ping": record["last_ping"],
}
async for record in self.services.channel.get_online_users(channel_uid)
results = [
record.record async for record in self.services.channel.get_online_users(channel_uid)
]
for result in results:
del result['email']
del result['password']
del result['deleted_at']
del result['updated_at']
return results
async def echo(self, obj):
await self.ws.send_json(obj)
@ -314,6 +342,7 @@ class RPCView(BaseView):
await rpc(msg.json())
except Exception as ex:
print("Deleting socket", ex, flush=True)
logger.exception(ex)
await self.services.socket.delete(ws)
break
elif msg.type == web.WSMsgType.ERROR: