Files
go-socket/docs/superpowers/plans/2026-04-28-discord-client.md
T

950 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Discord Chat Client Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
---
## ▶ Continuation State (last updated 2026-04-28)
Pick up here if resuming on a new device or session.
### Task Status
| # | Task | Status | Notes |
|---|------|--------|-------|
| 1 | API utilities (`http.js` + `ws.js`) | ✅ Done | Error logging added, empty-token guard added |
| 2 | AuthPage real API wiring | ✅ Done | `doLogin` helper extracted, loading guard on Enter key |
| 3 | AppContext global state + WS events | ✅ Done | Race condition fix in `selectConnection`, `sendMessage` rollback added |
| 4 | MainApp two-column layout | ✅ Done | |
| 5 | HubRow + UserBar | ✅ Done | `colorToCss` extracted to `src/utils/color.js` (shared), stub buttons `disabled` |
| 6 | ConnectionItem + ConnectionList + Sidebar | ✅ Done | `h-full` on Sidebar, search fallback for unresolved users, `min-w-0` truncate fix |
| 7 | MessageItem + MessageList + MessageInput + ChatArea | 🔄 Created, review **interrupted** — needs spec + quality review before proceeding |
| 8 | End-to-end verification | ⬜ Pending | Run `pnpm dev` in `client/`, manual browser smoke test (see task for steps) |
### Extra file created (not in original plan)
- `client/src/utils/color.js` — shared `colorToCss([r,g,b,a])` utility used by UserBar, ConnectionItem, MessageItem, ChatArea
### How to resume
**Option A — continue subagent-driven development (recommended):**
1. Open project in Claude Code: `cd /home/ffus/Projects/go-socket`
2. Use skill `superpowers:subagent-driven-development`
3. Start with Task 7 review: dispatch spec reviewer then code quality reviewer for the four files listed under Task 7
4. Then dispatch Task 8 (verification) — requires running `pnpm dev` and testing in browser
**Option B — inline continuation:**
1. Open project in Claude Code
2. Say: "Continue the plan at `docs/superpowers/plans/2026-04-28-discord-client.md`. Tasks 16 done, Task 7 files created but review was interrupted. Resume from Task 7 spec review."
### Key decisions made during implementation (not in plan)
- `colorToCss` shared in `src/utils/color.js` — do NOT redefine it per-component
- `sendMessage` reads `userId` fresh from localStorage at call time (not via closure) to avoid stale capture
- `selectConnection` clears messages immediately before fetch and guards result with `selectedIdRef`
- WS_DM handler compares `event.receiver` (connection UUID) to `selectedIdRef.current` — this is **correct**, `message.Receiver = conn.Id` in server code
- Stub buttons (HubRow `+`, UserBar gear) use `disabled` attribute
- `state.connections` is always initialized as `[]` in context — no null guard needed on `.filter()`
---
**Goal:** Build a Discord-inspired DM chat UI in the React client, wired to the go-socket backend via REST and WebSocket.
**Architecture:** Two-column layout (280px sidebar + flex chat). Global state in a React Context + useReducer. Thin fetch wrapper auto-injects the `token` header. WebSocket is a singleton module with per-event-type handlers. No test framework is installed — each task verifies in the browser via `pnpm dev`.
**Tech Stack:** React 19, React Router DOM 7, Tailwind CSS 4, Vite 8. Backend at `http://localhost:8080`.
---
## File Map
**Created:**
- `client/src/api/http.js` — fetch wrapper, auto-injects token
- `client/src/api/ws.js` — WebSocket singleton, event dispatch
- `client/src/context/AppContext.jsx` — global state, data loading, WS wiring
- `client/src/components/HubRow.jsx` — stub hub icons row
- `client/src/components/UserBar.jsx` — bottom current-user bar
- `client/src/components/ConnectionItem.jsx` — single DM contact row
- `client/src/components/ConnectionList.jsx` — filtered list + search input
- `client/src/components/Sidebar.jsx` — assembles sidebar panels
- `client/src/components/MessageItem.jsx` — single chat message
- `client/src/components/MessageList.jsx` — scrollable message history
- `client/src/components/MessageInput.jsx` — text input + send
- `client/src/components/ChatArea.jsx` — assembles chat panel
**Modified:**
- `client/src/components/AuthPage.jsx` — wire up real login/register API
- `client/src/components/MainApp.jsx` — replace stub with full layout
---
## Key API Facts
- All REST requests send token as a plain header named `token` (not Bearer)
- `POST /token` body (form-encoded): `username`, `password``{ token, userId }`
- `POST /user` body: `username`, `password` → 201 Created
- `GET /connections``Connection[]`
- `GET /user?targetid=<uuid>``{ name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] }`
- `GET /connections/unreadmessages?connections=uuid,uuid``uint32[]` (same order)
- `GET /connection/messages?connectionid=<uuid>``Message[]` (oldest first)
- `POST /connection/message` body: `connectionid`, `msgContent` → 202 (no body)
- WebSocket: connect to `ws://localhost:8080/ws`, on open send `{"token":"..."}`, server replies `{"type":0,"event":{"success":true,"error":""}}`
## Key Data Shapes
```js
// Connection
{ id, createdAt, requestorId, recipientId, userWantingToElevate, state }
// state: 0 = Stranger, 1 = Friend
// Message
{ id, content, attachedFile, createdAt, sender /* user UUID */, receiver /* connection UUID */ }
// User (from GET /user)
{ name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] }
// WS envelope
{ type: 0-9, event: any }
// type 1 (DirectMessage): event = Message
// type 2 (ConnectionCreated): event = Connection
// type 3 (ConnectionDeleted): event = "uuid-string"
// type 4 (ConnectionElevated): event = { id, newState: 1 }
// type 5 (ConnectionDeElevated): event = { id, newState: 0 }
```
---
## Task 1: API Utilities
**Files:**
- Create: `client/src/api/http.js`
- Create: `client/src/api/ws.js`
- [ ] **Step 1: Create `client/src/api/http.js`**
```js
const BASE = 'http://localhost:8080'
export async function apiFetch(method, path, { body, query } = {}) {
const token = localStorage.getItem('token') || ''
let url = BASE + path
if (query) url += '?' + new URLSearchParams(query)
const opts = { method, headers: { token } }
if (body) opts.body = new URLSearchParams(body)
const res = await fetch(url, opts)
if (!res.ok) throw new Error(`${method} ${path}${res.status}`)
const text = await res.text()
try { return JSON.parse(text) } catch { return text }
}
```
- [ ] **Step 2: Create `client/src/api/ws.js`**
```js
let socket = null
const handlers = {}
export function wsOnEvent(type, fn) {
handlers[type] = fn
}
export function wsConnect() {
if (socket) return
const token = localStorage.getItem('token') || ''
socket = new WebSocket('ws://localhost:8080/ws')
socket.onopen = () => socket.send(JSON.stringify({ token }))
socket.onmessage = (e) => {
try {
const msg = JSON.parse(e.data)
handlers[msg.type]?.(msg.event)
} catch {}
}
socket.onclose = () => { socket = null }
}
export function wsDisconnect() {
socket?.close()
socket = null
}
```
---
## Task 2: AuthPage — Wire Up Real API
**Files:**
- Modify: `client/src/components/AuthPage.jsx`
The login response is `{ token, userId }`. Both must be saved to localStorage — `userId` is needed to identify "my" messages and to fetch own profile.
- [ ] **Step 1: Replace `client/src/components/AuthPage.jsx` with wired version**
```jsx
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const BASE = 'http://localhost:8080'
export default function AuthPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleLogin() {
setError('')
setLoading(true)
try {
const res = await fetch(`${BASE}/token`, {
method: 'POST',
body: new URLSearchParams({ username, password }),
})
if (!res.ok) { setError('Invalid credentials'); return }
const { token, userId } = await res.json()
localStorage.setItem('token', token)
localStorage.setItem('userId', userId)
navigate('/')
} catch {
setError('Could not reach server')
} finally {
setLoading(false)
}
}
async function handleRegister() {
setError('')
setLoading(true)
try {
const res = await fetch(`${BASE}/user`, {
method: 'POST',
body: new URLSearchParams({ username, password }),
})
if (!res.ok) { setError('Registration failed'); return }
await handleLogin()
} catch {
setError('Could not reach server')
} finally {
setLoading(false)
}
}
function onKey(e) {
if (e.key === 'Enter') handleLogin()
}
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col gap-4 p-8 bg-gray-800 rounded-lg w-80">
<h1 className="text-white text-xl font-semibold text-center">go-socket</h1>
<input
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
onKeyDown={onKey}
autoComplete="username"
/>
<input
className="bg-gray-700 text-white px-4 py-2 rounded outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={onKey}
autoComplete="current-password"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex justify-end gap-2 mt-2">
<button
onClick={handleRegister}
disabled={loading}
className="px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 text-white disabled:opacity-50"
>
Register
</button>
<button
onClick={handleLogin}
disabled={loading}
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
>
Login
</button>
</div>
</div>
</div>
)
}
```
- [ ] **Step 2: Verify in browser**
Run `pnpm dev` in `client/`. Navigate to `http://localhost:5173/auth`.
- Enter valid credentials → should redirect to `/` (currently shows "Main App")
- Enter wrong credentials → should show "Invalid credentials"
- Click Register with a new username → should create account and redirect
---
## Task 3: AppContext — Global State + Data Loading + WS Events
**Files:**
- Create: `client/src/context/AppContext.jsx`
`userMap` is a `{ [userId]: userDetails }` dict populated by fetching `GET /user` for each connection's "other" participant. The `selectedIdRef` keeps the WS handler in sync with the current selection without stale closures.
- [ ] **Step 1: Create `client/src/context/AppContext.jsx`**
```jsx
import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react'
import { apiFetch } from '../api/http'
import { wsConnect, wsDisconnect, wsOnEvent } from '../api/ws'
const Ctx = createContext(null)
const WS_AUTH = 0
const WS_DM = 1
const WS_CONN_CREATED = 2
const WS_CONN_DELETED = 3
const WS_CONN_ELEVATED = 4
const WS_CONN_DEELEVATED = 5
const WS_USER_PROFILE = 6
const initial = {
connections: [],
userMap: {},
unread: {},
selectedId: null,
messages: [],
currentUser: null,
}
function reducer(state, action) {
switch (action.type) {
case 'SET_CONNECTIONS':
return { ...state, connections: action.payload }
case 'SET_USER_MAP':
return { ...state, userMap: { ...state.userMap, ...action.payload } }
case 'SET_UNREAD':
return { ...state, unread: action.payload }
case 'SET_CURRENT_USER':
return { ...state, currentUser: action.payload }
case 'SELECT_CONNECTION':
return {
...state,
selectedId: action.id,
unread: { ...state.unread, [action.id]: 0 },
}
case 'SET_MESSAGES':
return { ...state, messages: action.payload }
case 'APPEND_MESSAGE':
return { ...state, messages: [...state.messages, action.payload] }
case 'BUMP_UNREAD':
return {
...state,
unread: { ...state.unread, [action.id]: (state.unread[action.id] || 0) + 1 },
}
case 'ADD_CONNECTION':
return { ...state, connections: [action.payload, ...state.connections] }
case 'REMOVE_CONNECTION':
return {
...state,
connections: state.connections.filter(c => c.id !== action.id),
selectedId: state.selectedId === action.id ? null : state.selectedId,
messages: state.selectedId === action.id ? [] : state.messages,
}
case 'UPDATE_CONN_STATE':
return {
...state,
connections: state.connections.map(c =>
c.id === action.id ? { ...c, state: action.newState } : c
),
}
case 'PATCH_USER':
return {
...state,
userMap: {
...state.userMap,
[action.userId]: { ...state.userMap[action.userId], ...action.changes },
},
}
default:
return state
}
}
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initial)
const selectedIdRef = useRef(null)
const userId = localStorage.getItem('userId')
useEffect(() => {
selectedIdRef.current = state.selectedId
}, [state.selectedId])
useEffect(() => {
async function init() {
const me = await apiFetch('GET', '/user', { query: { targetid: userId } })
dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: userId } })
const conns = await apiFetch('GET', '/connections')
dispatch({ type: 'SET_CONNECTIONS', payload: conns })
const otherIds = [...new Set(
conns.map(c => c.requestorId === userId ? c.recipientId : c.requestorId)
)]
const entries = await Promise.all(
otherIds.map(async id => {
const u = await apiFetch('GET', '/user', { query: { targetid: id } })
return [id, u]
})
)
dispatch({ type: 'SET_USER_MAP', payload: Object.fromEntries(entries) })
if (conns.length > 0) {
const ids = conns.map(c => c.id).join(',')
const counts = await apiFetch('GET', '/connections/unreadmessages', { query: { connections: ids } })
const unreadMap = {}
conns.forEach((c, i) => { unreadMap[c.id] = counts[i] || 0 })
dispatch({ type: 'SET_UNREAD', payload: unreadMap })
}
wsOnEvent(WS_DM, (event) => {
if (event.receiver === selectedIdRef.current) {
dispatch({ type: 'APPEND_MESSAGE', payload: event })
} else {
dispatch({ type: 'BUMP_UNREAD', id: event.receiver })
}
})
wsOnEvent(WS_CONN_CREATED, async (event) => {
dispatch({ type: 'ADD_CONNECTION', payload: event })
const otherId = event.requestorId === userId ? event.recipientId : event.requestorId
const u = await apiFetch('GET', '/user', { query: { targetid: otherId } })
dispatch({ type: 'SET_USER_MAP', payload: { [otherId]: u } })
})
wsOnEvent(WS_CONN_DELETED, (id) => {
dispatch({ type: 'REMOVE_CONNECTION', id })
})
wsOnEvent(WS_CONN_ELEVATED, ({ id, newState }) => {
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
})
wsOnEvent(WS_CONN_DEELEVATED, ({ id, newState }) => {
dispatch({ type: 'UPDATE_CONN_STATE', id, newState })
})
// event = { userId, profileChangeList: { pronouns?, description?, color? } }
wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => {
dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList })
})
// types 7 (avatar) and 8 (profilebg) don't affect our color-circle display — ignore
wsConnect()
}
init()
return () => wsDisconnect()
}, [])
const selectConnection = useCallback(async (id) => {
dispatch({ type: 'SELECT_CONNECTION', id })
const msgs = await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })
dispatch({ type: 'SET_MESSAGES', payload: msgs })
}, [])
const sendMessage = useCallback(async (connectionId, content) => {
const optimistic = {
id: crypto.randomUUID(),
content,
attachedFile: '',
createdAt: new Date().toISOString(),
sender: userId,
receiver: connectionId,
}
dispatch({ type: 'APPEND_MESSAGE', payload: optimistic })
await apiFetch('POST', '/connection/message', { body: { connectionid: connectionId, msgContent: content } })
}, [userId])
return (
<Ctx.Provider value={{ state, selectConnection, sendMessage, userId }}>
{children}
</Ctx.Provider>
)
}
export function useApp() {
return useContext(Ctx)
}
```
---
## Task 4: MainApp — Two-Column Layout
**Files:**
- Modify: `client/src/components/MainApp.jsx`
- [ ] **Step 1: Replace `client/src/components/MainApp.jsx`**
```jsx
import { AppProvider } from '../context/AppContext'
import Sidebar from './Sidebar'
import ChatArea from './ChatArea'
export default function MainApp() {
return (
<AppProvider>
<div className="flex h-screen bg-gray-900 text-white overflow-hidden">
<Sidebar />
<ChatArea />
</div>
</AppProvider>
)
}
```
---
## Task 5: HubRow and UserBar
**Files:**
- Create: `client/src/components/HubRow.jsx`
- Create: `client/src/components/UserBar.jsx`
- [ ] **Step 1: Create `client/src/components/HubRow.jsx`**
```jsx
export default function HubRow() {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-700/60">
<button
title="Add hub"
className="w-8 h-8 rounded-full bg-gray-700 hover:bg-gray-600 flex items-center justify-center text-gray-400 hover:text-white text-lg leading-none transition-colors"
>
+
</button>
</div>
)
}
```
- [ ] **Step 2: Create `client/src/components/UserBar.jsx`**
```jsx
import { useApp } from '../context/AppContext'
function colorToCss(color) {
if (!color) return '#4b5563'
return `rgb(${color[0]},${color[1]},${color[2]})`
}
export default function UserBar() {
const { state } = useApp()
const user = state.currentUser
return (
<div className="flex items-center gap-2 px-3 py-3 border-t border-gray-700/60 bg-gray-900/80">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
style={{ backgroundColor: colorToCss(user?.color) }}
>
{user?.name?.[0]?.toUpperCase() ?? '?'}
</div>
<span className="text-sm font-medium text-gray-200 truncate flex-1">
{user?.name ?? '…'}
</span>
<button
title="Settings (coming soon)"
className="text-gray-500 hover:text-gray-300 transition-colors text-base px-1"
>
</button>
</div>
)
}
```
---
## Task 6: ConnectionItem, ConnectionList, Sidebar
**Files:**
- Create: `client/src/components/ConnectionItem.jsx`
- Create: `client/src/components/ConnectionList.jsx`
- Create: `client/src/components/Sidebar.jsx`
- [ ] **Step 1: Create `client/src/components/ConnectionItem.jsx`**
The "other user" is whichever of `requestorId`/`recipientId` isn't our `userId`.
```jsx
import { useApp } from '../context/AppContext'
function colorToCss(color) {
if (!color) return '#4b5563'
return `rgb(${color[0]},${color[1]},${color[2]})`
}
export default function ConnectionItem({ conn }) {
const { state, selectConnection, userId } = useApp()
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
const other = state.userMap[otherId]
const unread = state.unread[conn.id] || 0
const isSelected = state.selectedId === conn.id
const isFriend = conn.state === 1
return (
<button
onClick={() => selectConnection(conn.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors
${isSelected ? 'bg-gray-700' : 'hover:bg-gray-700/50'}`}
>
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
style={{ backgroundColor: colorToCss(other?.color) }}
>
{other?.name?.[0]?.toUpperCase() ?? '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-gray-100 truncate">
{other?.name ?? otherId.slice(0, 8)}
</span>
{isFriend && (
<span className="text-[10px] text-green-400 shrink-0"> friend</span>
)}
</div>
{other?.pronouns && (
<span className="text-xs text-gray-500 truncate block">{other.pronouns}</span>
)}
</div>
{unread > 0 && (
<span className="shrink-0 bg-red-500 text-white text-xs font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
)
}
```
- [ ] **Step 2: Create `client/src/components/ConnectionList.jsx`**
```jsx
import { useState } from 'react'
import { useApp } from '../context/AppContext'
import ConnectionItem from './ConnectionItem'
export default function ConnectionList() {
const { state, userId } = useApp()
const [search, setSearch] = useState('')
const filtered = state.connections.filter(conn => {
if (!search) return true
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
const other = state.userMap[otherId]
return other?.name?.toLowerCase().includes(search.toLowerCase())
})
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="px-3 py-2">
<input
className="w-full bg-gray-700 text-white text-sm px-3 py-1.5 rounded-md outline-none placeholder-gray-500 focus:ring-1 focus:ring-gray-500"
placeholder="Search…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-0.5">
{filtered.length === 0 && (
<p className="text-gray-600 text-xs text-center py-4">No contacts</p>
)}
{filtered.map(conn => (
<ConnectionItem key={conn.id} conn={conn} />
))}
</div>
</div>
)
}
```
- [ ] **Step 3: Create `client/src/components/Sidebar.jsx`**
```jsx
import HubRow from './HubRow'
import ConnectionList from './ConnectionList'
import UserBar from './UserBar'
export default function Sidebar() {
return (
<div className="w-[280px] shrink-0 flex flex-col bg-gray-900 border-r border-gray-700/60">
<HubRow />
<ConnectionList />
<UserBar />
</div>
)
}
```
---
## Task 7: MessageItem, MessageList, MessageInput, ChatArea
**Files:**
- Create: `client/src/components/MessageItem.jsx`
- Create: `client/src/components/MessageList.jsx`
- Create: `client/src/components/MessageInput.jsx`
- Create: `client/src/components/ChatArea.jsx`
- [ ] **Step 1: Create `client/src/components/MessageItem.jsx`**
Messages are displayed in Discord style: avatar + sender name on first message of a group, compact rows for consecutive same-sender messages.
```jsx
import { useApp } from '../context/AppContext'
function colorToCss(color) {
if (!color) return '#4b5563'
return `rgb(${color[0]},${color[1]},${color[2]})`
}
function formatTime(iso) {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export default function MessageItem({ msg, showHeader }) {
const { state, userId } = useApp()
const isMe = msg.sender === userId
const sender = isMe ? state.currentUser : state.userMap[msg.sender]
return (
<div className={`flex gap-3 px-4 ${showHeader ? 'mt-4' : 'mt-0.5'} group`}>
{showHeader ? (
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0 mt-0.5"
style={{ backgroundColor: colorToCss(sender?.color) }}
>
{sender?.name?.[0]?.toUpperCase() ?? '?'}
</div>
) : (
<div className="w-9 shrink-0" />
)}
<div className="flex-1 min-w-0">
{showHeader && (
<div className="flex items-baseline gap-2 mb-0.5">
<span
className="text-sm font-semibold"
style={{ color: colorToCss(sender?.color) }}
>
{sender?.name ?? msg.sender.slice(0, 8)}
</span>
<span className="text-xs text-gray-500">{formatTime(msg.createdAt)}</span>
</div>
)}
<p className="text-sm text-gray-200 break-words leading-relaxed">{msg.content}</p>
</div>
<span className="text-[10px] text-gray-600 opacity-0 group-hover:opacity-100 self-center shrink-0 transition-opacity">
{formatTime(msg.createdAt)}
</span>
</div>
)
}
```
- [ ] **Step 2: Create `client/src/components/MessageList.jsx`**
Groups consecutive messages from the same sender so that only the first shows the header (avatar + name).
```jsx
import { useEffect, useRef } from 'react'
import { useApp } from '../context/AppContext'
import MessageItem from './MessageItem'
export default function MessageList() {
const { state } = useApp()
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [state.messages])
return (
<div className="flex-1 overflow-y-auto py-2">
{state.messages.map((msg, i) => {
const prev = state.messages[i - 1]
const showHeader = !prev || prev.sender !== msg.sender
return <MessageItem key={msg.id} msg={msg} showHeader={showHeader} />
})}
<div ref={bottomRef} />
</div>
)
}
```
- [ ] **Step 3: Create `client/src/components/MessageInput.jsx`**
```jsx
import { useState, useRef } from 'react'
import { useApp } from '../context/AppContext'
export default function MessageInput() {
const { state, sendMessage } = useApp()
const [text, setText] = useState('')
const textareaRef = useRef(null)
async function handleSend() {
const trimmed = text.trim()
if (!trimmed || !state.selectedId) return
setText('')
await sendMessage(state.selectedId, trimmed)
textareaRef.current?.focus()
}
function onKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!state.selectedId) return null
return (
<div className="px-4 py-3 border-t border-gray-700/60">
<div className="flex items-end gap-2 bg-gray-700 rounded-lg px-4 py-2">
<textarea
ref={textareaRef}
className="flex-1 bg-transparent text-white text-sm resize-none outline-none placeholder-gray-500 max-h-36 leading-relaxed"
rows={1}
placeholder="Message…"
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
/>
<button
onClick={handleSend}
disabled={!text.trim()}
className="text-blue-400 hover:text-blue-300 disabled:text-gray-600 transition-colors pb-0.5 shrink-0"
title="Send (Enter)"
>
</button>
</div>
</div>
)
}
```
- [ ] **Step 4: Create `client/src/components/ChatArea.jsx`**
```jsx
import { useApp } from '../context/AppContext'
import MessageList from './MessageList'
import MessageInput from './MessageInput'
function colorToCss(color) {
if (!color) return '#4b5563'
return `rgb(${color[0]},${color[1]},${color[2]})`
}
export default function ChatArea() {
const { state, userId } = useApp()
const conn = state.connections.find(c => c.id === state.selectedId)
let header = null
if (conn) {
const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId
const other = state.userMap[otherId]
header = (
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-700/60 shrink-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0"
style={{ backgroundColor: colorToCss(other?.color) }}
>
{other?.name?.[0]?.toUpperCase() ?? '?'}
</div>
<div>
<p className="text-sm font-semibold text-white">{other?.name ?? '…'}</p>
{other?.pronouns && (
<p className="text-xs text-gray-500">{other.pronouns}</p>
)}
</div>
{conn.state === 1 && (
<span className="ml-auto text-xs text-green-400"> friend</span>
)}
</div>
)
}
return (
<div className="flex-1 flex flex-col bg-gray-800 min-w-0">
{header}
{state.selectedId ? (
<>
<MessageList />
<MessageInput />
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-600 text-sm">
Select a conversation
</div>
)}
</div>
)
}
```
---
## Task 8: End-to-End Verification
- [ ] **Step 1: Start the dev server**
In `client/`:
```bash
pnpm dev
```
Expected: Vite starts, no compile errors, app available at `http://localhost:5173`.
- [ ] **Step 2: Verify auth flow**
Navigate to `http://localhost:5173/auth`.
- Log in with valid credentials → redirected to `/`
- Sidebar shows your connections with avatar circles and names
- Unread badges appear for connections with unread messages
- [ ] **Step 3: Verify DM loading**
Click any connection in the sidebar.
- Right panel shows the contact name in the header
- Message history loads (oldest → newest, scrolled to bottom)
- Unread badge clears on the selected connection
- [ ] **Step 4: Verify sending**
Type a message in the input and press Enter.
- Message appears immediately (optimistic append)
- Opening a second browser tab as the recipient user → new message arrives via WebSocket without refresh
- [ ] **Step 5: Verify WS events**
From the second tab, send a message back.
- First tab receives it live in the open chat
- If a different connection is selected, the unread badge increments on the correct sidebar item
- [ ] **Step 6: Verify new connection**
Use the debug client (`machine-client/index.html`) or the second tab to create a new connection with the logged-in user.
- The new contact appears at the top of the sidebar without refresh