Real-time one-to-one and group messaging, built against the provided chat API.
Part 1 is the API documentation deliverable plus the chat feature itself; Part 2 is the
landing page that introduces it. The landing page is at /, the chat at /app.
pnpm install
pnpm dev
No .env is needed — the app points at the hosted API by default. To target a different
deployment, copy .env.example to .env and set VITE_API_ORIGIN.
The API is hosted on Render’s free tier and sleeps when idle. The first request after a quiet
period can take 40–60 seconds; the app detects this and shows a “waking up the server” hint
rather than looking broken.
The brief asked for the API documentation to be written first, as a standalone deliverable. It
lives in docs/api/:
| File | |
|---|---|
README.md |
Narrative reference — auth flow, conventions, error model, every endpoint, the WebSocket protocol, and a consolidated defects table |
openapi.yaml |
Complete OpenAPI 3.0 document, including the response schemas and status codes the official spec omits |
redesign.md |
How I would design this API instead, with rationale and priorities |
chat-api.postman_collection.json |
30 requests across 6 folders, with test scripts that chain auth and ids |
The vendor spec is explicitly request-only — it states that response bodies and status codes are
left undocumented on purpose, and that formalising them is part of the task. So rather than guessing,
I probed the live deployment end-to-end (including a raw Engine.IO client to capture the socket
contracts) and documented what it actually does.
That turned up 18 behaviours a client has to work around. The ones that shaped this codebase:
| Behaviour | Where it’s handled |
|---|---|
| Blank and whitespace-only messages are accepted by the server and persisted | Composer.tsx, useSendMessage.ts |
The before cursor is inclusive, so pages overlap by one message |
messages.ts, normalize.ts |
| History is newest-first, but every chat UI renders ascending | messages.ts |
message:new uses id + epoch-ms; REST uses _id + ISO strings |
normalize.ts |
| The socket never echoes to the sender | useSendMessage.ts |
q is injected raw into a regex — .* dumps the user table, + and ( cause 500s |
search.ts |
lastMessage is {} rather than null when empty |
normalize.ts |
POST /conversations returns an unpopulated stub unlike every other conversation response |
conversations.ts |
| A missing token returns 400, not 401 | http.ts |
| No unread counts exist anywhere in the API | RealtimeProvider.tsx |
React 19 (with the React Compiler, so no manual memo/useCallback noise) · TypeScript · Vite ·
TanStack Query · socket.io-client · React Router · Tailwind CSS v4.
src/
api/ one typed function per endpoint, returning domain types
lib/ http client, socket factory, search sanitising, formatting, storage
types/ wire types, domain types, and the normalisation boundary between them
components/ presentational primitives (Button, Modal, Avatar, states…)
features/
auth/ provider, login page, session restore
realtime/ socket lifecycle, cache fan-out, unread tracking
conversations/list, search dialogs, group management
messages/ thread, pagination, auto-scroll, composer, optimistic send
One normalisation boundary. The API is inconsistent in ways that would otherwise leak everywhere
— two message shapes, two timestamp formats, six response envelopes. types/normalize.ts is the
only module that knows about any of it; everything above works with clean domain types. Nothing
outside it imports an Api* type.
Realtime merges into the query cache, not into component state. message:new patches the
relevant conversation’s message cache and bumps it up the sidebar; conversation:updated patches the
conversation. A reconnect invalidates instead, so nothing is silently lost while the socket was down.
Auto-scroll is a hook with four distinct behaviours (useAutoScroll.ts):
opening a conversation jumps to the bottom instantly; a new message while pinned scrolls smoothly; a
new message while the reader is scrolled up does not move the viewport and instead raises a
“new messages” pill; and loading older messages restores the scroll offset so prepended history
doesn’t shove the viewport down.
Optimistic sends with real failure handling. A message appears immediately as sending, then
resolves to sent or failed with inline retry and discard. Because the socket doesn’t echo to the
sender, the HTTP response is the only copy — so there’s no duplicate to reconcile, but also no
safety net if it’s dropped.
Search is defensive by necessity. The endpoint interpolates the query into a regex, so the client
escapes metacharacters, refuses to fire on short input (an empty query returns every user in the
database), fans out a digits-only variant for phone-shaped input, and explains honestly when a
+-prefixed number can’t be matched at all rather than implying the person doesn’t exist.
Every async surface has four states. Loading (skeletons shaped like the real content, not
spinners), empty (distinct copy per context), error (with retry), and success. Plus the two states
unique to this backend: socket reconnecting, and server cold-starting.
+ cannot be found by number. Not a client bug: the+ before its exact-match phone branch can run,conversation:updated only reaches current members,/ is the landing page; /login is the sign-in; the chat lives under /app and /app/c/:id.
Route strings are centralised in paths.ts, and the socket only connects behind
the authenticated gate — a landing-page visitor opens no connection.
The page is a conversation. Not a page about a chat app — one continuous thread with a person
called Nour, running top to bottom. She asks; the product answers. The h1 is delivered as an
outgoing bubble, section headings are replies, and timestamps sit in the margin the way they do in
the app. A day divider separates the acts. It is the one visual device the product itself hands you,
and it means the page demonstrates the feature by existing rather than by describing it.
Below the hero the layout settles into a crafted grid, so the device stays a frame rather than
becoming a gimmick.
The centrepiece is not a screenshot or a reimplementation. It imports the app’s own
Composer,
MessageBubble,
buildRows and
useAutoScroll, and wires them to a local script instead
of the API — no query client, no socket, no auth, no network.
Because it is the real code, everything it shows is real behaviour: blank messages refuse to send,
sends go out optimistically and then settle, replies arrive on their own, days divide themselves.
Including the behaviour that is impossible to screenshot — scroll up mid-thread, send a message, and
the viewport holds still and offers a pill instead of dragging you down. A caption under the frame
names whichever behaviour you are currently triggering.
The opening line plays once, when the demo first scrolls into view; every scheduled beat is tracked
and cleared on unmount, so nothing keeps ticking in a background tab.
Same semantic tokens as the app, so the landing page follows light and dark exactly as the product
does, with no dark: variants anywhere. landing.css adds only what
marketing scale needs: a display type step, the ambient mesh, the reveal transition and two
keyframes.
One typeface. The headline lives inside a message bubble, so a second face would break the
illusion — it is Inter at 800/900 with tight tracking instead. No animation library: reveals are
one shared IntersectionObserver toggling an attribute the stylesheet transitions on, staggered by
a delay custom property. Under prefers-reduced-motion reveals resolve instantly, the mesh and the
typing dots stop, and the demo skips its scripted entrance.
The illustrations are built from Avatar, the real JumpToLatest pill and the theme tokens rather
than exported images, so they cannot drift out of date with the product. The states section renders
the actual Skeleton, EmptyState and ErrorState components — most landing pages hide those,
which felt like the wrong instinct for a page arguing that the states are the work.
docs/thought-process.md covers the reasoning behind the Part 1
architecture and Part 2 design choices, how AI tooling was used while building this, and what I’d
improve with more time.