diff --git a/client/src/api/http.js b/client/src/api/http.js
new file mode 100644
index 0000000..368f48d
--- /dev/null
+++ b/client/src/api/http.js
@@ -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 }
+}
diff --git a/client/src/api/ws.js b/client/src/api/ws.js
new file mode 100644
index 0000000..6a4a361
--- /dev/null
+++ b/client/src/api/ws.js
@@ -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
+}
diff --git a/client/src/components/AuthPage.jsx b/client/src/components/AuthPage.jsx
index bf8c33d..001d11f 100644
--- a/client/src/components/AuthPage.jsx
+++ b/client/src/components/AuthPage.jsx
@@ -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 (
-
-
+ 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 (
+
+
+
go-socket
+
setUsername(e.target.value)}
+ onKeyDown={onKey}
+ autoComplete="username"
+ />
+
setPassword(e.target.value)}
+ onKeyDown={onKey}
+ autoComplete="current-password"
+ />
+ {error &&
{error}
}
+
+
+ Register
+
+
+ Login
+
- )
+
+
+ )
}
diff --git a/client/src/components/ChatArea.jsx b/client/src/components/ChatArea.jsx
new file mode 100644
index 0000000..a4c2a53
--- /dev/null
+++ b/client/src/components/ChatArea.jsx
@@ -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 = (
+
+
+ {other?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+
{other?.name ?? '…'}
+ {other?.pronouns && (
+
{other.pronouns}
+ )}
+
+ {conn.state === 1 && (
+
● friend
+ )}
+
+ )
+ }
+
+ return (
+
+ {header}
+ {state.selectedId ? (
+ <>
+
+
+ >
+ ) : (
+
+ Select a conversation
+
+ )}
+
+ )
+}
diff --git a/client/src/components/ConnectionItem.jsx b/client/src/components/ConnectionItem.jsx
new file mode 100644
index 0000000..2b8b160
--- /dev/null
+++ b/client/src/components/ConnectionItem.jsx
@@ -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 (
+
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'}`}
+ >
+
+ {other?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+
+
+
+ {other?.name ?? otherId.slice(0, 8)}
+
+ {isFriend && (
+ ● friend
+ )}
+
+ {other?.pronouns && (
+
{other.pronouns}
+ )}
+
+
+ {unread > 0 && (
+
+ {unread > 99 ? '99+' : unread}
+
+ )}
+
+ )
+}
diff --git a/client/src/components/ConnectionList.jsx b/client/src/components/ConnectionList.jsx
new file mode 100644
index 0000000..11da4d3
--- /dev/null
+++ b/client/src/components/ConnectionList.jsx
@@ -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 (
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {filtered.length === 0 && (
+
No contacts
+ )}
+ {filtered.map(conn => (
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/components/HubRow.jsx b/client/src/components/HubRow.jsx
new file mode 100644
index 0000000..80c5427
--- /dev/null
+++ b/client/src/components/HubRow.jsx
@@ -0,0 +1,13 @@
+export default function HubRow() {
+ return (
+
+
+ +
+
+
+ )
+}
diff --git a/client/src/components/MainApp.jsx b/client/src/components/MainApp.jsx
index 7b0b6cb..2a469c9 100644
--- a/client/src/components/MainApp.jsx
+++ b/client/src/components/MainApp.jsx
@@ -1,3 +1,14 @@
-export default function MainApp({ token }) {
- return
Main App
+import { AppProvider } from '../context/AppContext'
+import Sidebar from './Sidebar'
+import ChatArea from './ChatArea'
+
+export default function MainApp() {
+ return (
+
+
+
+
+
+
+ )
}
diff --git a/client/src/components/MessageInput.jsx b/client/src/components/MessageInput.jsx
new file mode 100644
index 0000000..24e8dc5
--- /dev/null
+++ b/client/src/components/MessageInput.jsx
@@ -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 (
+
+ )
+}
diff --git a/client/src/components/MessageItem.jsx b/client/src/components/MessageItem.jsx
new file mode 100644
index 0000000..277ad2d
--- /dev/null
+++ b/client/src/components/MessageItem.jsx
@@ -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 (
+
+ {showHeader ? (
+
+ {sender?.name?.[0]?.toUpperCase() ?? '?'}
+
+ ) : (
+
+ )}
+
+
+ {showHeader && (
+
+
+ {sender?.name ?? msg.sender.slice(0, 8)}
+
+ {formatTime(msg.createdAt)}
+
+ )}
+
{msg.content}
+
+
+
+ {formatTime(msg.createdAt)}
+
+
+ )
+}
diff --git a/client/src/components/MessageList.jsx b/client/src/components/MessageList.jsx
new file mode 100644
index 0000000..638f15e
--- /dev/null
+++ b/client/src/components/MessageList.jsx
@@ -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 (
+
+ {state.messages.map((msg, i) => {
+ const prev = state.messages[i - 1]
+ const showHeader = !prev || prev.sender !== msg.sender
+ return
+ })}
+
+
+ )
+}
diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx
new file mode 100644
index 0000000..5362484
--- /dev/null
+++ b/client/src/components/Sidebar.jsx
@@ -0,0 +1,13 @@
+import HubRow from './HubRow'
+import ConnectionList from './ConnectionList'
+import UserBar from './UserBar'
+
+export default function Sidebar() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/UserBar.jsx b/client/src/components/UserBar.jsx
new file mode 100644
index 0000000..f36a028
--- /dev/null
+++ b/client/src/components/UserBar.jsx
@@ -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 (
+
+
+ {user?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+ {user?.name ?? '…'}
+
+
+ ⚙
+
+
+ )
+}
diff --git a/client/src/context/AppContext.jsx b/client/src/context/AppContext.jsx
new file mode 100644
index 0000000..b0ef0b8
--- /dev/null
+++ b/client/src/context/AppContext.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function useApp() {
+ return useContext(Ctx)
+}
diff --git a/client/src/utils/color.js b/client/src/utils/color.js
new file mode 100644
index 0000000..c8d970a
--- /dev/null
+++ b/client/src/utils/color.js
@@ -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})`
+}
diff --git a/docs/superpowers/plans/2026-04-28-discord-client.md b/docs/superpowers/plans/2026-04-28-discord-client.md
new file mode 100644
index 0000000..b1efce5
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-28-discord-client.md
@@ -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 1–6 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=
` → `{ name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] }`
+- `GET /connections/unreadmessages?connections=uuid,uuid` → `uint32[]` (same order)
+- `GET /connection/messages?connectionid=` → `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 (
+
+
+
go-socket
+
setUsername(e.target.value)}
+ onKeyDown={onKey}
+ autoComplete="username"
+ />
+
setPassword(e.target.value)}
+ onKeyDown={onKey}
+ autoComplete="current-password"
+ />
+ {error &&
{error}
}
+
+
+ Register
+
+
+ Login
+
+
+
+
+ )
+}
+```
+
+- [ ] **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 (
+
+ {children}
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ )
+}
+```
+
+---
+
+## 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 (
+
+
+ +
+
+
+ )
+}
+```
+
+- [ ] **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 (
+
+
+ {user?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+ {user?.name ?? '…'}
+
+
+ ⚙
+
+
+ )
+}
+```
+
+---
+
+## 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 (
+ 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'}`}
+ >
+
+ {other?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+
+
+
+ {other?.name ?? otherId.slice(0, 8)}
+
+ {isFriend && (
+ ● friend
+ )}
+
+ {other?.pronouns && (
+
{other.pronouns}
+ )}
+
+
+ {unread > 0 && (
+
+ {unread > 99 ? '99+' : unread}
+
+ )}
+
+ )
+}
+```
+
+- [ ] **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 (
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {filtered.length === 0 && (
+
No contacts
+ )}
+ {filtered.map(conn => (
+
+ ))}
+
+
+ )
+}
+```
+
+- [ ] **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 (
+
+
+
+
+
+ )
+}
+```
+
+---
+
+## 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 (
+
+ {showHeader ? (
+
+ {sender?.name?.[0]?.toUpperCase() ?? '?'}
+
+ ) : (
+
+ )}
+
+
+ {showHeader && (
+
+
+ {sender?.name ?? msg.sender.slice(0, 8)}
+
+ {formatTime(msg.createdAt)}
+
+ )}
+
{msg.content}
+
+
+
+ {formatTime(msg.createdAt)}
+
+
+ )
+}
+```
+
+- [ ] **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 (
+
+ {state.messages.map((msg, i) => {
+ const prev = state.messages[i - 1]
+ const showHeader = !prev || prev.sender !== msg.sender
+ return
+ })}
+
+
+ )
+}
+```
+
+- [ ] **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 (
+
+ )
+}
+```
+
+- [ ] **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 = (
+
+
+ {other?.name?.[0]?.toUpperCase() ?? '?'}
+
+
+
{other?.name ?? '…'}
+ {other?.pronouns && (
+
{other.pronouns}
+ )}
+
+ {conn.state === 1 && (
+
● friend
+ )}
+
+ )
+ }
+
+ return (
+
+ {header}
+ {state.selectedId ? (
+ <>
+
+
+ >
+ ) : (
+
+ Select a conversation
+
+ )}
+
+ )
+}
+```
+
+---
+
+## 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
diff --git a/docs/superpowers/specs/2026-04-28-discord-client-design.md b/docs/superpowers/specs/2026-04-28-discord-client-design.md
new file mode 100644
index 0000000..385b05e
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-28-discord-client-design.md
@@ -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=` → overlay unread counts
+4. Open WebSocket `ws://localhost:8080/ws`, on open send `{"token": "..."}`
+
+**On connection selected:**
+1. `GET /connection/messages?connectionid=` → 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
diff --git a/go-socket b/go-socket
index 84a1554..d82637d 100755
Binary files a/go-socket and b/go-socket differ
diff --git a/machine-client/index.html b/machine-client/index.html
index 82b33a2..663f962 100644
--- a/machine-client/index.html
+++ b/machine-client/index.html
@@ -86,7 +86,9 @@
DELETE /connection
POST /connection/message
POST /hub
+ POST /hub/message
PUT /hub/join
+ GET /hubs
WS /ws
@@ -239,6 +241,16 @@
@@ -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)' },
diff --git a/main.go b/main.go
index 774f7b2..5fddb7f 100644
--- a/main.go
+++ b/main.go
@@ -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))
diff --git a/packages/httpRequest/get.go b/packages/httpRequest/get.go
index cc152d9..93f492a 100644
--- a/packages/httpRequest/get.go
+++ b/packages/httpRequest/get.go
@@ -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
diff --git a/packages/httpRequest/hubs.go b/packages/httpRequest/hubs.go
index 3265d4f..07de05a 100644
--- a/packages/httpRequest/hubs.go
+++ b/packages/httpRequest/hubs.go
@@ -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)
+}
diff --git a/packages/httpRequest/user.go b/packages/httpRequest/user.go
index b784d82..a900d26 100644
--- a/packages/httpRequest/user.go
+++ b/packages/httpRequest/user.go
@@ -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
diff --git a/packages/types/types.go b/packages/types/types.go
index 90143d3..411277d 100644
--- a/packages/types/types.go
+++ b/packages/types/types.go
@@ -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"`