add in join/part messages, user list management, and reformat display (remove chat bubbles...)

This commit is contained in:
kc1awv 2026-01-08 07:52:24 -05:00
parent 8a27a3f518
commit d4d7ff28e5
No known key found for this signature in database
GPG key ID: 9819F9660E8A6739
5 changed files with 260 additions and 108 deletions

View file

@ -896,25 +896,17 @@ class BackendService:
self.ping_task = self.loop.create_task(self._ping_loop())
async def _on_joined(self, room: str, env: dict) -> None:
"""Handle JOINED confirmation from RRC."""
if room not in self.rooms:
if len(self.rooms) >= self.max_rooms:
logger.error(f"Room limit reached ({self.max_rooms}), cannot join room: {room}")
if self.broadcast:
await self.broadcast(
{
"type": "error",
"error": f"Cannot join room: server room limit reached ({self.max_rooms})",
}
)
return
self.rooms[room] = {"messages": [], "users": set()}
"""Handle JOINED confirmation from RRC.
This handles two scenarios:
1. Self-join: Multiple hashes (full member list) - we just joined the room
2. Member-join: Single hash - another user joined the room we're in
"""
body = env.get(K_BODY)
users = []
logger.debug(f"JOINED room={room}, body type={type(body)}, body={body}")
# Extract user list from body (could be dict or list)
user_list = None
if isinstance(body, dict):
user_list = body.get(B_JOINED_USERS)
@ -923,7 +915,28 @@ class BackendService:
user_list = body
logger.debug(f"Body is list directly, user_list={user_list}")
if isinstance(user_list, list):
if not isinstance(user_list, list):
user_list = []
# Determine if this is a self-join (multiple users) or member-join (single user)
is_self_join = len(user_list) != 1
if is_self_join:
# We're joining the room - create/reset room with full member list
if room not in self.rooms:
if len(self.rooms) >= self.max_rooms:
logger.error(f"Room limit reached ({self.max_rooms}), cannot join room: {room}")
if self.broadcast:
await self.broadcast(
{
"type": "error",
"error": f"Cannot join room: server room limit reached ({self.max_rooms})",
}
)
return
self.rooms[room] = {"messages": [], "users": set()}
users = []
for user_hash in user_list:
if isinstance(user_hash, (bytes, bytearray)):
user_hex = user_hash.hex()
@ -931,44 +944,138 @@ class BackendService:
users.append(self._format_user(user_hash))
logger.debug(f"Added user: {self._format_user(user_hash)}")
message = {
"type": "system",
"room": room,
"text": f"Joined room: {room}",
"timestamp": self._get_timestamp(),
}
self.rooms[room]["messages"].append(message)
if self.broadcast:
await self.broadcast(message)
await self.broadcast(
{
"type": "room_joined",
"room": room,
"users": users,
}
)
async def _on_parted(self, room: str, _env: dict) -> None:
"""Handle PARTED confirmation from RRC."""
message = {
"type": "system",
"room": room,
"text": f"Left room: {room}",
"timestamp": self._get_timestamp(),
}
if room in self.rooms:
message = {
"type": "system",
"room": room,
"text": f"Joined room: {room}",
"timestamp": self._get_timestamp(),
}
self.rooms[room]["messages"].append(message)
if self.broadcast:
await self.broadcast(message)
await self.broadcast(
{
"type": "room_parted",
if self.broadcast:
await self.broadcast(message)
await self.broadcast(
{
"type": "room_joined",
"room": room,
"users": users,
}
)
else:
# Another user joined the room we're already in
if room not in self.rooms:
logger.warning(f"Received JOINED for unknown room: {room}")
return
user_hash = user_list[0]
if isinstance(user_hash, (bytes, bytearray)):
user_hex = user_hash.hex()
# Add user to room member list
self.rooms[room]["users"].add(user_hex)
user_formatted = self._format_user(user_hash)
# Create join notification message
message = {
"type": "join",
"room": room,
"user": user_formatted,
"timestamp": self._get_timestamp(),
}
)
self.rooms[room]["messages"].append(message)
if self.broadcast:
await self.broadcast(message)
# Also send user list update
users = [self._format_user(bytes.fromhex(u)) for u in self.rooms[room]["users"]]
await self.broadcast(
{
"type": "user_list_update",
"room": room,
"users": users,
}
)
async def _on_parted(self, room: str, env: dict) -> None:
"""Handle PARTED confirmation from RRC.
This handles two scenarios:
1. Self-part: Multiple hashes (remaining members) - we left the room
2. Member-part: Single hash - another user left the room we're in
"""
body = env.get(K_BODY)
logger.debug(f"PARTED room={room}, body type={type(body)}, body={body}")
# Extract user list from body (could be dict or list)
user_list = None
if isinstance(body, dict):
user_list = body.get(B_JOINED_USERS) # Reuse same key for remaining members
logger.debug(f"Body is dict, user_list={user_list}")
elif isinstance(body, list):
user_list = body
logger.debug(f"Body is list directly, user_list={user_list}")
if not isinstance(user_list, list):
user_list = []
# Determine if this is a self-part (we left) or member-part (single user left)
is_self_part = len(user_list) != 1
if is_self_part:
# We left the room
message = {
"type": "system",
"room": room,
"text": f"Left room: {room}",
"timestamp": self._get_timestamp(),
}
if room in self.rooms:
self.rooms[room]["messages"].append(message)
if self.broadcast:
await self.broadcast(message)
await self.broadcast(
{
"type": "room_parted",
"room": room,
}
)
else:
# Another user left the room we're in
if room not in self.rooms:
logger.warning(f"Received PARTED for unknown room: {room}")
return
user_hash = user_list[0]
if isinstance(user_hash, (bytes, bytearray)):
user_hex = user_hash.hex()
user_formatted = self._format_user(user_hash)
# Remove user from room member list
self.rooms[room]["users"].discard(user_hex)
# Create part notification message
message = {
"type": "part",
"room": room,
"user": user_formatted,
"timestamp": self._get_timestamp(),
}
self.rooms[room]["messages"].append(message)
if self.broadcast:
await self.broadcast(message)
# Also send user list update
users = [self._format_user(bytes.fromhex(u)) for u in self.rooms[room]["users"]]
await self.broadcast(
{
"type": "user_list_update",
"room": room,
"users": users,
}
)
async def _on_close(self) -> None:
"""Handle connection close from RRC."""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,10 @@
<script>
import { currentRoomData, identityHash, sentMessageIds } from "../stores.js";
import { currentRoom, currentRoomData, identityHash, sentMessageIds } from "../stores.js";
let messageContainer;
let shouldAutoScroll = $state(true);
let showScrollButton = $state(false);
let showTimestamps = $state(true);
// Check if user is scrolled near the bottom
function isNearBottom() {
@ -69,64 +70,65 @@
return false;
}
function getMessageClass(msg) {
if (isOwnMessage(msg)) {
return "chat chat-end";
}
function getMessageText(msg) {
switch (msg.type) {
case "message":
return "chat chat-start";
case "system":
return "chat chat-end";
case "join":
return `→ ${msg.user} joined`;
case "part":
return `← ${msg.user} left`;
case "notice":
return "chat chat-end";
case "error":
return "chat chat-end";
return formatNotice(msg.text);
default:
return "chat";
return msg.text;
}
}
function getBubbleClass(msg) {
if (isOwnMessage(msg)) {
return "chat-bubble chat-bubble-accent";
function formatNotice(text) {
if (!text) return text;
// Parse IRC-style room notice: "room test: registered; mode=+nrt; topic=this is a test room"
const match = text.match(/^room\s+(\S+):\s*registered;\s*mode=([^;]+);\s*topic=(.+)$/);
if (match) {
const [, roomName, mode, topic] = match;
return topic ? `Topic: ${topic}` : `Room ${roomName} registered`;
}
switch (msg.type) {
case "message":
return "chat-bubble chat-bubble-primary";
case "system":
return "chat-bubble chat-bubble-info";
case "notice":
return "chat-bubble chat-bubble-warning";
case "error":
return "chat-bubble chat-bubble-error";
default:
return "chat-bubble";
}
// If it doesn't match the expected format, return as-is
return text;
}
</script>
<div class="message-area-wrapper">
{#if $currentRoom !== '[Hub]'}
<div class="flex justify-end p-2 border-b border-base-300">
<label class="label cursor-pointer gap-2">
<span class="label-text text-xs">Show timestamps</span>
<input type="checkbox" class="toggle toggle-sm" bind:checked={showTimestamps} />
</label>
</div>
{/if}
<div bind:this={messageContainer} class="message-area p-4" onscroll={handleScroll}>
{#each ($currentRoomData?.messages || []) as msg, index (msg.timestamp + msg.text + index)}
<div class={getMessageClass(msg)}>
{#if msg.user}
<div class="chat-header">
{msg.user}
<time class="text-xs opacity-50">{msg.timestamp}</time>
</div>
{/if}
<div class={getBubbleClass(msg)} style="white-space: pre-line;">
{msg.text}
{#each ($currentRoomData?.messages || []) as msg, index (msg.timestamp + (msg.text || msg.type || '') + (msg.user || '') + index)}
{#if msg.type === 'join' || msg.type === 'part' || msg.type === 'notice' || msg.type === 'system' || msg.type === 'error'}
<!-- Join/Part/Notice/System/Error informational messages -->
<div class="my-2">
{#if showTimestamps || $currentRoom === '[Hub]'}
<span class="text-xs opacity-40 mr-2">{msg.timestamp}</span>
{/if}
<span class="text-sm opacity-60 {msg.type === 'join' ? 'text-success' : msg.type === 'part' ? 'text-warning' : msg.type === 'error' ? 'text-error' : 'text-info'}">
{getMessageText(msg)}
</span>
</div>
{#if !msg.user && msg.timestamp}
<div class="chat-footer opacity-50">
<time class="text-xs">{msg.timestamp}</time>
</div>
{/if}
</div>
{:else if msg.type === 'message'}
<!-- Regular chat messages -->
<div class="my-1">
{#if showTimestamps || $currentRoom === '[Hub]'}
<span class="text-xs opacity-40 mr-2">{msg.timestamp}</span>
{/if}
<span class="font-semibold {isOwnMessage(msg) ? 'text-accent' : 'text-primary'}">{msg.user}</span>
<span class="ml-2" style="white-space: pre-line;">{msg.text}</span>
</div>
{/if}
{/each}
</div>
@ -164,13 +166,4 @@
transform: translateY(-2px);
box-shadow: 0 6px 8px -1px rgba(0, 0, 0, 0.15), 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* --TEST-- Fix text wrapping for long messages */
:global(.chat-bubble) {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
white-space: pre-wrap;
}
</style>

View file

@ -96,6 +96,12 @@ function handleMessage(data) {
case 'system':
addSystemMessage(data);
break;
case 'join':
addJoinMessage(data);
break;
case 'part':
addPartMessage(data);
break;
case 'message_sent':
handleMessageSent(data);
break;
@ -236,6 +242,52 @@ function addSystemMessage(data) {
});
}
function addJoinMessage(data) {
const room = data.room || get(currentRoom);
rooms.update(r => {
if (!r.has(room)) {
r.set(room, { messages: [], users: new Set(), unread: 0 });
}
const roomData = r.get(room);
roomData.messages.push({
type: 'join',
user: data.user,
timestamp: data.timestamp || new Date().toLocaleTimeString()
});
if (room !== get(currentRoom)) {
roomData.unread++;
}
return new Map(r);
});
}
function addPartMessage(data) {
const room = data.room || get(currentRoom);
rooms.update(r => {
if (!r.has(room)) {
r.set(room, { messages: [], users: new Set(), unread: 0 });
}
const roomData = r.get(room);
roomData.messages.push({
type: 'part',
user: data.user,
timestamp: data.timestamp || new Date().toLocaleTimeString()
});
if (room !== get(currentRoom)) {
roomData.unread++;
}
return new Map(r);
});
}
function handleMessageSent(data) {
// Track the message ID of messages we sent
if (data.message_id) {