taghyeer

0
0
0
1
TypeScript
public

Chat

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.

Part 1a — API documentation

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

Part 1b — the feature

Stack

React 19 (with the React Compiler, so no manual memo/useCallback noise) · TypeScript · Vite ·
TanStack Query · socket.io-client · React Router · Tailwind CSS v4.

Structure

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

Decisions worth calling out

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.

Known limitations

  • Unread counts are session-only. The API has no read state, so counts are tracked client-side
    and reset on reload. Fixing this properly needs a server change — see item 7 in
    redesign.md.
  • Users whose phone number starts with + cannot be found by number. Not a client bug: the
    server’s name-matching branch throws on a leading + before its exact-match phone branch can run,
    and no encoding avoids both. They remain findable by name.
  • Being removed from a group isn’t pushed. conversation:updated only reaches current members,
    so a removed user sees the group disappear on their next refetch rather than instantly.

Part 2 — the landing page

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

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 demo is the actual chat

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.

Design

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.


Part 3 — thought process

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.

v0.3.3[beta]