Private
Public Access
Replaces the Phase 1 placeholder UI with a single persistent app shell (top bar + sidebar + chat pane, 860px responsive breakpoint) matching the "PWA chat system UI" design handoff: message bubbles with consecutive-run avatar/name grouping, auto-growing composer, room search, and the real DarkSingularity logo (also used to regenerate the PWA icons). The handoff didn't cover Phase 2 (private rooms, roles, invites) or browsing/joining open rooms, so those are added using the same visual language: a room info panel with role badges, invite-by-username with a pending-invites list, member management (remove/promote/demote/transfer ownership), room settings (rename/describe/delete), and separate browse-rooms/invites-inbox modals. Unread badges, last-message preview, and the typing indicator are deliberately deferred -- both need new backend features (read-tracking, a WS typing event) that weren't in scope this pass. Two small backend additions round out data the new UI needs but the API didn't expose: MessageRead.username (historic messages had no sender name) and InviteRead.target_username / MyInviteRead.room_name+invited_by_username (a recipient's invite list can't otherwise resolve a room they're not in). Both are additive; 35 backend tests still pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
52 lines
1.8 KiB
TypeScript
52 lines
1.8 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { senderColorIndex } from '../lib/messageGrouping'
|
|
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
|
import { UserAvatar } from './UserAvatar'
|
|
import './MessageList.css'
|
|
|
|
interface MessageListProps {
|
|
messages: (Message | ChatMessageEnvelope)[]
|
|
members: RoomMember[]
|
|
}
|
|
|
|
export function MessageList({ messages, members }: MessageListProps) {
|
|
const { user } = useAuth()
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ block: 'end' })
|
|
}, [messages.length])
|
|
|
|
return (
|
|
<div className="message-list">
|
|
{messages.map((msg, i) => {
|
|
const mine = msg.user_id === user?.id
|
|
const prev = messages[i - 1]
|
|
const showAvatar = !mine && (!prev || prev.user_id !== msg.user_id)
|
|
const showName = showAvatar
|
|
|
|
return (
|
|
<div key={msg.id} className={`message-row${mine ? ' message-row-mine' : ''}`}>
|
|
{!mine && (
|
|
<div className="message-avatar-slot">
|
|
{showAvatar && (
|
|
<UserAvatar username={msg.username} colorIndex={senderColorIndex(msg.username, members)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="message-bubble-wrap">
|
|
{showName && <div className="message-author">{msg.username}</div>}
|
|
<div className={`message-bubble${mine ? ' message-bubble-mine' : ''}`}>{msg.content}</div>
|
|
<div className="message-time">
|
|
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
)
|
|
}
|