add hub get logic and part of client

This commit is contained in:
2026-04-28 21:22:33 +02:00
parent a49f9f4615
commit 221fb47495
24 changed files with 1788 additions and 22 deletions
+13
View File
@@ -0,0 +1,13 @@
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 ? { token } : {} }
if (body) opts.body = new URLSearchParams(body)
const res = await fetch(url, opts)
if (!res.ok) { const errText = await res.text(); throw new Error(`${method} ${path}${res.status}: ${errText}`) }
const text = await res.text()
try { return JSON.parse(text) } catch { return text }
}
+26
View File
@@ -0,0 +1,26 @@
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 (err) { console.error('[ws] message error', err) }
}
socket.onclose = () => { socket = null }
socket.onerror = (err) => console.error('[ws] error', err)
}
export function wsDisconnect() {
socket?.close()
socket = null
}
+93 -16
View File
@@ -1,24 +1,101 @@
import { useNavigate } from "react-router-dom"
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const BASE = 'http://localhost:8080'
export default function AuthPage() {
const navigate = useNavigate()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
function handleLogin(token) {
localStorage.setItem("token", token)
navigate("/")
async function doLogin() {
const res = await fetch(`${BASE}/token`, {
method: 'POST',
body: new URLSearchParams({ username, password }),
})
if (!res.ok) throw new Error('Invalid credentials')
const { token, userId } = await res.json()
localStorage.setItem('token', token)
localStorage.setItem('userId', userId)
navigate('/')
}
async function handleLogin() {
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
setError('')
setLoading(true)
try {
await doLogin()
} catch {
setError('Invalid credentials')
} finally {
setLoading(false)
}
}
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col gap-4 p-8 bg-gray-800 rounded-lg">
<input className="bg-gray-700 text-white px-4 py-2 rounded" placeholder="Username" />
<input className="bg-gray-700 text-white px-4 py-2 rounded" placeholder="Password" type="password" />
<div className="flex justify-end gap-2 mt-4">
<button className="px-4 py-2 rounded bg-gray-700 hover:bg-gray-600">Register</button>
<button className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700">Login</button>
</div>
</div>
async function handleRegister() {
if (!username.trim() || !password.trim()) { setError('Username and password are required'); return }
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 doLogin()
} catch {
setError('Could not reach server')
} finally {
setLoading(false)
}
}
function onKey(e) {
if (e.key === 'Enter' && !loading) 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>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
import MessageList from './MessageList'
import MessageInput from './MessageInput'
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>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
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 min-w-0">
<span className="text-sm font-medium text-gray-100 truncate min-w-0">
{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>
)
}
+38
View File
@@ -0,0 +1,38 @@
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]
const haystack = (other?.name ?? otherId.slice(0, 8)).toLowerCase()
return haystack.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>
)
}
+13
View File
@@ -0,0 +1,13 @@
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 (coming soon)"
disabled
className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-500 text-lg leading-none cursor-not-allowed"
>
+
</button>
</div>
)
}
+13 -2
View File
@@ -1,3 +1,14 @@
export default function MainApp({ token }) {
return <div>Main App</div>
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>
)
}
+53
View File
@@ -0,0 +1,53 @@
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('')
try {
await sendMessage(state.selectedId, trimmed)
} catch {
setText(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>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
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>
)
}
+23
View File
@@ -0,0 +1,23 @@
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>
)
}
+13
View File
@@ -0,0 +1,13 @@
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 h-full bg-gray-900 border-r border-gray-700/60">
<HubRow />
<ConnectionList />
<UserBar />
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useApp } from '../context/AppContext'
import { colorToCss } from '../utils/color'
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)"
disabled
className="text-gray-600 cursor-not-allowed text-base px-1"
>
</button>
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
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 'REMOVE_MESSAGE':
return { ...state, messages: state.messages.filter(m => m.id !== action.id) }
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().catch(err => console.error('[AppContext] init failed', err))
return () => wsDisconnect()
}, [])
const selectConnection = useCallback(async (id) => {
dispatch({ type: 'SELECT_CONNECTION', id })
dispatch({ type: 'SET_MESSAGES', payload: [] })
try {
const msgs = await apiFetch('GET', '/connection/messages', { query: { connectionid: id } })
if (selectedIdRef.current === id) {
dispatch({ type: 'SET_MESSAGES', payload: msgs })
}
} catch (err) {
console.error('[AppContext] failed to load messages for', id, err)
}
}, [])
const sendMessage = useCallback(async (connectionId, content) => {
const uid = localStorage.getItem('userId')
const optimistic = {
id: crypto.randomUUID(),
content,
attachedFile: '',
createdAt: new Date().toISOString(),
sender: uid,
receiver: connectionId,
}
dispatch({ type: 'APPEND_MESSAGE', payload: optimistic })
try {
await apiFetch('POST', '/connection/message', { body: { connectionid: connectionId, msgContent: content } })
} catch (err) {
dispatch({ type: 'REMOVE_MESSAGE', id: optimistic.id })
throw err
}
}, [])
return (
<Ctx.Provider value={{ state, selectConnection, sendMessage, userId }}>
{children}
</Ctx.Provider>
)
}
export function useApp() {
return useContext(Ctx)
}
+6
View File
@@ -0,0 +1,6 @@
const FALLBACK_COLOR = '#4b5563'
export function colorToCss(color) {
if (!color) return FALLBACK_COLOR
return `rgba(${color[0]},${color[1]},${color[2]},${(color[3] ?? 255) / 255})`
}
@@ -0,0 +1,949 @@
# 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
@@ -0,0 +1,120 @@
# Discord-like Client Design
## Context
The go-socket backend has a full real-time messaging API (REST + WebSocket) but the React client is nearly empty — only `AuthPage` and an empty `MainApp` placeholder exist. This spec covers building the main app UI: a Discord-inspired layout with a connections/DM sidebar and chat panel, wired to the real backend.
## Layout
Two-column layout, full viewport height:
```
┌──────────────────────────────────────────────────────┐
│ [280px sidebar] │ [flex chat area] │
│ │ │
│ ○ ○ ○ + ← hub row │ Contact Name │
│ ───────────────── │ ──────────────── │
│ Search... │ │
│ ▸ Contact A [3] │ [message history] │
│ ▸ Contact B │ │
│ ▸ ... │ ────────────────────── │
│ ───────────────── │ [ type a message... ] ➤ │
│ [avatar] You ⚙ │ │
└──────────────────────────────────────────────────────┘
```
**Sidebar (280px):**
- Hub row: horizontal strip of small icon circles + a `+` stub button (empty for now, layout ready for hubs)
- Search input to filter the connections list
- Scrollable connections list: colored avatar circle (initial fallback), name, unread badge, last-message preview
- Bottom bar: current user avatar + username + settings gear (stub — no action yet)
**Chat area:**
- Header bar: contact name + pronouns
- Scrollable message history (newest at bottom)
- Message input + send button
## Components
```
src/
context/
AppContext.jsx — connections, selectedId, messages, currentUser, ws ref
components/
MainApp.jsx — root layout, mounts context provider
Sidebar.jsx — hub row + search + connection list + user bar
HubRow.jsx — stub hub icons + add button
ConnectionList.jsx — filtered, sorted list of ConnectionItem
ConnectionItem.jsx — single row: avatar, name, unread badge, last message
UserBar.jsx — bottom of sidebar: own avatar, name, settings gear stub
ChatArea.jsx — header + message list + input bar
MessageList.jsx — scrollable history, auto-scrolls to bottom on new message
MessageItem.jsx — single message bubble: sender avatar, content, timestamp
MessageInput.jsx — textarea + send button, Enter to send
api/
http.js — thin fetch wrapper; reads token from localStorage; sends token header
ws.js — WebSocket singleton: connect, send, onMessage dispatcher
```
## Data Flow
**On MainApp mount:**
1. Read `token` + `userId` from localStorage (App.jsx already guards this route)
2. `GET /connections` → load sidebar list
3. `GET /connections/unreadmessages?connections=<ids>` → overlay unread counts
4. Open WebSocket `ws://localhost:8080/ws`, on open send `{"token": "..."}`
**On connection selected:**
1. `GET /connection/messages?connectionid=<id>` → render history
2. Clear unread badge locally for that connection
**Sending a message:**
1. `POST /connection/message` body: `connectionid`, `msgContent`
2. Optimistically append to message list
**Incoming WebSocket events:**
| Type | Action |
|------|--------|
| 1 — DirectMessage | Append to open chat if matches; else bump unread badge |
| 2 — ConnectionCreated | Prepend new connection to sidebar |
| 3 — ConnectionDeleted | Remove from sidebar; clear chat if open |
| 4/5 — Elevated/DeElevated | Update connection `state` field (Friend/Stranger cosmetic label) |
| 6/7/8 — Profile/Avatar/BgChange | Refresh contact display data |
## API Reference
All requests send `token` as a plain header (not Bearer). Token and userId stored in localStorage from login.
| Endpoint | Usage |
|----------|-------|
| `GET /connections` | Load sidebar list on mount |
| `GET /connections/unreadmessages?connections=UUID,UUID` | Unread counts array |
| `GET /connection/messages?connectionid=UUID` | Load history for selected DM |
| `POST /connection/message` | Send message (body: connectionid, msgContent) |
| `WS /ws` → send `{"token":"..."}` | Authenticate WebSocket |
## Design Aesthetic
- Dark theme (`bg-gray-900` base, matching existing `index.css`)
- Natural, human-designed feel — no generic AI look
- User-assigned color (RGBA from server) used as avatar background and accent
- Subtle hover/active states, no heavy borders
- Tailwind utility classes, no CSS files
## Out of Scope (this spec)
- Hub channels and hub management
- User profile/customization UI (gear stub present, no action)
- File attachments
- Friend request flow (elevate/deelevate)
- Connection creation UI
## Verification
1. `pnpm dev` in `client/` — app loads at localhost:5173
2. Log in with existing credentials → redirects to MainApp
3. Sidebar shows real connections from the server
4. Clicking a connection loads message history
5. Sending a message appears in the chat
6. Open a second browser tab as another user — send a message → first tab receives it via WebSocket without refresh
7. Unread badges update when a message arrives for a non-selected connection
BIN
View File
Binary file not shown.
+20
View File
@@ -86,7 +86,9 @@
<button data-form="del-connection" class="warn" onclick="showForm('del-connection')">DELETE /connection</button>
<button data-form="msg-user" onclick="showForm('msg-user')">POST /connection/message</button>
<button data-form="hub-create" onclick="showForm('hub-create')">POST /hub</button>
<button data-form="hub-message" onclick="showForm('hub-message')">POST /hub/message</button>
<button data-form="hub-join" onclick="showForm('hub-join')">PUT /hub/join</button>
<button data-form="get-hubs" onclick="showForm('get-hubs')">GET /hubs</button>
<button data-form="websocket" onclick="showForm('websocket')">WS /ws</button>
</div>
@@ -239,6 +241,16 @@
<div class="form-actions"><button class="send" onclick="submit('hub-create')">Send</button></div>
</div>
<!-- POST /hub/message -->
<div class="form-content" id="fc-hub-message">
<div class="field"><label>token</label><input id="hm-token" placeholder=""></div>
<div class="field"><label>hubid</label><input id="hm-hubid" placeholder="UUID"></div>
<div class="field"><label>channelid</label><input id="hm-channelid" placeholder="UUID"><span class="hint">sent as header</span></div>
<div class="field"><label>msgContent</label><input id="hm-msgContent" placeholder="message text (optional if file set)"></div>
<div class="field"><label>attachedFile</label><input id="hm-attachedFile" placeholder="key from POST /file (optional)"></div>
<div class="form-actions"><button class="send" onclick="submit('hub-message')">Send</button></div>
</div>
<!-- PUT /hub/join -->
<div class="form-content" id="fc-hub-join">
<div class="field"><label>token</label><input id="hj-token" placeholder=""></div>
@@ -246,6 +258,12 @@
<div class="form-actions"><button class="send" onclick="submit('hub-join')">Send</button></div>
</div>
<!-- GET /hubs -->
<div class="form-content" id="fc-get-hubs">
<div class="field"><label>token</label><input id="gh-token" placeholder=""></div>
<div class="form-actions"><button class="send" onclick="submit('get-hubs')">Send</button></div>
</div>
<!-- WS /ws -->
<div class="form-content" id="fc-websocket">
<div class="form-actions" style="margin-bottom:10px">
@@ -287,7 +305,9 @@
'del-connection': { method:'DELETE', path:'/connection', title:'DELETE /connection — delete a connection', fields:[{id:'dc-token',dest:'header',name:'token'},{id:'dc-connectionid',dest:'query',name:'connectionid'}] },
'msg-user': { method:'POST', path:'/connection/message', title:'POST /connection/message — send direct message', fields:[{id:'mu-token',dest:'header',name:'token'},{id:'mu-connectionid',dest:'body',name:'connectionid'},{id:'mu-msgContent',dest:'body',name:'msgContent'},{id:'mu-attachedFile',dest:'body',name:'attachedFile'}] },
'hub-create': { method:'POST', path:'/hub', title:'POST /hub — create a new hub', fields:[{id:'hc-token',dest:'header',name:'token'},{id:'hc-hubname',dest:'body',name:'hubname'}] },
'hub-message': { method:'POST', path:'/hub/message', title:'POST /hub/message — send hub channel message', fields:[{id:'hm-token',dest:'header',name:'token'},{id:'hm-hubid',dest:'body',name:'hubid'},{id:'hm-channelid',dest:'header',name:'channelid'},{id:'hm-msgContent',dest:'body',name:'msgContent'},{id:'hm-attachedFile',dest:'body',name:'attachedFile'}] },
'hub-join': { method:'PUT', path:'/hub/join', title:'PUT /hub/join — join hub (hubid as header)', fields:[{id:'hj-token',dest:'header',name:'token'},{id:'hj-hubid',dest:'header',name:'hubid'}] },
'get-hubs': { method:'GET', path:'/hubs', title:'GET /hubs — get own hubs', fields:[{id:'gh-token',dest:'header',name:'token'}] },
'mod-user-avatar': { title:'PATCH /user/avatar — set avatar image' },
'mod-user-profilebg': { title:'PATCH /user/profilebg — set profile background' },
'file-upload': { title:'POST /file — upload file (multipart)' },
+3 -1
View File
@@ -46,7 +46,7 @@ func main() {
http.HandleFunc("POST /token", withCORS(httpRequest.HandleUserNewToken))
http.HandleFunc("POST /connection", withCORS(httpRequest.HandleUserNewConnection))
http.HandleFunc("GET /connection", withCORS(httpRequest.HandleUserNewConnection))
http.HandleFunc("DELETE /connection", withCORS(httpRequest.HandleUserDeleteConnection))
http.HandleFunc("POST /connection/elevate", withCORS(httpRequest.HandleUserElevateConnection))
http.HandleFunc("POST /connection/deelevate", withCORS(httpRequest.HandleUserDeElevateConnection))
@@ -58,6 +58,8 @@ func main() {
http.HandleFunc("GET /file", withCORS(httpRequest.HandleAttachmentFileDownload))
http.HandleFunc("POST /hub", withCORS(httpRequest.HandleHubCreate))
http.HandleFunc("POST /hub/message", withCORS(httpRequest.HandleHubMessage))
http.HandleFunc("GET /hubs", withCORS(httpRequest.HandleGetHubs))
http.HandleFunc("PUT /hub/join", withCORS(httpRequest.HandleHubJoin))
http.HandleFunc("POST /connection/message", withCORS(httpRequest.HandleDm))
+1 -1
View File
@@ -18,7 +18,7 @@ import (
func getUserById(ctx context.Context, userId uuid.UUID) (*types.User, error) {
user, err := cache.GetUserById(userId)
if err != nil {
user = &types.User{Id: userId}
user = &types.User{Id: userId, Hubs: make(map[uuid.UUID]*types.Hub)}
err = postgresql.GetWholeUser(ctx, user)
if err != nil {
return nil, err
+31
View File
@@ -1,7 +1,10 @@
package httpRequest
import (
"encoding/json"
"maps"
"net/http"
"slices"
"strings"
"time"
@@ -80,6 +83,7 @@ func HandleHubCreate(response http.ResponseWriter, request *http.Request) {
hub.Id = uuid.New()
hub.Creator = user.Id
hub.CreatedAt = time.Now()
user.Hubs[user.Id] = hub
creator := types.NewHubUser()
creator.OriginalId = user.Id
@@ -211,3 +215,30 @@ func HandleHubMessage(response http.ResponseWriter, request *http.Request) {
wsServer.WsSendMessageCloseIfTimeout(target, msg)
}
}
func HandleGetHubs(response http.ResponseWriter, request *http.Request) {
if !validCheckWithResponseOnFail(&response, request, normal) {
return
}
ctx := request.Context()
user, err := getUserByToken(ctx, request.Header.Get("token"))
if err != nil {
http.Error(response, "invalid token", http.StatusBadRequest)
return
}
user.Mu.RLock()
hubs := slices.Collect(maps.Values(user.Hubs))
user.Mu.RUnlock()
if len(hubs) == 0 {
response.WriteHeader(http.StatusNoContent)
response.Write([]byte("no hubs found"))
}
converted, err := json.Marshal(hubs)
if err != nil {
http.Error(response, "json error", http.StatusInternalServerError)
return
}
response.WriteHeader(http.StatusOK)
response.Write(converted)
}
+3 -1
View File
@@ -17,6 +17,8 @@ import (
"go-socket/packages/wsServer"
"golang.org/x/crypto/bcrypt"
"github.com/google/uuid"
)
func HandleUserNewToken(response http.ResponseWriter, request *http.Request) {
@@ -45,7 +47,7 @@ func HandleUserNewToken(response http.ResponseWriter, request *http.Request) {
user, err = cache.GetUserByName(username)
if err != nil {
user = &types.User{Name: username}
user = &types.User{Name: username, Hubs: make(map[uuid.UUID]*types.Hub)}
if err = postgresql.UserGetStandardInfoByName(ctx, user); err != nil {
http.Error(response, "bad login", http.StatusUnauthorized)
return
+2 -1
View File
@@ -37,6 +37,7 @@ type User struct {
WsConn *websocket.Conn `json:"-"`
Id uuid.UUID `json:"-"`
Connections map[uuid.UUID]*Connection `json:"-"`
Hubs map[uuid.UUID]*Hub `json:"-"`
Color Rgba `json:"color"`
}
@@ -233,7 +234,7 @@ type Hub struct {
Roles [256]*HubRole `json:"-"`
Users map[uuid.UUID]*HubUser `json:"-"`
Groups [256]*HubGroup `json:"-"`
Channels map[uuid.UUID]*HubChannel `json:"channels"`
Channels map[uuid.UUID]*HubChannel `json:"-"`
Name string `json:"name"`
IconUrl string `json:"iconUrl"`
BgUrl string `json:"backgroundUrl"`