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

30 KiB
Raw Blame History

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 /connectionsConnection[]
  • GET /user?targetid=<uuid>{ name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] }
  • GET /connections/unreadmessages?connections=uuid,uuiduint32[] (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

// 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

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
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
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
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

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

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
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.

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
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
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.

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).

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
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
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/:

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