feat: add real-time chat service with WebSocket broadcasting and message list UI

Implement core chat functionality including ChatService for message creation and notification dispatch, SocketService for WebSocket subscription management and broadcasting, RPC endpoint for client-server communication, and frontend components (message-list, message-list-manager, MessageModel) for rendering messages in real-time across channels.
This commit is contained in:
2025-01-26 21:51:51 +00:00
parent b71e74336e
commit b2898361fe
6 changed files with 282 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
from snek.system.service import BaseService
class ChatService(BaseService):
async def send(self,user_uid, channel_uid, message):
channel_message = await self.services.channel_message.create(
user_uid,
channel_uid,
message
)
channel_message_uid = channel_message["uid"]
user = await self.services.user.get(uid=user_uid)
async for channel_member in self.services.channel_member.find(
channel_uid=channel_message["channel_uid"],
is_banned=False,
is_muted=False,
deleted_at=None,
):
model = await self.new()
model["object_uid"] = channel_message_uid
model["object_type"] = "channel_message"
model["user_uid"] = channel_member["user_uid"]
model["message"] = (
f"New message from {user['nick']} in {channel_member['label']}."
)
if not await self.services.channel_member.save(model):
raise Exception(f"Failed to create notification: {model.errors}.")
sent_to_count = await self.services.socket.broadcast(channel_uid, dict(
message=message,
user_uid=user_uid,
channel_uid=channel_uid,
created_at=channel_message["created_at"],
updated_at=None,
uid=channel_message['uid'],
user_nick=user['nick']
))
return sent_to_count
+33
View File
@@ -0,0 +1,33 @@
from snek.system.service import BaseService
class SocketService(BaseService):
def __init__(self, app):
super().__init__(app)
self.sockets = set()
self.subscriptions = {}
async def add(self, ws):
self.sockets.add(ws)
async def subscribe(self, ws, channel_uid):
if not channel_uid in self.subscriptions:
self.subscriptions[channel_uid] = set()
self.subscriptions[channel_uid].add(ws)
async def broadcast(self, channel_uid, message):
print("BROADCAT!",message)
count = 0
for ws in self.subscriptions.get(channel_uid,[]):
await ws.send_json(message)
count += 1
return count
async def delete(self, ws):
try:
self.sockets.remove(ws)
except IndexError:
pass
+23
View File
@@ -0,0 +1,23 @@
class MessageListManagerElement extends HTMLElement {
constructor() {
super()
this.attachShadow({mode:'open'})
this.container = document.createElement("div")
this.shadowRoot.appendChild(this.container)
}
async connectedCallback() {
let channels = await app.rpc.getChannels()
const me = this
channels.forEach(channel=>{
const messageList = document.createElement("message-list")
messageList.setAttribute("channel",channel.uid)
me.container.appendChild(messageList)
})
}
}
customElements.define("message-list-manager",MessageListManagerElement)
+86
View File
@@ -0,0 +1,86 @@
class MessageListElement extends HTMLElement {
static get observedAttributes() {
return ["messages"];
}
messages = []
room = null
url = null
container = null
constructor() {
super()
this.attachShadow({ mode: 'open' });
this.component = document.createElement('div')
this.shadowRoot.appendChild(this.component )
}
createElement(message){
const element = document.createElement("div")
element.classList.add("message")
const avatar = document.createElement("div")
avatar.classList.add("avatar")
avatar.innerText = message.user_nick[0]
const messageContent = document.createElement("div")
messageContent.classList.add("message-content")
const author = document.createElement("div")
author.classList.add("author")
author.textContent = message.user_nick
const text = document.createElement("div")
text.classList.add("text")
text.textContent = message.message
const time = document.createElement("div")
time.classList.add("time")
time.textContent = message.created_at
messageContent.appendChild(author)
messageContent.appendChild(text)
messageContent.appendChild(time)
element.appendChild(avatar)
element.appendChild(messageContent)
message.element = element
return element
}
addMessage(message){
const obj = new models.Message(
message.uid,
message.channel_uid,
message.user_uid,
message.user_nick,
message.message,
message.created_at,
message.updated_at
)
const element = this.createElement(obj)
this.messages.push(obj)
this.container.appendChild(element)
return obj
}
connectedCallback() {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/base.css'
this.component.appendChild(link)
this.container = document.createElement('div')
this.container.classList.add("chat-messages")
this.component.appendChild(this.container)
this.messages = []
this.channel_uid = this.getAttribute("channel")
const me = this
app.addEventListener(this.channel_uid, (data) => {
console.info("WIIIIIIIIIIIIIIIIIIIIIIII")
me.addMessage(data)
})
this.dispatchEvent(new CustomEvent("rendered", {detail:this,bubbles:true}))
}
}
customElements.define('message-list', MessageListElement);
+22
View File
@@ -0,0 +1,22 @@
class MessageModel {
message = null
user_uid = null
channel_uid = null
created_at = null
updated_at = null
element = null
constructor(uid, channel_uid,user_uid,user_nick, message,created_at, updated_at){
this.uid = uid
this.message = message
this.user_uid = user_uid
this.user_nick = user_nick
this.channel_uid = channel_uid
this.created_at = created_at
this.updated_at = updated_at
}
}
const models = {
Message: MessageModel
}
+75
View File
@@ -0,0 +1,75 @@
from aiohttp import web
from snek.system.view import BaseView
class RPCView(BaseView):
login_required = True
class RPCApi:
def __init__(self,view, ws):
self.view = view
self.app = self.view.app
self.services = self.app.services
self.user_uid = self.view.session.get("uid")
self.ws = ws
async def get_channels(self):
channels = []
async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):
channels.append(dict(
name=subscription["label"],
uid=subscription["channel_uid"],
is_moderator=subscription["is_moderator"],
is_read_only=subscription["is_read_only"]
))
return channels
async def send_message(self, room, message):
await self.services.chat.send(self.user_uid,room,message)
return True
async def echo(self,*args):
return args
async def __call__(self, data):
call_id = data.get("callId")
method_name = data.get("method")
args = data.get("args")
if hasattr(super(),method_name) or not hasattr(self,method_name):
return await self.ws.send_json({"callId":call_id,"data":"Not allowed"})
method = getattr(self,method_name.replace(".","_"),None)
result = await method(*args)
await self.ws.send_json({"callId":call_id,"data":result})
async def call_ping(self,callId,*args):
return {"pong": args}
async def get(self):
ws = web.WebSocketResponse()
await ws.prepare(self.request)
await self.services.socket.add(ws)
async for subscription in self.services.channel_member.find(user_uid=self.session.get("uid"),deleted_at=None,is_banned=False):
await self.services.socket.subscribe(ws,subscription["channel_uid"])
print("Subscribed for: ", subscription["label"],flush=True)
rpc = RPCView.RPCApi(self,ws)
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
await rpc(msg.json())
elif msg.type == web.WSMsgType.ERROR:
print(f"WebSocket exception {ws.exception()}")
await self.services.socket.delete(ws)
print("WebSocket connection closed")
return ws