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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user